Files
ohm_streaming/app/routers/admin.py
T
Roman 9148b5fb6a 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)
2026-09-22 11:58:03 +00:00

256 lines
8.2 KiB
Python

"""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,
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__)
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
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