2 Commits
Author SHA1 Message Date
Roman affc97c527 Choix de la langue VF/VOSTFR sur la fiche titre
- Episode : nouveau champ version (vf/vostfr)
- French-Manga : épisodes listés pour chaque langue disponible
  (VOSTFR puis VF) au lieu d'un choix automatique, fragment
  #ep=<version>-<num> déjà résolu par extract_embed_links
- Vostfree : langue déduite du titre h1 ou du slug de la fiche
- UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode,
  téléchargement de saison limité à la langue filtrée, compteur
  d'épisodes unique, version incluse dans le nom de fichier
2026-09-22 11:58:10 +00:00
Roman 9148b5fb6a Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker
- Détection de version via l'API Gitea, application via Watchtower
- Router système, UI admin (intégrations), bandeau de mise à jour
- Dockerfile multi-étapes, docker-compose, scripts/release.sh
- Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
2026-09-22 11:58:03 +00:00
25 changed files with 1184 additions and 72 deletions
+20
View File
@@ -0,0 +1,20 @@
# Contexte de build minimal : ni secrets, ni données, ni caches
.git
.gitignore
.env
.env.example
.venv
.plasma
.pytest_cache
.ruff_cache
data/
downloads/
tests/
__pycache__/
*.pyc
Dockerfile
docker-compose.yml
docker-compose.yml.example
README.md
Projet_descriptions.md
scripts/
+10 -1
View File
@@ -1,4 +1,4 @@
# Copier en .env et adapter. Toutes les variables sont préfixées OHM_. # Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères) # OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
OHM_SECRET_KEY=change-me-in-production OHM_SECRET_KEY=change-me-in-production
@@ -23,3 +23,12 @@ OHM_SECRET_KEY=change-me-in-production
# OHM_REFRESH_TOKEN_TTL_DAYS=30 # OHM_REFRESH_TOKEN_TTL_DAYS=30
# OHM_DEBUG=false # OHM_DEBUG=false
# ── Déploiement Docker (docker-compose.yml) ────────────────────────────────
# Secret partagé entre OhmStreaming et Watchtower pour déclencher les mises
# à jour depuis la page Admin. OBLIGATOIRE en Docker.
# Générer : openssl rand -hex 24
WATCHTOWER_TOKEN=change-me-watchtower
# Port hôte exposé par docker compose (défaut 8777)
# OHM_PORT=8777
+56
View File
@@ -0,0 +1,56 @@
# ---------------------------------------------------------------------------
# Étape 1 — dépendances Python via uv (cache couche par couche)
# ---------------------------------------------------------------------------
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /opt/ohm
# D'abord les métadonnées seules : couche réutilisable tant que uv.lock ne bouge pas
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project --no-cache
# Puis le code
COPY app ./app
RUN uv sync --frozen --no-dev --no-cache
# ---------------------------------------------------------------------------
# Étape 2 — image d'exécution minimale
# ---------------------------------------------------------------------------
FROM python:3.13-slim-bookworm
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
# gosu (bascule utilisateur non-root dans l'entrypoint)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/*
# Utilisateur non-root
RUN useradd --create-home --uid 1000 ohm
WORKDIR /opt/ohm
COPY --from=builder --chown=ohm:ohm /opt/ohm/.venv ./.venv
COPY --chown=ohm:ohm app ./app
COPY --chown=ohm:ohm docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV PATH="/opt/ohm/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
# Chemins montés en volumes par docker-compose
OHM_DATA_DIR=/data \
OHM_DOWNLOAD_DIR=/downloads \
OHM_DATABASE_PATH=/data/ohm.db
# Version cuite dans l'image par scripts/release.sh (build-arg VERSION)
ARG VERSION=dev
ENV OHM_VERSION=${VERSION}
RUN mkdir -p /data /downloads && chown -R ohm:ohm /opt/ohm /data /downloads
# Root par défaut : l'entrypoint chown les volumes puis passe en « ohm »
EXPOSE 8777
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8777/health', timeout=4)"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8777"]
+51 -7
View File
@@ -3,12 +3,51 @@
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir, Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
regarder et télécharger des animes et séries VOSTFR/VF. regarder et télécharger des animes et séries VOSTFR/VF.
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire ## Déploiement (Docker) — recommandé
> de téléchargements temps réel, bibliothèque locale, favoris, administration.
> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab**
> et personnalise « Pour toi » avec vos téléchargements Sonarr.
## Démarrage rapide Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
docker login git.lanro.eu
# 2. Configuration locale
cp .env.example .env
# → OHM_SECRET_KEY (openssl rand -hex 32) et WATCHTOWER_TOKEN (openssl rand -hex 24) obligatoires
# 3. Démarrage
docker compose up -d
```
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
mises à jour. Port hôte modifiable via `OHM_PORT` dans `.env`.
### Mettre à jour
**Depuis l'interface** (déploiement Docker) : page **Admin → Mise à jour** —
configurer une fois le dépôt Gitea + un jeton d'accès (droit lecture), puis
« Vérifier » et « ⬆ Mettre à jour maintenant ». Watchtower tire la nouvelle
image et recrée le conteneur : quelques secondes d'indisponibilité, les pages
ouvertes se reconnectent et rechargent automatiquement.
**En ligne de commande** (toujours possible) :
```bash
docker compose pull && docker compose up -d
```
### Publier une version (mainteneur)
```bash
./scripts/release.sh 0.2.0
```
Bump de version, commit + tag git, build de l'image et push vers
`git.lanro.eu/roman/ohm_streaming` (tags `0.2.0` et `latest`).
## Démarrage rapide (développement)
```bash ```bash
uv sync uv sync
@@ -16,7 +55,8 @@ uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777 uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
``` ```
Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'` Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**. Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
@@ -30,11 +70,15 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés | | `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite | | `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés | | `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés |
| `OHM_WATCHTOWER_URL` | *(vide)* | URL Watchtower pour la mise à jour (réglé par docker-compose) |
| `OHM_WATCHTOWER_TOKEN` | *(vide)* | Jeton partagé Watchtower (réglé par docker-compose) |
| `OHM_VERSION` | *(pyproject)* | Version affichée — cuite dans l'image Docker au build |
## Fonctionnalités ## Fonctionnalités
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source - **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
est un module interchangeable activable/désactivable à chaud (page Admin). est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
est modifiable à la volée (utile si un site change de domaine).
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe - **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo). (Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS. - **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
+4
View File
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
kitsu_base_url: str = "https://kitsu.io/api/edge" kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72 metadata_cache_ttl_hours: int = 72
# Mise à jour (déploiement Docker — Watchtower compagnon)
watchtower_url: str = ""
watchtower_token: str = ""
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True) self.data_dir.mkdir(parents=True, exist_ok=True)
self.download_dir.mkdir(parents=True, exist_ok=True) self.download_dir.mkdir(parents=True, exist_ok=True)
+15 -1
View File
@@ -9,10 +9,22 @@ from fastapi.staticfiles import StaticFiles
from app.config import BASE_DIR, get_settings from app.config import BASE_DIR, get_settings
from app.db import db from app.db import db
from app.logging_config import setup_logging from app.logging_config import setup_logging
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab from app.routers import (
admin,
auth,
discover,
downloads,
library,
pages,
proxy,
search,
system,
torznab,
)
from app.scrapers.http import close_client from app.scrapers.http import close_client
from app.services.discover import discover as discover_service from app.services.discover import discover as discover_service
from app.services.downloads import download_manager from app.services.downloads import download_manager
from app.services.settings import apply_source_base_urls
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,6 +42,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings() settings = get_settings()
setup_logging(settings.debug) setup_logging(settings.debug)
await db.connect() await db.connect()
await apply_source_base_urls()
await download_manager.start() await download_manager.start()
warmup = asyncio.create_task(discover_service.latest()) warmup = asyncio.create_task(discover_service.latest())
warmup.add_done_callback(_log_task_error) warmup.add_done_callback(_log_task_error)
@@ -46,6 +59,7 @@ def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router) app.include_router(torznab.router)
app.include_router(system.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(pages.protected) app.include_router(pages.protected)
+96 -9
View File
@@ -1,7 +1,8 @@
"""Administration : utilisateurs, activation des sources, santé.""" """Administration : utilisateurs, activation des sources, santé, mises à jour."""
import asyncio import asyncio
import logging import logging
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
@@ -9,17 +10,29 @@ from pydantic import BaseModel
from app import auth from app import auth
from app.db import db from app.db import db
from app.routers.auth import AdminUser from app.routers.auth import AdminUser
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers from app.scrapers.base import (
ScrapeError,
SourceScraper,
all_sources,
get_source,
import_all_scrapers,
)
from app.services.discover import discover from app.services.discover import discover
from app.services.settings import ( from app.services.settings import (
get_sonarr_config, get_sonarr_config,
get_source_health,
get_torznab_apikey, get_torznab_apikey,
is_source_enabled, is_source_enabled,
reset_torznab_apikey, reset_torznab_apikey,
set_sonarr_config, set_sonarr_config,
set_source_base_url,
set_source_enabled, set_source_enabled,
set_source_health,
set_update_config,
) )
from app.services.sonarr import sonarr from app.services.sonarr import sonarr
from app.services.update import UpdateError, fetch_latest_version, trigger_update
from app.services.update import status as update_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,26 +105,62 @@ class SourceToggle(BaseModel):
enabled: bool enabled: bool
def _source_or_404(name: str) -> SourceScraper:
try:
return get_source(name)
except ScrapeError as exc:
raise HTTPException(404, str(exc)) from exc
@router.post("/sources/{name}/toggle") @router.post("/sources/{name}/toggle")
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict: async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
get_source(name) # 404 implicite si inconnue _source_or_404(name)
await set_source_enabled(name, payload.enabled) await set_source_enabled(name, payload.enabled)
return {"name": name, "enabled": payload.enabled} return {"name": name, "enabled": payload.enabled}
class SourceUrlUpdate(BaseModel):
url: str
@router.put("/sources/{name}/url")
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
source = _source_or_404(name)
default = type(source).base_url
url = payload.url.strip().rstrip("/")
if url and url != default:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
await set_source_base_url(name, url)
else:
url = default
await set_source_base_url(name, None)
source.base_url = url
logger.info("URL de la source %s : %s", name, url)
return {
"name": name,
"base_url": url,
"default_base_url": default,
"overridden": url != default,
}
@router.post("/sources/{name}/health") @router.post("/sources/{name}/health")
async def health_check(name: str, admin: AdminUser) -> dict: async def health_check(name: str, admin: AdminUser) -> dict:
"""Test de santé manuel : la source doit répondre à une recherche simple.""" """Test de santé manuel : la source doit répondre à une recherche simple."""
source = get_source(name) source = _source_or_404(name)
try: try:
results = await asyncio.wait_for(source.search("naruto"), timeout=30) results = await asyncio.wait_for(source.search("naruto"), timeout=30)
healthy = len(results) > 0 healthy, detail = len(results) > 0, f"{len(results)} résultats"
detail = f"{len(results)} résultats"
except (ScrapeError, TimeoutError) as exc: except (ScrapeError, TimeoutError) as exc:
healthy = False healthy, detail = False, str(exc)[:200]
detail = str(exc)[:200]
logger.error("Health check %s KO : %s", name, exc) logger.error("Health check %s KO : %s", name, exc)
return {"name": name, "healthy": healthy, "detail": detail} except Exception as exc:
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
logger.exception("Health check %s : erreur inattendue", name)
state = await set_source_health(name, healthy, detail)
return {"name": name, **state}
@router.get("/sources") @router.get("/sources")
@@ -121,7 +170,10 @@ async def sources_status(admin: AdminUser) -> list[dict]:
"name": s.name, "name": s.name,
"label": s.label, "label": s.label,
"base_url": s.base_url, "base_url": s.base_url,
"default_base_url": type(s).base_url,
"overridden": s.base_url != type(s).base_url,
"enabled": await is_source_enabled(s.name), "enabled": await is_source_enabled(s.name),
"health": await get_source_health(s.name),
} }
for s in all_sources() for s in all_sources()
] ]
@@ -166,3 +218,38 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
@router.post("/integrations/sonarr/test") @router.post("/integrations/sonarr/test")
async def test_sonarr(admin: AdminUser) -> dict: async def test_sonarr(admin: AdminUser) -> dict:
return await sonarr.test_connection() return await sonarr.test_connection()
# ---------------------------------------------------------------- mise à jour logicielle
class UpdateConfig(BaseModel):
gitea_url: str
repo: str
token: str
@router.get("/update")
async def get_update(admin: AdminUser) -> dict:
"""Version courante, dernière version disponible et configuration Gitea."""
return await update_status()
@router.put("/update")
async def save_update(payload: UpdateConfig, admin: AdminUser) -> dict:
await set_update_config(payload.gitea_url, payload.repo, payload.token)
return await update_status()
@router.post("/update/check")
async def check_update(admin: AdminUser) -> dict:
"""Force la re-vérification de la dernière version (ignore le cache)."""
await fetch_latest_version(force=True)
return await update_status()
@router.post("/update/apply")
async def apply_update(admin: AdminUser) -> dict:
"""Déclenche la mise à jour via Watchtower (le conteneur est recréé)."""
try:
return await trigger_update()
except UpdateError as exc:
raise HTTPException(502, str(exc)) from exc
+14
View File
@@ -0,0 +1,14 @@
"""Endpoints système publics : version (utilisée par le frontend pour détecter
une mise à jour et recharger la page automatiquement)."""
from fastapi import APIRouter
from app.config import get_settings
from app.version import get_version
router = APIRouter(prefix="/api", tags=["system"])
@router.get("/version")
async def version() -> dict[str, str]:
return {"name": get_settings().app_name, "version": get_version()}
+1 -1
View File
@@ -38,7 +38,7 @@ class Episode:
title: str | None title: str | None
url: str # page de l'épisode chez la source url: str # page de l'épisode chez la source
season: int = 1 season: int = 1
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
@dataclass @dataclass
class TitleDetails: class TitleDetails:
+4 -5
View File
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
if season_match: if season_match:
season = int(season_match.group(1)) season = int(season_match.group(1))
numbers: set[float] = set() info = data.get("info") or {}
episodes: list[Episode] = []
for version in versions: for version in versions:
numbers: set[float] = set()
for number_text in data.get(version) or {}: for number_text in data.get(version) or {}:
try: try:
numbers.add(float(number_text)) numbers.add(float(number_text))
except ValueError: except ValueError:
logger.warning("french_manga : numéro d'épisode invalide %r", number_text) logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
info = data.get("info") or {}
episodes: list[Episode] = []
for number in sorted(numbers): for number in sorted(numbers):
number_text = str(int(number)) if number.is_integer() else str(number) number_text = str(int(number)) if number.is_integer() else str(number)
episode_info = info.get(number_text) or info.get(str(int(number))) episode_info = info.get(number_text) or info.get(str(int(number)))
label = episode_info.get("title") if isinstance(episode_info, dict) else None label = episode_info.get("title") if isinstance(episode_info, dict) else None
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
episodes.append( episodes.append(
Episode( Episode(
number=number, number=number,
title=label, title=label,
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}", url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
season=season, season=season,
version=version,
) )
) )
if not episodes: if not episodes:
+12
View File
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)") _NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE) _SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") _SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
def _merged_config() -> dict: def _merged_config() -> dict:
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
return int(season_match.group(1)) return int(season_match.group(1))
return 1 return 1
def _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
config = _merged_config()
title_el = soup.select_one(config["details"]["title"])
for text in (self._text(title_el), page_url):
match = _VERSION_RE.search(text)
if match:
return match.group(1).lower()
return None
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]: def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
config = _merged_config() config = _merged_config()
version = self._detect_version(soup, page_url)
options = soup.select(config["episodes"]["option"]) options = soup.select(config["episodes"]["option"])
entries: list[tuple[str, str]] = [] entries: list[tuple[str, str]] = []
seen_ids: set[str] = set() seen_ids: set[str] = set()
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
title=label or f"Episode {index}", title=label or f"Episode {index}",
url=f"{page_url}#{button_id}" if button_id else page_url, url=f"{page_url}#{button_id}" if button_id else page_url,
season=season, season=season,
version=version,
) )
) )
return episodes return episodes
+65 -1
View File
@@ -1,7 +1,8 @@
"""Paramètres persistés en DB (activation des sources, réglages UI…).""" """Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
import json import json
import logging import logging
import secrets import secrets
from datetime import UTC, datetime
from app.db import db from app.db import db
@@ -35,6 +36,48 @@ async def set_source_enabled(name: str, enabled: bool) -> None:
await set_setting(f"source:{name}:enabled", enabled) await set_setting(f"source:{name}:enabled", enabled)
logger.info("Source %s %s", name, "activée" if enabled else "désactivée") logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
async def get_source_health(name: str) -> dict | None:
"""Dernier état de santé connu d'une source (None si jamais testée)."""
value = await get_setting(f"source:{name}:health")
return value if isinstance(value, dict) else None
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
state = {
"healthy": healthy,
"detail": detail,
"checked_at": datetime.now(UTC).isoformat(),
}
await set_setting(f"source:{name}:health", state)
return state
async def get_source_base_url(name: str) -> str | None:
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
value = await get_setting(f"source:{name}:base_url")
return value if isinstance(value, str) and value else None
async def set_source_base_url(name: str, url: str | None) -> None:
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
key = f"source:{name}:base_url"
if url:
await set_setting(key, url)
else:
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
async def apply_source_base_urls() -> None:
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
from app.scrapers.base import all_sources
for source in all_sources():
override = await get_source_base_url(source.name)
if override and override != type(source).base_url:
source.base_url = override
logger.info("URL personnalisée pour %s : %s", source.name, override)
# ---------------------------------------------------------------- intégrations *arr # ---------------------------------------------------------------- intégrations *arr
TORZNAB_APIKEY_KEY = "torznab:apikey" TORZNAB_APIKEY_KEY = "torznab:apikey"
@@ -70,3 +113,24 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_URL_KEY, url.rstrip("/")) await set_setting(SONARR_URL_KEY, url.rstrip("/"))
await set_setting(SONARR_APIKEY_KEY, apikey.strip()) await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url) logger.info("Configuration Sonarr enregistrée (%s)", url)
# ---------------------------------------------------------------- mise à jour logicielle
UPDATE_GITEA_URL_KEY = "update:gitea_url"
UPDATE_REPO_KEY = "update:repo"
UPDATE_TOKEN_KEY = "update:token"
async def get_update_config() -> dict[str, str]:
return {
"gitea_url": await get_setting(UPDATE_GITEA_URL_KEY, "https://git.lanro.eu"),
"repo": await get_setting(UPDATE_REPO_KEY, "Roman/ohm_streaming"),
"token": await get_setting(UPDATE_TOKEN_KEY, ""),
}
async def set_update_config(gitea_url: str, repo: str, token: str) -> None:
await set_setting(UPDATE_GITEA_URL_KEY, gitea_url.rstrip("/"))
await set_setting(UPDATE_REPO_KEY, repo.strip().strip("/"))
await set_setting(UPDATE_TOKEN_KEY, token.strip())
logger.info("Configuration de mise à jour enregistrée (%s)", repo)
+113
View File
@@ -0,0 +1,113 @@
"""Mises à jour logicielles.
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
image et recrée le conteneur — quelques secondes d'indisponibilité.
"""
import logging
import re
import time
import httpx
from app.config import get_settings
from app.services.settings import get_update_config
from app.version import get_version
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_CACHE_TTL = 300.0 # secondes
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None}
class UpdateError(Exception):
"""Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO)."""
def parse_tag(tag: str) -> tuple[int, int, int] | None:
"""'v0.2.1' → (0, 2, 1) ; None si le tag n'est pas un semver strict."""
m = _TAG_RE.match(tag.strip())
return tuple(int(g) for g in m.groups()) if m else None # type: ignore[return-value]
def is_newer(latest: str, current: str) -> bool:
a, b = parse_tag(latest), parse_tag(current)
if a is None or b is None:
return False
return a > b
async def fetch_latest_version(*, force: bool = False) -> str | None:
"""Dernier tag semver du dépôt (cache 5 min). None si non configuré ou erreur."""
now = time.monotonic()
latest_cache = _cache["latest"]
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
return str(latest_cache) # type: ignore[arg-type]
config = await get_update_config()
if not config["repo"]:
return None
url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20"
# Jeton requis uniquement pour un dépôt privé (public en lecture : inutile)
headers = {"Authorization": f"token {config['token']}"} if config["token"] else {}
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.warning("Vérification de mise à jour impossible : %s", exc)
return None
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
_cache.update(checked_at=now, latest=latest)
if latest and is_newer(latest, get_version()):
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
return latest
def invalidate_cache() -> None:
_cache.update(checked_at=0.0, latest=None)
async def status() -> dict[str, object]:
"""État complet : version courante, dernière dispo, config."""
config = await get_update_config()
current = get_version()
latest = await fetch_latest_version()
return {
"current": current,
"latest": latest,
"update_available": bool(latest and is_newer(latest, current)),
"configured": bool(config["repo"]),
"docker": bool(get_settings().watchtower_url),
"config": config,
}
async def trigger_update() -> dict[str, str]:
"""Demande à Watchtower de recréer le conteneur avec la dernière image.
Le conteneur courant (donc cette requête) disparaît quelques secondes après :
la réponse est renvoyée immédiatement, le frontend gère la reconnexion.
"""
settings = get_settings()
if not settings.watchtower_url:
raise UpdateError(
"Watchtower non configuré — mise à jour disponible uniquement en déploiement Docker"
)
headers = {}
if settings.watchtower_token:
headers["Authorization"] = f"Bearer {settings.watchtower_token}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(f"{settings.watchtower_url}/v1/update", headers=headers)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise UpdateError(f"Watchtower injoignable : {exc}") from exc
logger.info("Mise à jour déclenchée via Watchtower (version courante %s)", get_version())
return {"status": "started"}
+51
View File
@@ -147,6 +147,7 @@ button { font-family: inherit; }
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; } .btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; } .btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
.btn-danger:hover { background: #4d1518; filter: none; } .btn-danger:hover { background: #4d1518; filter: none; }
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
/* ------------------------------------------------------------ grille de posters */ /* ------------------------------------------------------------ grille de posters */
@@ -652,3 +653,53 @@ button { font-family: inherit; }
.search-bar { flex-direction: column; } .search-bar { flex-direction: column; }
.search-bar .btn { justify-content: center; } .search-bar .btn { justify-content: center; }
} }
/* ---------------------------------------------- bandeau mise à jour (update-watcher.js) */
.update-banner {
position: fixed;
inset: auto 0 1.2rem 0;
margin: 0 auto;
width: max-content;
max-width: 90vw;
padding: 0.7rem 1.2rem;
border-radius: 10px;
background: rgba(20, 20, 24, 0.96);
border: 1px solid var(--accent);
color: #fff;
font-size: 0.9rem;
z-index: 1000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 0.6rem;
}
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
@keyframes update-spin { to { transform: rotate(360deg); } }
/* ------------------------------------------------------------ filtre VF/VOSTFR */
.version-filter {
display: inline-flex;
gap: 0.2rem;
background: var(--surface-2);
padding: 0.2rem;
border-radius: 99px;
margin-left: 0.8rem;
vertical-align: middle;
}
.version-tab {
border: none;
background: transparent;
color: var(--text-dim);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.28rem 0.75rem;
border-radius: 99px;
cursor: pointer;
transition: all 0.15s;
}
.version-tab:hover { color: var(--text); }
.version-tab.active { background: var(--accent); color: #fff; }
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
+67
View File
@@ -0,0 +1,67 @@
/* Surveille la disponibilité du serveur et détecte les changements de version.
* Pendant une mise à jour Docker (quelques secondes d'indisponibilité), affiche
* un bandeau « Mise à jour en cours » puis recharge la page automatiquement
* quand le service revient avec une nouvelle version.
* Un serveur qui RÉPOND est considéré comme disponible (même 404 : instance
* antérieure à l'endpoint /api/version) — seul un échec réseau (connexion
* refusée, timeout) signifie « en cours de redémarrage ». */
(() => {
const POLL_MS = 8000;
const KEY_UPDATING = 'ohm-updating';
let initialVersion = null;
let failures = 0;
let banner = null;
let done = false;
const isUpdating = () => sessionStorage.getItem(KEY_UPDATING) === '1';
function showBanner() {
if (banner) return;
banner = document.createElement('div');
banner.className = 'update-banner';
banner.innerHTML = '<span class="spin">⟳</span> Mise à jour en cours — reconnexion automatique…';
document.body.appendChild(banner);
}
function hideBanner() {
if (banner) banner.remove();
banner = null;
}
async function poll() {
if (document.hidden || done) return;
let version = null;
let up = false;
try {
const r = await fetch('/api/version', { cache: 'no-store' });
up = true; // le serveur a répondu : il est vivant
if (r.ok) version = (await r.json()).version ?? null;
} catch {
up = false; // connexion refusée / réseau coupé : serveur injoignable
}
if (!up) {
failures += 1;
if (isUpdating() || failures >= 2) showBanner();
return;
}
failures = 0;
hideBanner();
if (version !== null && initialVersion === null) initialVersion = version;
if (isUpdating() || (version !== null && version !== initialVersion)) {
done = true;
sessionStorage.removeItem(KEY_UPDATING);
setTimeout(() => location.reload(), 1000); // laisser le serveur finir de démarrer
}
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) poll();
});
if (isUpdating()) showBanner();
poll();
setInterval(poll, POLL_MS);
})();
+139 -11
View File
@@ -26,12 +26,23 @@
<template x-for="s in sources" :key="s.name"> <template x-for="s in sources" :key="s.name">
<tr> <tr>
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td> <td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td> <td>
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
@change="s._urlDirty = s._url.trim() !== s.base_url">
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
x-text="s._saving ? '…' : 'Enregistrer'"></button>
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
</div>
</td>
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td> <td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td> <td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span> <span x-show="!s.health" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span> <span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span> :title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
</td> </td>
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td> <td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
</tr> </tr>
@@ -84,6 +95,38 @@
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'" :style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail"></p> x-text="integrations.testResult?.detail"></p>
</div> </div>
<div class="panel">
<h2>🔄 Mise à jour</h2>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Version installée : <strong x-text="upd.current || '—'"></strong>
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
x-text="(upd.latest || '?') + ' disponible'"></span>
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
style="flex:2;min-width:220px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
x-text="upd._checking ? '…' : 'Vérifier'"></button>
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
</div>
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
</p>
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
(<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon).
</p>
</div>
+
<div class="panel"> <div class="panel">
<h2>👥 Utilisateurs</h2> <h2>👥 Utilisateurs</h2>
@@ -121,6 +164,11 @@
function adminPage() { function adminPage() {
return { return {
users: [], sources: [], stats: {}, forbidden: false, users: [], sources: [], stats: {}, forbidden: false,
upd: {
current: '', latest: null, update_available: false, configured: false, docker: false,
config: { gitea_url: '', repo: '', token: '' },
_dirty: false, _checking: false, _applying: false,
},
integrations: { integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' }, torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null, _regen: false, _testing: false, testResult: null,
@@ -132,7 +180,7 @@ function adminPage() {
]); ]);
if (u.status === 403) { this.forbidden = true; return; } if (u.status === 403) { this.forbidden = true; return; }
this.users = await u.json(); this.users = await u.json();
this.sources = await s.json(); this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
this.stats = await st.json(); this.stats = await st.json();
const itg = await fetch('/api/admin/integrations'); const itg = await fetch('/api/admin/integrations');
if (itg.ok) { if (itg.ok) {
@@ -140,8 +188,10 @@ function adminPage() {
this.integrations.torznab = data.torznab; this.integrations.torznab = data.torznab;
this.integrations.sonarr = { ...data.sonarr, _dirty: false }; this.integrations.sonarr = { ...data.sonarr, _dirty: false };
} }
const upd = await fetch('/api/admin/update');
if (upd.ok) this.applyUpdateData(await upd.json());
this.checkAllSources();
}, },
copy(value) { copy(value) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié')); navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
}, },
@@ -170,13 +220,53 @@ function adminPage() {
if (res.ok) s.enabled = !s.enabled; if (res.ok) s.enabled = !s.enabled;
}, },
async healthCheck(s) { async saveUrl(s) {
s._checking = true; s._saving = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' }); try {
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s._url }),
});
if (res.ok) {
const data = await res.json(); const data = await res.json();
s.health = data.healthy; s.healthDetail = data.detail; s.base_url = data.base_url;
s.overridden = data.overridden;
s._url = data.base_url;
s._urlDirty = false;
toast('✔ URL enregistrée — test de la source…');
this.healthCheck(s);
} else {
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
}
} catch {
toast('✖ Erreur réseau');
} finally {
s._saving = false;
}
},
async resetUrl(s) {
s._url = '';
await this.saveUrl(s);
},
async healthCheck(s, notify = true) {
s._checking = true;
try {
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
} catch {
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
if (notify) toast(`✖ ${s.label} : erreur réseau`);
} finally {
s._checking = false; s._checking = false;
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`); }
},
checkAllSources() {
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
}, },
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); }, async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
@@ -188,6 +278,44 @@ function adminPage() {
this.integrations._testing = false; this.integrations._testing = false;
}, },
applyUpdateData(data) {
this.upd.current = data.current;
this.upd.latest = data.latest;
this.upd.update_available = data.update_available;
this.upd.configured = data.configured;
this.upd.docker = data.docker;
this.upd.config = { ...data.config, _dirty: false };
},
async saveUpdate() {
const res = await fetch('/api/admin/update', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.upd.config),
});
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
},
async checkUpdate() {
this.upd._checking = true;
const res = await fetch('/api/admin/update/check', { method: 'POST' });
if (res.ok) this.applyUpdateData(await res.json());
this.upd._checking = false;
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
},
async applyUpdate() {
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
this.upd._applying = true;
sessionStorage.setItem('ohm-updating', '1');
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
if (!res.ok) {
sessionStorage.removeItem('ohm-updating');
const detail = (await res.json()).detail || 'Erreur';
toast('✖ ' + detail); this.upd._applying = false;
}
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
},
async del(u) { async del(u) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return; if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' }); await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
+1
View File
@@ -32,6 +32,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/update-watcher.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+37 -6
View File
@@ -28,7 +28,7 @@
<div class="title-stats"> <div class="title-stats">
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span> <span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span> <span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span> <span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
</div> </div>
<div class="title-actions"> <div class="title-actions">
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running"> <button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
@@ -43,11 +43,21 @@
</div> </div>
</div> </div>
<h2 class="section-title">Épisodes</h2> <h2 class="section-title">Épisodes
<span class="version-filter" x-show="epVersions.length" x-cloak>
<template x-for="v in epVersions" :key="v">
<button class="version-tab" :class="{ active: versionFilter === v }"
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
</template>
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
@click="versionFilter = 'all'">Tout</button>
</span>
</h2>
<div class="episode-list"> <div class="episode-list">
<template x-for="ep in details.episodes" :key="ep.number"> <template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
<div class="episode-row"> <div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></span> <span class="episode-num" x-text="'E' + ep.number"></span>
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span> <span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button> <button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button> <button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
@@ -76,6 +86,23 @@ function titlePage() {
details: null, loading: true, error: null, favorite: false, details: null, loading: true, error: null, favorite: false,
player: { active: false, hls: null }, player: { active: false, hls: null },
seasonJob: { running: false, done: 0, total: 0 }, seasonJob: { running: false, done: 0, total: 0 },
versionFilter: 'all',
get epVersions() {
if (!this.details) return [];
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
},
get filteredEpisodes() {
if (!this.details) return [];
if (this.versionFilter === 'all') return this.details.episodes;
return this.details.episodes.filter(e => e.version === this.versionFilter);
},
get uniqueEpisodeCount() {
if (!this.details) return 0;
return new Set(this.details.episodes.map(e => e.number)).size;
},
async load(source, sourceId) { async load(source, sourceId) {
this.source = source; this.sourceId = sourceId; this.source = source; this.sourceId = sourceId;
@@ -84,6 +111,7 @@ function titlePage() {
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status); if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
this.details = await res.json(); this.details = await res.json();
this.details.episodes.forEach(e => e._busy = false); this.details.episodes.forEach(e => e._busy = false);
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
document.title = this.details.title + ' — Ohm Stream'; document.title = this.details.title + ' — Ohm Stream';
} catch (e) { this.error = e.message; } } catch (e) { this.error = e.message; }
finally { this.loading = false; } finally { this.loading = false; }
@@ -98,7 +126,10 @@ function titlePage() {
return data.links[0]; return data.links[0];
}, },
epLabel(ep) { return `${this.details.title} - E${ep.number}`; }, epLabel(ep) {
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
return `${this.details.title} - E${ep.number}${version}`;
},
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire) // Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
openPlayer(link) { openPlayer(link) {
@@ -148,8 +179,8 @@ function titlePage() {
}, },
async downloadSeason() { async downloadSeason() {
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length }; this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
for (const ep of this.details.episodes) { for (const ep of this.filteredEpisodes) {
await this.downloadEpisode(ep); await this.downloadEpisode(ep);
this.seasonJob.done++; this.seasonJob.done++;
} }
+19
View File
@@ -0,0 +1,19 @@
"""Version de l'application.
Source de vérité : `version` de pyproject.toml (lue via les métadonnées du paquet
installé par uv). Dans l'image Docker, `OHM_VERSION` est cuit au build par
scripts/release.sh et prime sur tout.
"""
import importlib.metadata
import os
def get_version() -> str:
v = os.environ.get("OHM_VERSION")
if v:
return v
try:
return importlib.metadata.version("ohm-stream")
except importlib.metadata.PackageNotFoundError:
return "dev"
+53
View File
@@ -0,0 +1,53 @@
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
#
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
# 3. docker compose up -d
#
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
# docker compose pull && docker compose up -d
services:
ohm:
image: git.lanro.eu/roman/ohm_streaming:latest
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
# build:
# context: .
# args:
# VERSION: dev
restart: unless-stopped
ports:
- "${OHM_PORT:-8777}:8777"
env_file: .env
environment:
# Chemins internes (volumes ci-dessous) — ne pas changer
OHM_DATA_DIR: /data
OHM_DOWNLOAD_DIR: /downloads
OHM_DATABASE_PATH: /data/ohm.db
# Watchtower compagnon sur le réseau interne compose
OHM_WATCHTOWER_URL: http://watchtower:8080
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
volumes:
- ./data:/data # base SQLite, persiste entre les mises à jour
- ./downloads:/downloads # bibliothèque téléchargée
labels:
# Watchtower ne touche que les conteneurs portant ce label
- com.centurylinklabs.watchtower.enable=true
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
# Pas de vérification périodique : uniquement sur appel de l'admin
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
WATCHTOWER_LABEL_ENABLE: "true"
# Compatibilité Docker récent (l'image watchtower négocie une API trop ancienne)
DOCKER_API_VERSION: "1.44"
# Supprimer les anciennes images après mise à jour
WATCHTOWER_CLEANUP: "true"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Corrige les permissions des volumes (créés par Docker en root au premier up)
# puis bascule sur l'utilisateur non-root « ohm ».
set -e
if [ "$(id -u)" = "0" ]; then
chown -R ohm:ohm /data /downloads 2>/dev/null || true
exec gosu ohm:ohm "$@"
fi
exec "$@"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Publie une version d'OhmStreaming :
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
#
# Usage : ./scripts/release.sh 0.2.0
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
set -euo pipefail
VERSION="${1:?Usage : ./scripts/release.sh <version> (ex. 0.2.0)}"
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
cd "$(dirname "$0")/.."
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
if [[ -n "$(git status --porcelain)" ]]; then
echo "✖ Dépôt sale — committez ou rangez vos modifications avant de publier :"
git status --short
exit 1
fi
TAG="v$VERSION"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "✖ Le tag $TAG existe déjà"
exit 1
fi
echo "▶ 1/3 Version $VERSION dans pyproject.toml"
uv version "$VERSION"
git add pyproject.toml uv.lock
git commit -m "v$VERSION"
echo "▶ 2/3 Tag et push git"
git tag "$TAG"
git push origin HEAD
git push origin "$TAG"
echo "▶ 3/3 Image Docker → $REGISTRY"
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
docker push "$REGISTRY:$VERSION"
docker push "$REGISTRY:latest"
echo
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
+22
View File
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
await db.execute(f"DELETE FROM {table}") await db.execute(f"DELETE FROM {table}")
yield yield
await db.close() await db.close()
# ---------------------------------------------------------------- client HTTP partagé
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncClient:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def admin_cookies(client: AsyncClient):
"""Premier compte créé → administrateur, cookies de session."""
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
assert r.status_code == 303
return r.cookies
+83 -18
View File
@@ -1,25 +1,8 @@
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin).""" """Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import get_settings from app.config import get_settings
from app.db import db from app.db import db
from app.main import app from app.scrapers.base import ScrapeError, SearchResult
@pytest.fixture
async def client() -> AsyncClient:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def admin_cookies(client: AsyncClient):
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
assert r.status_code == 303
return r.cookies
async def test_health(client): async def test_health(client):
@@ -121,3 +104,85 @@ async def test_source_toggle(client, admin_cookies):
states = {s["name"]: s["enabled"] for s in r.json()} states = {s["name"]: s["enabled"] for s in r.json()}
assert states["vostfree"] is False assert states["vostfree"] is False
assert states["french_manga"] is True assert states["french_manga"] is True
async def test_source_health(client, admin_cookies, monkeypatch):
# source inconnue → 404 (pas de 500)
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
assert r.status_code == 404
# état initial : aucune santé connue
r = await client.get("/api/admin/sources", cookies=admin_cookies)
assert all(s["health"] is None for s in r.json())
async def ok_search(self, query: str) -> list[SearchResult]:
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
data = r.json()
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
# résultat persisté et exposé par GET /sources
r = await client.get("/api/admin/sources", cookies=admin_cookies)
health = {s["name"]: s["health"] for s in r.json()}
assert health["vostfree"]["healthy"] is True
assert health["french_manga"] is None
async def failing_search(self, query: str) -> list[SearchResult]:
raise ScrapeError("site indisponible")
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
data = r.json()
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
async def broken_search(self, query: str) -> list[SearchResult]:
raise RuntimeError("boom")
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
data = r.json()
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
async def test_source_toggle_unknown(client, admin_cookies):
r = await client.post(
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
)
assert r.status_code == 404
async def test_source_url_override(client, admin_cookies):
from app.scrapers.base import get_source
r = await client.get("/api/admin/sources", cookies=admin_cookies)
src = next(s for s in r.json() if s["name"] == "vostfree")
default = src["default_base_url"]
assert src["base_url"] == default and src["overridden"] is False
# source inconnue → 404
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
assert r.status_code == 404
# URL invalide → 422
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
assert r.status_code == 422
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
assert get_source("vostfree").base_url == "https://exemple.org"
r = await client.get("/api/admin/sources", cookies=admin_cookies)
src = next(s for s in r.json() if s["name"] == "vostfree")
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
# URL vide → retour au défaut du code
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
data = r.json()
assert data["base_url"] == default and data["overridden"] is False
assert get_source("vostfree").base_url == default
+184
View File
@@ -0,0 +1,184 @@
"""Mises à jour : semver, détection Gitea (API tags), déclenchement Watchtower, endpoints."""
import httpx
import pytest
from app.services import update as update_service
from app.services.settings import get_update_config
from app.version import get_version
@pytest.fixture(autouse=True)
def reset_update_cache():
update_service.invalidate_cache()
yield
update_service.invalidate_cache()
# ---------------------------------------------------------------- semver / version
def test_parse_tag():
assert update_service.parse_tag("v0.2.1") == (0, 2, 1)
assert update_service.parse_tag("0.10.3") == (0, 10, 3)
assert update_service.parse_tag("v0.2") is None
assert update_service.parse_tag("v0.2.1-beta") is None
assert update_service.parse_tag("nimporte") is None
def test_is_newer():
assert update_service.is_newer("v0.2.0", "0.1.9")
assert update_service.is_newer("v1.0.0", "v0.99.99")
assert not update_service.is_newer("v0.1.0", "0.1.0")
assert not update_service.is_newer("v0.1.0", "dev") # courant non semver → jamais forcé
def test_get_version_env_override(monkeypatch):
monkeypatch.setenv("OHM_VERSION", "9.9.9")
assert get_version() == "9.9.9"
# ---------------------------------------------------------------- détection Gitea
async def test_fetch_latest_without_repo():
from app.services.settings import set_update_config
await set_update_config("https://git.example", "", "tok")
assert await update_service.fetch_latest_version(force=True) is None
async def test_fetch_latest_public_repo_no_token(monkeypatch):
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "")
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> list[dict]:
return [{"name": "v0.3.0"}]
calls: list[tuple[str, dict]] = []
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
calls.append((url, headers or {}))
return FakeResponse()
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
assert await update_service.fetch_latest_version(force=True) == "v0.3.0"
assert calls[0][1] == {} # aucun en-tête d'auth
async def test_fetch_latest_picks_highest_semver(monkeypatch):
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> list[dict]:
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
calls: list[tuple[str, dict]] = []
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
calls.append((url, headers or {}))
return FakeResponse()
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
latest = await update_service.fetch_latest_version(force=True)
assert latest == "v0.2.3"
assert calls == [
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
]
# puis servi par le cache (plus d'appel réseau)
assert await update_service.fetch_latest_version() == "v0.2.3"
assert len(calls) == 1
async def test_fetch_latest_network_error_degrades(monkeypatch):
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
raise httpx.ConnectError("injoignable")
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
assert await update_service.fetch_latest_version(force=True) is None
# ---------------------------------------------------------------- déclenchement
async def test_trigger_update_requires_watchtower():
with pytest.raises(update_service.UpdateError, match="Watchtower"):
await update_service.trigger_update()
# ---------------------------------------------------------------- endpoints
async def test_version_endpoint_public(client):
r = await client.get("/api/version")
assert r.status_code == 200
data = r.json()
assert data["version"] == get_version() and data["name"]
async def test_update_requires_admin(client):
r = await client.get("/api/admin/update", follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/login"
async def test_update_status_defaults(client, admin_cookies, monkeypatch):
# Aucun appel réseau : la détection renvoie None (repo sans tag)
async def fake_fetch(*, force: bool = False):
return None
monkeypatch.setattr(update_service, "fetch_latest_version", fake_fetch)
r = await client.get("/api/admin/update", cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["configured"] is True and data["docker"] is False # repo par défaut présent
assert data["latest"] is None and data["update_available"] is False
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
assert data["config"]["repo"] == "Roman/ohm_streaming"
async def test_update_save_config_normalizes(client, admin_cookies):
r = await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
cookies=admin_cookies,
)
assert r.status_code == 200
assert await get_update_config() == {
"gitea_url": "https://git.example",
"repo": "roman/ohm",
"token": "tok",
}
async def test_update_check_degrades_gracefully(client, admin_cookies):
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
cookies=admin_cookies,
)
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
assert r.status_code == 200 and r.json()["latest"] is None
async def test_update_apply_without_docker_is_502(client, admin_cookies):
r = await client.post("/api/admin/update/apply", cookies=admin_cookies)
assert r.status_code == 502