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)
This commit is contained in:
Roman
2026-09-22 11:58:03 +00:00
parent 41566ab5fb
commit 9148b5fb6a
21 changed files with 1092 additions and 49 deletions
+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 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