Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
169 lines
5.4 KiB
Python
169 lines
5.4 KiB
Python
"""Administration : utilisateurs, activation des sources, santé."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
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, all_sources, get_source, import_all_scrapers
|
|
from app.services.discover import discover
|
|
from app.services.settings import (
|
|
get_sonarr_config,
|
|
get_torznab_apikey,
|
|
is_source_enabled,
|
|
reset_torznab_apikey,
|
|
set_sonarr_config,
|
|
set_source_enabled,
|
|
)
|
|
from app.services.sonarr import sonarr
|
|
|
|
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
|
|
|
|
|
|
@router.post("/sources/{name}/toggle")
|
|
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
|
get_source(name) # 404 implicite si inconnue
|
|
await set_source_enabled(name, payload.enabled)
|
|
return {"name": name, "enabled": payload.enabled}
|
|
|
|
|
|
@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)
|
|
try:
|
|
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
|
healthy = len(results) > 0
|
|
detail = f"{len(results)} résultats"
|
|
except (ScrapeError, TimeoutError) as exc:
|
|
healthy = False
|
|
detail = str(exc)[:200]
|
|
logger.error("Health check %s KO : %s", name, exc)
|
|
return {"name": name, "healthy": healthy, "detail": detail}
|
|
|
|
|
|
@router.get("/sources")
|
|
async def sources_status(admin: AdminUser) -> list[dict]:
|
|
return [
|
|
{
|
|
"name": s.name,
|
|
"label": s.label,
|
|
"base_url": s.base_url,
|
|
"enabled": await is_source_enabled(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()
|