"""API Torznab/Newznab consommée par Sonarr, Prowlarr (et les *arr en général). Authentification par clé API (`?apikey=…` ou en-tête `X-Api-Key`), indépendante des sessions utilisateurs — aucun cookie requis. """ import logging import secrets from fastapi import APIRouter, Request, Response from app.scrapers.base import ScrapeError from app.services.settings import get_torznab_apikey from app.services.torznab import torrent_stub, torznab logger = logging.getLogger(__name__) router = APIRouter(prefix="/torznab", tags=["torznab"]) def _base_url(request: Request) -> str: return str(request.base_url).rstrip("/") async def _auth_error(request: Request, apikey: str | None) -> Response | None: """Réponse d'erreur XML si la clé est invalide (None = authentifié).""" expected = await get_torznab_apikey() provided = apikey or request.headers.get("X-Api-Key") or "" if secrets.compare_digest(provided, expected): return None return Response( torznab.error_xml(100, "Invalid API key"), media_type="application/xml", status_code=401, ) @router.get("/api") @router.post("/api") async def torznab_api( request: Request, t: str = "caps", q: str | None = None, season: int | None = None, ep: int | None = None, apikey: str | None = None, ) -> Response: """Point d'entrée Torznab : caps / tvsearch / search.""" error = await _auth_error(request, apikey) if error is not None: return error key = await get_torznab_apikey() base = _base_url(request) if t == "caps": return Response(torznab.caps_xml(base), media_type="application/xml") if t in ("tvsearch", "search"): if not q or len(q) < 2: return Response( torznab.error_xml(200, "Paramètre q requis"), media_type="application/xml", status_code=400, ) try: releases = await torznab.tvsearch(q, season=season, ep=ep) except Exception as exc: # noqa: BLE001 — Sonarr attend du XML, pas un traceback logger.error("Torznab %s %r KO : %s", t, q, exc) return Response( torznab.error_xml(300, "Recherche indisponible"), media_type="application/xml", status_code=502, ) return Response( torznab.results_xml(base, key, releases), media_type="application/rss+xml" ) return Response( torznab.error_xml(203, f"Fonction non supportée : {t}"), media_type="application/xml", status_code=400, ) @router.get("/download") async def torznab_download( request: Request, source: str | None = None, sid: str | None = None, season: int | None = None, ep: int | None = None, series: str | None = None, apikey: str | None = None, ) -> Response: """Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode. Le flux retourne un .torrent de service (blackhole-friendly) pendant que l'épisode réel entre dans la file de téléchargements interne. """ error = await _auth_error(request, apikey) if error is not None: return error if None in (source, sid, season, ep, series): return Response( torznab.error_xml(201, "Paramètres manquants : source, sid, season, ep, series"), media_type="application/xml", status_code=400, ) try: result = await torznab.grab(source, sid, season, ep, series) logger.info("Torznab grab OK : %s → download %s", series, result.get("id")) except ScrapeError as exc: logger.error("Torznab grab KO : %s", exc) return Response( torznab.error_xml(300, str(exc)), media_type="application/xml", status_code=502, ) stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}") return Response( stub, media_type="application/x-bittorrent", headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'}, )