Files
ohm_streaming/app/routers/torznab.py
T
roman 853b4e0866 Compatibilité Sonarr : flux RSS Torznab, catégories qBittorrent, save_path, TLS auto-signé
- torznab : tvsearch sans q sert les nouveautés (RSS sync + test Sonarr)
  au lieu d'une erreur 400 — ajoute TorznabService.latest_releases()
- qbit : POST /torrents/createCategory + GET /torrents/categories stateful
  (Sonarr crée sa catégorie tv-sonarr puis la revérifie)
- qbit : /app/preferences expose save_path=/downloads (health Sonarr)
- sonarr : verify=False sur httpx — reverse proxy swizzin en certificat
  auto-signé (usage LAN)
2026-09-22 18:25:03 +00:00

140 lines
4.6 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 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"
)
},
)