From eae97f912919bac8ad8b6be2c6e81f0b362a700c Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 25 Sep 2026 15:39:00 +0000 Subject: [PATCH] =?UTF-8?q?Panneau=20MAJ=20simplifi=C3=A9=20:=20d=C3=A9p?= =?UTF-8?q?=C3=B4t=20public,=20plus=20de=20configuration=20Gitea=20?= =?UTF-8?q?=E2=80=94=20patchnote=20du=20dernier=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 5 ++- app/routers/admin.py | 14 +------- app/services/settings.py | 20 ----------- app/services/update.py | 39 ++++++++++---------- app/templates/admin.html | 37 ++++++------------- tests/test_update.py | 77 ++++++++++++++-------------------------- 6 files changed, 61 insertions(+), 131 deletions(-) diff --git a/app/config.py b/app/config.py index 38a18e8..ead67e7 100644 --- a/app/config.py +++ b/app/config.py @@ -34,7 +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) + # Mise à jour (dépôt public — lecture anonyme de l'API Gitea) + gitea_url: str = "https://git.lanro.eu" + gitea_repo: str = "Roman/ohm_streaming" + # Déploiement Docker — Watchtower compagnon watchtower_url: str = "" watchtower_token: str = "" diff --git a/app/routers/admin.py b/app/routers/admin.py index 8111858..916f8db 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -28,7 +28,6 @@ from app.services.settings import ( 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 @@ -222,23 +221,12 @@ async def test_sonarr(admin: AdminUser) -> dict: # ---------------------------------------------------------------- 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.""" + """Version courante, dernière version disponible et patchnote.""" 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).""" diff --git a/app/services/settings.py b/app/services/settings.py index 76e6e7c..a1fc422 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -114,23 +114,3 @@ async def set_sonarr_config(url: str, apikey: str) -> None: 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 index 98f24af..7bfc90b 100644 --- a/app/services/update.py +++ b/app/services/update.py @@ -1,6 +1,7 @@ """Mises à jour logicielles. -- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé). +- Détection : dernier tag semver du dépôt Gitea public via son API (lecture anonyme), + avec le message du tag comme patchnote. - Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle image et recrée le conteneur — quelques secondes d'indisponibilité. """ @@ -12,7 +13,6 @@ 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__) @@ -20,11 +20,11 @@ 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} +_cache: dict[str, object] = {"checked_at": 0.0, "latest": None, "notes": None} class UpdateError(Exception): - """Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO).""" + """Erreur de mise à jour (Gitea injoignable, Watchtower KO).""" def parse_tag(tag: str) -> tuple[int, int, int] | None: @@ -41,51 +41,48 @@ def is_newer(latest: str, current: str) -> bool: 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.""" + """Dernier tag semver du dépôt + patchnote (cache 5 min). None si 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 {} + settings = get_settings() + url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20" try: async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: - resp = await client.get(url, headers=headers) + resp = await client.get(url) resp.raise_for_status() - tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))] + tags = [ + (t["name"], (t.get("message") or "").strip()) + 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) + latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None) + _cache.update(checked_at=now, latest=latest, notes=notes or None) 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) + _cache.update(checked_at=0.0, latest=None, notes=None) async def status() -> dict[str, object]: - """État complet : version courante, dernière dispo, config.""" - config = await get_update_config() + """État complet : version courante, dernière dispo, patchnote.""" current = get_version() latest = await fetch_latest_version() return { "current": current, "latest": latest, + "notes": _cache["notes"], "update_available": bool(latest and is_newer(latest, current)), - "configured": bool(config["repo"]), "docker": bool(get_settings().watchtower_url), - "config": config, } diff --git a/app/templates/admin.html b/app/templates/admin.html index 59c1113..a35252e 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -111,28 +111,23 @@

- - - -
-

- 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é. -

-

+

+ + Patchnote + +

+    
+

⚠ Mise à jour automatique disponible uniquement en déploiement Docker (docker compose pull && docker compose up -d sinon).

-+

👥 Utilisateurs

@@ -171,9 +166,8 @@ 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, + current: '', latest: null, notes: null, update_available: false, docker: false, + _checking: false, _applying: false, }, integrations: { torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' }, @@ -287,18 +281,9 @@ function adminPage() { applyUpdateData(data) { this.upd.current = data.current; this.upd.latest = data.latest; + this.upd.notes = data.notes; 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() { diff --git a/tests/test_update.py b/tests/test_update.py index 1f37a21..47b13d6 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -3,8 +3,8 @@ import httpx import pytest +from app.config import get_settings from app.services import update as update_service -from app.services.settings import get_update_config from app.version import get_version @@ -15,6 +15,12 @@ def reset_update_cache(): update_service.invalidate_cache() +def _patch_gitea(monkeypatch, url: str = "https://git.example", repo: str = "roman/ohm") -> None: + settings = get_settings() + monkeypatch.setattr(settings, "gitea_url", url) + monkeypatch.setattr(settings, "gitea_repo", repo) + + # ---------------------------------------------------------------- semver / version @@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch): # ---------------------------------------------------------------- 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", "") +async def test_fetch_latest_no_auth_header(monkeypatch): + """Dépôt public : appel sans en-tête Authorization.""" + _patch_gitea(monkeypatch) class FakeResponse: def raise_for_status(self) -> None: @@ -73,17 +70,20 @@ async def test_fetch_latest_public_repo_no_token(monkeypatch): 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") + """Plus haut semver retenu + patchnote du tag, puis cache.""" + _patch_gitea(monkeypatch) 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"}] + return [ + {"name": "v0.1.0", "message": "ancien"}, + {"name": "v0.2.3", "message": "correctifs\n"}, + {"name": "v1.0.0-rc"}, + {"name": "divers"}, + ] calls: list[tuple[str, dict]] = [] @@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch): return FakeResponse() monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) - latest = await update_service.fetch_latest_version(force=True) + status = await update_service.status() - assert latest == "v0.2.3" - assert calls == [ - ("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"}) - ] + assert status["latest"] == "v0.2.3" + assert status["notes"] == "correctifs" + assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})] # 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") + _patch_gitea(monkeypatch) async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None): raise httpx.ConnectError("injoignable") @@ -148,33 +145,13 @@ async def test_update_status_defaults(client, admin_cookies, monkeypatch): 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" + assert data["docker"] is False and data["notes"] is None -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, - ) +async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch): + # Gitea injoignable : le check répond quand même (latest=None) + _patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm") r = await client.post("/api/admin/update/check", cookies=admin_cookies) assert r.status_code == 200 and r.json()["latest"] is None