"""Indexeur Torznab/Newznab : expose le catalogue OhmStreaming à Sonarr/Prowlarr.
OhmStreaming devient une source d'indexeur à part entière : Sonarr (ou Prowlarr,
qui relaiera vers Radarr/Lidarr…) interroge `/torznab/api` comme n'importe quel
indexer Jackett. Chaque « release » correspond à un épisode résolu depuis les
sources de scraping ; le grab (`/torznab/download`) déclenche l'extraction puis
l'ajout dans la file de téléchargements OhmStreaming — le fichier arrive donc
dans la bibliothèque locale, comme un téléchargement manuel.
Formats :
- `t=caps` → capacités du serveur (catégories TV/Anime, paramètres supportés)
- `t=tvsearch` → recherche par série + saison + épisode (Sonarr)
- `t=search` → recherche libre (Prowlarr, recherche manuelle)
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import format_datetime
from urllib.parse import urlencode
from app.scrapers.base import (
Episode,
ScrapeError,
SourceScraper,
VideoLink,
all_sources,
import_all_scrapers,
resolve_hoster,
)
from app.services.downloads import download_manager
from app.services.settings import is_source_enabled
logger = logging.getLogger(__name__)
import_all_scrapers()
TORZNAB_NS = "http://torznab.com/schemas/2015/feed"
CAT_TV = "5000"
CAT_ANIME = "5070"
_SIZE_ESTIMATE = 400 * 1024 * 1024 # estimation affichée (~400 Mo/épisode)
_MAX_SERIES_PER_SOURCE = 2 # fiches explorées par source lors d'une recherche
_MAX_RELEASES = 100 # borne du flux retourné
_SEARCH_TIMEOUT = 40.0 # scraping lent : garde-fou par source
_EPISODE_CACHE_TTL = 600.0 # listes d'épisodes re-scrapées au bout de 10 min
@dataclass
class Release:
"""Un épisode vu comme une release par Sonarr/Prowlarr."""
series: str
season: int
ep: int
source: str
source_id: str
episode_url: str
@property
def sonarr_title(self) -> str:
return f"{self.series} S{self.season:02d}E{self.ep:02d} VOSTFR WEB-DL"
def _bencode(value) -> bytes:
if isinstance(value, int):
return f"i{value}e".encode()
if isinstance(value, str):
raw = value.encode()
return f"{len(raw)}:".encode() + raw
if isinstance(value, bytes):
return f"{len(value)}:".encode() + value
if isinstance(value, dict):
return b"d" + b"".join(
_bencode(k) + _bencode(v) for k, v in sorted(value.items())
) + b"e"
if isinstance(value, list):
return b"l" + b"".join(_bencode(v) for v in value) + b"e"
raise TypeError(f"type non encodable en bencode : {type(value)!r}")
def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming)."""
return _bencode(
{
"announce": announce_url,
"created by": "OhmStreaming",
"comment": name,
"info": {
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
}
)
class TorznabService:
"""Recherche multi-sources mappée en releases Torznab + grab → file interne."""
def __init__(self) -> None:
self._episodes_cache: dict[tuple[str, str], tuple[float, list[Episode]]] = {}
# ------------------------------------------------------------ recherche
async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]:
"""Recherche type Sonarr : série (+ saison/épisode optionnels)."""
releases: list[Release] = []
for source in await self._enabled_sources():
outcomes = await self._search_source(source, q)
for result in outcomes[:_MAX_SERIES_PER_SOURCE]:
try:
episodes = await self._episodes_of(source, result.source_id)
except (ScrapeError, TimeoutError):
continue
releases.extend(
self._releases_for(result.title, source, result.source_id, episodes, season, ep)
)
if len(releases) >= _MAX_RELEASES:
return releases[:_MAX_RELEASES]
return releases
async def search(self, q: str) -> list[Release]:
"""Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q)
async def _search_source(self, source: SourceScraper, q: str):
try:
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
except (ScrapeError, TimeoutError) as exc:
logger.warning("Torznab : recherche %s KO pour %r : %s", source.name, q, exc)
return []
async def _enabled_sources(self) -> list[SourceScraper]:
return [s for s in all_sources() if await is_source_enabled(s.name)]
async def _episodes_of(self, source: SourceScraper, source_id: str) -> list[Episode]:
"""Liste d'épisodes d'une fiche, avec cache mémoire TTL."""
key = (source.name, source_id)
cached = self._episodes_cache.get(key)
if cached is not None and cached[0] > time.monotonic():
return cached[1]
episodes = await asyncio.wait_for(source.list_episodes(source_id), timeout=_SEARCH_TIMEOUT)
self._episodes_cache[key] = (time.monotonic() + _EPISODE_CACHE_TTL, episodes)
return episodes
def _releases_for(
self,
series: str,
source: SourceScraper,
source_id: str,
episodes: list[Episode],
season: int | None,
ep: int | None,
) -> list[Release]:
"""Filtre les épisodes selon saison/épisode demandés (entiers uniquement)."""
out = []
for item in episodes:
if item.number != int(item.number): # OAV 2.5 → ignorée (inparseable Sonarr)
continue
if season is not None and item.season != season:
continue
if ep is not None and int(item.number) != ep:
continue
out.append(
Release(
series=series,
season=item.season,
ep=int(item.number),
source=source.name,
source_id=source_id,
episode_url=item.url,
)
)
return out
# ------------------------------------------------------------ grab
async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict:
"""Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne.
Retourne le dict du téléchargement (existant si doublon actif).
Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu.
"""
from app.scrapers.base import get_source
scraper = get_source(source)
episodes = await self._episodes_of(scraper, source_id)
match = next(
(
e
for e in episodes
if e.season == season and e.number == int(ep)
),
None,
)
if match is None:
raise ScrapeError(f"Épisode S{season:02d}E{ep:02d} introuvable sur {source}")
link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}"
result = await download_manager.enqueue(link.url, match.url, title)
if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg"
logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
return result
async def _resolve_video(self, scraper: SourceScraper, episode_url: str) -> VideoLink:
"""Chaîne complète : page épisode → embeds → première URL directe valide."""
embeds = await asyncio.wait_for(
scraper.extract_embed_links(episode_url), timeout=_SEARCH_TIMEOUT
)
errors: list[str] = []
for embed in embeds:
extractor = resolve_hoster(embed)
if extractor is None:
errors.append(f"hébergeur non supporté : {embed}")
continue
try:
link = await asyncio.wait_for(extractor.extract(embed), timeout=_SEARCH_TIMEOUT)
if link and link.url:
return link
except (ScrapeError, TimeoutError) as exc:
errors.append(str(exc))
raise ScrapeError(f"Aucun hébergeur résolu pour {episode_url} ({'; '.join(errors[:3])})")
# ------------------------------------------------------------ XML
def download_url(self, base_url: str, apikey: str, release: Release) -> str:
query = urlencode(
{
"apikey": apikey,
"source": release.source,
"sid": release.source_id,
"season": release.season,
"ep": release.ep,
"series": release.series,
}
)
return f"{base_url}/torznab/download?{query}"
def caps_xml(self, base_url: str) -> str:
return f"""
"""
def results_xml(self, base_url: str, apikey: str, releases: list[Release]) -> str:
from xml.sax.saxutils import escape
items = []
for release in releases:
url = escape(self.download_url(base_url, apikey, release))
pubdate = escape(format_datetime(datetime.now(UTC)))
items.append(f""" -
{escape(release.sonarr_title)}
{url}
{url}
{escape(release.episode_url)}
{pubdate}
{CAT_ANIME}
""")
body = "\n".join(items)
return f"""
OhmStreaming
{escape(base_url)}
Indexeur OhmStreaming — animes VOSTFR scrapés en direct
fr-FR
{body}
"""
def error_xml(self, code: int, description: str) -> str:
from xml.sax.saxutils import escape
return (
'\n'
f''
)
torznab = TorznabService()