From 9148b5fb6a1c3c141c4a64a8668c4eef6aa8b8a9 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 22 Sep 2026 11:58:03 +0000 Subject: [PATCH] =?UTF-8?q?Mise=20=C3=A0=20jour=20automatique=20(Gitea=20+?= =?UTF-8?q?=20Watchtower)=20et=20conteneurisation=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .dockerignore | 20 ++++ .env.example | 11 +- Dockerfile | 56 ++++++++++ README.md | 58 ++++++++-- app/config.py | 4 + app/main.py | 16 ++- app/routers/admin.py | 105 ++++++++++++++++-- app/routers/system.py | 14 +++ app/services/settings.py | 66 +++++++++++- app/services/update.py | 113 ++++++++++++++++++++ app/static/css/style.css | 24 +++++ app/static/js/update-watcher.js | 67 ++++++++++++ app/templates/admin.html | 152 +++++++++++++++++++++++--- app/templates/base.html | 1 + app/version.py | 19 ++++ docker-compose.yml | 53 +++++++++ docker-entrypoint.sh | 11 ++ scripts/release.sh | 44 ++++++++ tests/conftest.py | 22 ++++ tests/test_api.py | 101 ++++++++++++++---- tests/test_update.py | 184 ++++++++++++++++++++++++++++++++ 21 files changed, 1092 insertions(+), 49 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/routers/system.py create mode 100644 app/services/update.py create mode 100644 app/static/js/update-watcher.js create mode 100644 app/version.py create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100755 scripts/release.sh create mode 100644 tests/test_update.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7232f31 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example index 431b493..6f2c954 100644 --- a/.env.example +++ b/.env.example @@ -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) 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_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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd4e55a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 6072a1a..76a718b 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,51 @@ 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. -> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire -> 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éploiement (Docker) — recommandé -## 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 uv sync @@ -16,7 +55,8 @@ uv sync 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**. @@ -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_DATABASE_PATH` | `./data/ohm.db` | Base SQLite | | `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 - **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 (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. diff --git a/app/config.py b/app/config.py index 7dd5f67..38a18e8 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,10 @@ class Settings(BaseSettings): kitsu_base_url: str = "https://kitsu.io/api/edge" metadata_cache_ttl_hours: int = 72 + # Mise à jour (déploiement Docker — Watchtower compagnon) + watchtower_url: str = "" + watchtower_token: str = "" + def ensure_dirs(self) -> None: self.data_dir.mkdir(parents=True, exist_ok=True) self.download_dir.mkdir(parents=True, exist_ok=True) diff --git a/app/main.py b/app/main.py index 83dec24..2ae3e6c 100644 --- a/app/main.py +++ b/app/main.py @@ -9,10 +9,22 @@ from fastapi.staticfiles import StaticFiles from app.config import BASE_DIR, get_settings from app.db import db 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.services.discover import discover as discover_service from app.services.downloads import download_manager +from app.services.settings import apply_source_base_urls logger = logging.getLogger(__name__) @@ -30,6 +42,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() setup_logging(settings.debug) await db.connect() + await apply_source_base_urls() await download_manager.start() warmup = asyncio.create_task(discover_service.latest()) warmup.add_done_callback(_log_task_error) @@ -46,6 +59,7 @@ def create_app() -> FastAPI: app = FastAPI(title=settings.app_name, lifespan=lifespan) app.include_router(torznab.router) + app.include_router(system.router) app.include_router(pages.router) app.include_router(pages.protected) diff --git a/app/routers/admin.py b/app/routers/admin.py index 868a6dd..8111858 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -1,7 +1,8 @@ -"""Administration : utilisateurs, activation des sources, santé.""" +"""Administration : utilisateurs, activation des sources, santé, mises à jour.""" import asyncio import logging +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel @@ -9,17 +10,29 @@ from pydantic import BaseModel from app import auth from app.db import db 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.settings import ( get_sonarr_config, + get_source_health, get_torznab_apikey, is_source_enabled, reset_torznab_apikey, set_sonarr_config, + set_source_base_url, set_source_enabled, + set_source_health, + set_update_config, ) 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__) @@ -92,26 +105,62 @@ class SourceToggle(BaseModel): 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") 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) 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") async def health_check(name: str, admin: AdminUser) -> dict: """Test de santé manuel : la source doit répondre à une recherche simple.""" - source = get_source(name) + source = _source_or_404(name) try: results = await asyncio.wait_for(source.search("naruto"), timeout=30) - healthy = len(results) > 0 - detail = f"{len(results)} résultats" + healthy, detail = len(results) > 0, f"{len(results)} résultats" except (ScrapeError, TimeoutError) as exc: - healthy = False - detail = str(exc)[:200] + healthy, detail = False, str(exc)[:200] 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") @@ -121,7 +170,10 @@ async def sources_status(admin: AdminUser) -> list[dict]: "name": s.name, "label": s.label, "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), + "health": await get_source_health(s.name), } for s in all_sources() ] @@ -166,3 +218,38 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict: @router.post("/integrations/sonarr/test") async def test_sonarr(admin: AdminUser) -> dict: 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 diff --git a/app/routers/system.py b/app/routers/system.py new file mode 100644 index 0000000..b7c74f2 --- /dev/null +++ b/app/routers/system.py @@ -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()} diff --git a/app/services/settings.py b/app/services/settings.py index d22bdf8..76e6e7c 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -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 logging import secrets +from datetime import UTC, datetime 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) 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 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_APIKEY_KEY, apikey.strip()) 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) diff --git a/app/services/update.py b/app/services/update.py new file mode 100644 index 0000000..98f24af --- /dev/null +++ b/app/services/update.py @@ -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"} diff --git a/app/static/css/style.css b/app/static/css/style.css index 7ca654a..6637f51 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -147,6 +147,7 @@ button { font-family: inherit; } .btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; } .btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; } .btn-danger:hover { background: #4d1518; filter: none; } +.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; } /* ------------------------------------------------------------ grille de posters */ @@ -652,3 +653,26 @@ button { font-family: inherit; } .search-bar { flex-direction: column; } .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); } } + diff --git a/app/static/js/update-watcher.js b/app/static/js/update-watcher.js new file mode 100644 index 0000000..49f3bfe --- /dev/null +++ b/app/static/js/update-watcher.js @@ -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 = '⟳ 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); +})(); diff --git a/app/templates/admin.html b/app/templates/admin.html index 1752d25..c545cd2 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -26,12 +26,23 @@