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 @@
-
+
+
+
+
+ ↺
+
+
- —
- ✔ OK
- ✖ KO
+ —
+
@@ -84,6 +95,38 @@
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail">
+
+
🔄 Mise à jour
+
+ Version installée :
+
+ ✔ à jour
+
+
+
+
+
+
+ Enregistrer
+
+
+
+
+ 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é.
+
+
+ ⚠ Mise à jour automatique disponible uniquement en déploiement Docker
+ (docker compose pull && docker compose up -d sinon).
+
+
++
👥 Utilisateurs
@@ -121,6 +164,11 @@
function adminPage() {
return {
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: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null,
@@ -132,7 +180,7 @@ function adminPage() {
]);
if (u.status === 403) { this.forbidden = true; return; }
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();
const itg = await fetch('/api/admin/integrations');
if (itg.ok) {
@@ -140,8 +188,10 @@ function adminPage() {
this.integrations.torznab = data.torznab;
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) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
},
@@ -170,13 +220,53 @@ function adminPage() {
if (res.ok) s.enabled = !s.enabled;
},
- async healthCheck(s) {
+ async saveUrl(s) {
+ s._saving = true;
+ 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();
+ 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;
- const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
- const data = await res.json();
- s.health = data.healthy; s.healthDetail = data.detail;
- s._checking = false;
- toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
+ 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;
+ }
+ },
+
+ 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(); },
@@ -188,6 +278,44 @@ function adminPage() {
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) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
diff --git a/app/templates/base.html b/app/templates/base.html
index 26dfe09..75f5b82 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -32,6 +32,7 @@
{% block content %}{% endblock %}
+
{% block scripts %}{% endblock %}