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:
+96
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user