v0.1.0 — Réécriture complète de OhmStreaming
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).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user