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