"""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 from app import auth from app.db import db from app.routers.auth import AdminUser 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, ) 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__) router = APIRouter(prefix="/api/admin", tags=["admin"]) import_all_scrapers() # ---------------------------------------------------------------- utilisateurs @router.get("/users") async def list_users(admin: AdminUser) -> list[dict]: rows = await db.fetchall( "SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY id" ) return [dict(row) for row in rows] @router.post("/users/{user_id}/toggle-active") async def toggle_active(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de désactiver son propre compte") row = await db.fetchone("SELECT is_active FROM users WHERE id = ?", (user_id,)) if row is None: raise HTTPException(404, "Utilisateur introuvable") new_state = 0 if row["is_active"] else 1 await db.execute("UPDATE users SET is_active = ? WHERE id = ?", (new_state, user_id)) if not new_state: await auth.revoke_all_refresh_tokens(user_id) return {"is_active": bool(new_state)} @router.post("/users/{user_id}/toggle-admin") async def toggle_admin(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de modifier ses propres droits") row = await db.fetchone("SELECT is_admin FROM users WHERE id = ?", (user_id,)) if row is None: raise HTTPException(404, "Utilisateur introuvable") new_state = 0 if row["is_admin"] else 1 await db.execute("UPDATE users SET is_admin = ? WHERE id = ?", (new_state, user_id)) return {"is_admin": bool(new_state)} @router.delete("/users/{user_id}") async def delete_user(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de supprimer son propre compte") cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) if cursor.rowcount == 0: raise HTTPException(404, "Utilisateur introuvable") return {"ok": True} @router.get("/stats") async def stats(admin: AdminUser) -> dict: users = await db.fetchone("SELECT COUNT(*) AS n FROM users") downloads = await db.fetchall("SELECT status, COUNT(*) AS n FROM downloads GROUP BY status") return { "users": users["n"], "downloads": {row["status"]: row["n"] for row in downloads}, } # ---------------------------------------------------------------- sources 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: _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 = _source_or_404(name) try: results = await asyncio.wait_for(source.search("naruto"), timeout=30) healthy, detail = len(results) > 0, f"{len(results)} résultats" except (ScrapeError, TimeoutError) as exc: healthy, detail = False, str(exc)[:200] logger.error("Health check %s KO : %s", name, exc) 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") async def sources_status(admin: AdminUser) -> list[dict]: return [ { "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() ] # ---------------------------------------------------------------- intégrations *arr class SonarrConfig(BaseModel): url: str apikey: str @router.get("/integrations") async def integrations(admin: AdminUser, request: Request) -> dict: """Configuration Torznab (indexeur) et Sonarr (recommandations).""" config = await get_sonarr_config() base = str(request.base_url).rstrip("/") return { "torznab": { "apikey": await get_torznab_apikey(), "endpoint": f"{base}/torznab/api", }, "sonarr": config, } @router.post("/integrations/torznab/regenerate") async def regenerate_torznab_key(admin: AdminUser) -> dict: key = await reset_torznab_apikey() return {"apikey": key} @router.put("/integrations/sonarr") async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict: """Enregistre la connexion Sonarr et recalcule les recommandations.""" await set_sonarr_config(payload.url, payload.apikey) sonarr.invalidate() discover.invalidate_for_you() return {"ok": True} @router.post("/integrations/sonarr/test") async def test_sonarr(admin: AdminUser) -> dict: return await sonarr.test_connection() # ---------------------------------------------------------------- mise à jour logicielle @router.get("/update") async def get_update(admin: AdminUser) -> dict: """Version courante, dernière version disponible et patchnote.""" 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