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).
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user