"""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()