Files
ohm_streaming/app/routers/torznab.py
T
Roman 41566ab5fb 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).
2026-09-22 10:05:47 +00:00

125 lines
4.1 KiB
Python

"""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"'},
)