mirror of
https://github.com/vatax3/ranger.git
synced 2026-08-04 21:22:28 +00:00
Audit complet du dépôt suite à un signalement de résultats vides intermittents qui se résolvaient tout seuls après plusieurs minutes. Deux causes racines identifiées et corrigées : 1. run_search() attendait TOUS les trackers (asyncio.gather sans budget) avant de renvoyer quoi que ce soit. Chaque tracker a son propre timeout (10-20s), mais un seul tracker capricieux qui va au bout du sien retarde toute la réponse — combiné à la récupération des métadonnées (jusqu'à 20s) et la vérification de disponibilité débrideur (jusqu'à 20s), la latence totale pouvait dépasser ce que Stremio tolère côté client, donnant un "0 résultat" trompeur qui se résout dès que le tracker redevient rapide. Ajout d'un budget global de 15s (asyncio.wait) : les trackers pas encore revenus sont abandonnés pour cette requête sans bloquer les autres, même principe que le budget déjà en place sur la vérification débrideur. 2. Un résultat de recherche VIDE (tracker qui répond 200 avec 0 item lors d'un accroc passager, pas une exception) était mis en cache pour le TTL_SEARCH complet (30 min par défaut) — verrouillant "aucun résultat" pour TOUT LE MONDE sur cette instance partagée jusqu'à expiration, même une fois le tracker redevenu normal. Nouveau TTL_SEARCH_EMPTY (3 min par défaut, RANGER_TTL_SEARCH_EMPTY) appliqué uniquement aux résultats vides. Durcissement additionnel (trouvé en auditant les autres appels réseau) : - Aucune instance aiohttp.ClientSession n'avait de timeout par défaut au niveau session ; plusieurs appels dans alldebrid.py/torbox.py/ stremthru.py/realdebrid.py/debridlink.py (chemin /resolve, au clic sur lecture) n'avaient pas de timeout par appel non plus, héritant du défaut aiohttp de 300s. Ajout d'un timeout de session par défaut (20s) partout, qui n'écrase pas les timeouts par-appel déjà présents. - Suppression de services/tmdb.py (code mort, jamais importé — remplacé depuis le début par core/metadata.py). Le ping /health toutes les 30s dans les logs est le HEALTHCHECK Docker normal (urllib.request, cf. Dockerfile), pas un souci. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
6.3 KiB
Python
161 lines
6.3 KiB
Python
"""
|
|
Client Torznab générique : branchez Jackett ou Prowlarr et accédez à des
|
|
centaines de trackers publics et privés (l'authentification tracker est
|
|
gérée côté Jackett/Prowlarr).
|
|
|
|
Config : [{"name": "MonIndexeur", "url": "http://jackett:9117/api/v2.0/indexers/xxx/results/torznab", "apikey": "..."}]
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import urllib.parse
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import aiohttp
|
|
|
|
from core.netsafety import is_url_safe
|
|
|
|
_HASH_RE = re.compile(r"btih:([a-fA-F0-9]{40})")
|
|
|
|
|
|
class TorznabService:
|
|
def __init__(self, indexers):
|
|
# indexers: liste de {"name", "url", "apikey"}
|
|
self.indexers = [
|
|
i for i in (indexers or [])
|
|
if (i.get("url") or "").strip()
|
|
]
|
|
|
|
async def _query(self, indexer, params):
|
|
url = indexer["url"].strip().rstrip("/")
|
|
if not url.endswith("/api") and "torznab" not in url and "/results" not in url:
|
|
url += "/api"
|
|
|
|
# SSRF : l'URL de l'indexeur vient de la config utilisateur, or Ranger
|
|
# est exposé publiquement — n'importe qui peut en forger une pointant
|
|
# vers du loopback ou les métadonnées cloud. Voir core/netsafety.py.
|
|
if not await is_url_safe(url):
|
|
logging.warning(f"Torznab [{indexer.get('name', '?')}]: URL bloquée (cible interne) : {url}")
|
|
return []
|
|
|
|
params = dict(params)
|
|
if indexer.get("apikey"):
|
|
params["apikey"] = indexer["apikey"]
|
|
|
|
log_params = {k: ("***" if k == "apikey" else v) for k, v in params.items()}
|
|
logging.info(f"Torznab [{indexer.get('name', '?')}]: ?{urllib.parse.urlencode(log_params)}")
|
|
|
|
try:
|
|
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
|
|
async with session.get(
|
|
url, params=params, timeout=aiohttp.ClientTimeout(total=20),
|
|
allow_redirects=False, # une redirection pourrait viser une cible interne non validée
|
|
) as resp:
|
|
if resp.status != 200:
|
|
logging.warning(f"Torznab [{indexer.get('name')}] HTTP {resp.status}")
|
|
return []
|
|
text = await resp.text()
|
|
except Exception as e:
|
|
logging.error(f"Torznab [{indexer.get('name')}] error: {e}")
|
|
return []
|
|
|
|
return self._parse_xml(text, indexer.get("name") or "Torznab")
|
|
|
|
@staticmethod
|
|
def _parse_xml(xml_text, indexer_name):
|
|
try:
|
|
root = ET.fromstring(xml_text)
|
|
except ET.ParseError as e:
|
|
logging.error(f"Torznab [{indexer_name}] XML invalide: {e}")
|
|
return []
|
|
|
|
ns = {"torznab": "http://torznab.com/schemas/2015/feed"}
|
|
results = []
|
|
for item in root.findall(".//item"):
|
|
title = item.findtext("title", "")
|
|
size = int(item.findtext("size", "0") or 0)
|
|
|
|
enclosure = item.find("enclosure")
|
|
download_link = enclosure.get("url", "") if enclosure is not None else item.findtext("link", "")
|
|
|
|
info_hash = None
|
|
magnet = None
|
|
seeders = 0
|
|
leechers = 0
|
|
for attr in item.findall("torznab:attr", ns):
|
|
name, value = attr.get("name"), attr.get("value")
|
|
if name == "infohash" and value:
|
|
info_hash = value.lower()
|
|
elif name == "magneturl" and value:
|
|
magnet = value
|
|
elif name == "seeders" and value:
|
|
seeders = int(value)
|
|
elif name == "peers" and value:
|
|
leechers = int(value)
|
|
|
|
# Extraction du hash depuis le magnet si besoin
|
|
if not info_hash:
|
|
for candidate in (magnet, download_link, item.findtext("guid", "")):
|
|
if candidate:
|
|
m = _HASH_RE.search(candidate)
|
|
if m:
|
|
info_hash = m.group(1).lower()
|
|
break
|
|
if not info_hash:
|
|
guid = (item.findtext("guid", "") or "").strip().lower()
|
|
if re.fullmatch(r"[a-f0-9]{40}", guid):
|
|
info_hash = guid
|
|
|
|
if not info_hash:
|
|
continue # sans hash : pas de dédup ni de débridage possible
|
|
|
|
results.append({
|
|
"name": title,
|
|
"size": size,
|
|
"tracker_name": indexer_name,
|
|
"info_hash": info_hash,
|
|
"magnet": magnet,
|
|
"link": magnet or download_link,
|
|
"source": "torznab",
|
|
"seeders": seeders,
|
|
"leechers": leechers,
|
|
})
|
|
return results
|
|
|
|
async def _search_all(self, params_builder):
|
|
tasks = [self._query(indexer, params_builder(indexer)) for indexer in self.indexers]
|
|
results_list = await asyncio.gather(*tasks, return_exceptions=True)
|
|
merged = []
|
|
for results in results_list:
|
|
if isinstance(results, Exception):
|
|
logging.error(f"Torznab task error: {results}")
|
|
continue
|
|
merged.extend(results)
|
|
logging.info(f"Torznab: {len(merged)} résultats ({len(self.indexers)} indexeurs)")
|
|
return merged
|
|
|
|
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
|
|
def build(_indexer):
|
|
params = {"t": "movie"}
|
|
if imdb_id:
|
|
params["imdbid"] = imdb_id if str(imdb_id).startswith("tt") else f"tt{imdb_id}"
|
|
else:
|
|
params["t"] = "search"
|
|
params["q"] = f"{title} {year}".strip()
|
|
return params
|
|
return await self._search_all(build)
|
|
|
|
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
|
|
def build(_indexer):
|
|
params = {"t": "tvsearch"}
|
|
if imdb_id:
|
|
params["imdbid"] = imdb_id if str(imdb_id).startswith("tt") else f"tt{imdb_id}"
|
|
else:
|
|
params["q"] = title
|
|
if season is not None:
|
|
params["season"] = season
|
|
if episode is not None:
|
|
params["ep"] = episode
|
|
return params
|
|
return await self._search_all(build)
|