"""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 urllib.parse import urlencode 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 build_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: releases = await torznab.latest_releases() return Response( torznab.results_xml(base, key, releases), media_type="application/rss+xml" ) 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 » ; Ohm télécharge l'épisode. Le flux retourne un .torrent de service : avec un client « Torrent Blackhole » c'est un simple accusé de réception ; avec le client qBittumber (l'API /api/v2 d'Ohm), Sonarr le renvoie et le grab est rejoué/dédupliqué, puis suivi comme un téléchargement classique. """ 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, ) name = f"{series} S{season:02d}E{ep:02d}" announce = ( _base_url(request) + "/torznab/api?" + urlencode( {"source": source, "sid": sid, "season": season, "ep": ep, "series": series} ) ) stub, infohash = build_stub(announce, name) try: result = await torznab.grab(source, sid, season, ep, series, sonarr_hash=infohash) 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, ) return Response( stub, media_type="application/x-bittorrent", headers={ "Content-Disposition": ( f'attachment; filename="ohm-{series.replace("/", "-")}' f"-S{season:02d}E{ep:02d}.torrent" ) }, )