Files
ohm_streaming/app/routers/torznab.py
T
Roman 4e1faf4cd9 Ohm comme client de téléchargement Sonarr (API compatible qBittorrent)
- /api/v2/* : login SID (mot de passe = clé Torznab), app/version,
  torrents/info (progression temps réel), properties (content_path),
  add (rejoue le grab encodé dans le .torrent de service, dédupliqué
  par infohash SHA-1), delete (± fichiers), pause/resume
- Les grabs Sonarr sont marqués « sonarr:<hash>| » dans source_key →
  suivis de bout en bout : Sonarr importe, renomme et range les épisodes
  dans sa bibliothèque, puis retire le torrent de la file Ohm
- L'indexeur Torznab embarque les paramètres du grab dans l'announce
- README : nouveau mode « client de téléchargement » recommandé (Remote
  Path Mapping documenté), blackhole en variante minimale
- 3 nouveaux tests (flux complet add → suivi → import → delete)
2026-09-22 16:42:12 +00:00

141 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:
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 » ; 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"
)
},
)