ranger/services/unit3d.py
Valentin 6e6703d012 Fix: "0 résultats" intermittent — budget de recherche + cache vide trop long
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>
2026-07-20 09:30:49 +02:00

158 lines
6.6 KiB
Python

import aiohttp
import asyncio
import logging
import json
from urllib.parse import urlencode
from core.netsafety import is_url_safe
class Unit3DService:
def __init__(self, trackers_config):
"""
trackers_config: liste de dicts {url, token, categories}
"""
self.trackers = trackers_config
async def search_tracker(self, session, tracker, query_params):
url = f"{tracker['url']}/api/torrents/filter"
# SSRF : l'URL du tracker vient de la config utilisateur, or Ranger
# est exposé publiquement. Voir core/netsafety.py.
if not await is_url_safe(url):
logging.warning(f"UNIT3D: URL bloquée (cible interne) : {tracker['url']}")
return []
params = {
"api_token": tracker['token'],
**query_params
}
# On ignore les catégories comme demandé
if 'categories' in params:
del params['categories']
# Construction de la query string standard
query_string = urlencode(params)
full_url = f"{url}?{query_string}"
# Masquage du token pour les logs
log_url = full_url.replace(tracker['token'], '***TOKEN***')
logging.info(f"[{tracker['url']}] Requesting: {log_url}")
try:
async with session.get(full_url, timeout=15, allow_redirects=False) as response:
logging.info(f"[{tracker['url']}] Status: {response.status}")
if response.status == 200:
text_data = await response.text()
try:
data = json.loads(text_data)
except json.JSONDecodeError as e:
logging.error(f"[{tracker['url']}] JSON Decode Error: {e}")
return []
results = []
if isinstance(data, dict):
if 'data' in data and isinstance(data['data'], list):
results = data['data']
else:
# Cas où data serait directement la liste ou autre structure
# logging.warning(f"[{tracker['url']}] Structure 'data' list not found. Keys: {data.keys()}")
pass
elif isinstance(data, list):
results = data
logging.info(f"[{tracker['url']}] Found {len(results)} items for params {query_params}")
cleaned_results = []
for res in results:
item = res
if 'attributes' in res:
item = {**res, **res['attributes']}
item['tracker_name'] = tracker['url']
# Extraction du lien de téléchargement pour qBittorrent
# Format typique: {"download_link": "https://tracker.com/torrents/download/123?api_token=xxx"}
if 'download_link' in item:
item['link'] = item['download_link']
elif 'download_link' in res.get('attributes', {}):
item['link'] = res['attributes']['download_link']
cleaned_results.append(item)
return cleaned_results
else:
logging.warning(f"[{tracker['url']}] Error Status: {response.status}")
# text = await response.text()
# logging.warning(f"[{tracker['url']}] Error Body: {text[:200]}")
except Exception as e:
logging.error(f"[{tracker['url']}] Exception: {e}")
# Traceback complet inutile si c'est juste un timeout ou connection error fréquent
# import traceback
# logging.error(traceback.format_exc())
pass
return []
async def search_all(self, tmdb_id=None, imdb_id=None, type=None, season=None, episode=None):
tasks = []
# Préparation des paramètres
params_list = []
# 1. Recherche Standard (Saison + Episode si dispo)
base_params = {}
if type == 'series' and season is not None:
base_params['seasonNumber'] = season
if episode is not None:
base_params['episodeNumber'] = episode
params_list.append(base_params)
# 2. Recherche Pack Saison (Saison sans Episode)
# Si on a un épisode, on ajoute aussi une recherche pour la saison entière pour trouver les packs
if type == 'series' and season is not None and episode is not None:
pack_params = {'seasonNumber': season}
params_list.append(pack_params)
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
for tracker in self.trackers:
for common_params in params_list:
# Recherche TMDB
if tmdb_id:
params_tmdb = {'tmdbId': tmdb_id, **common_params}
tasks.append(self.search_tracker(session, tracker, params_tmdb))
# Recherche IMDB
if imdb_id:
# Certains trackers UNIT3D attendent l'ID sans 'tt'
clean_imdb = imdb_id.replace('tt', '')
params_imdb = {'imdbId': clean_imdb, **common_params}
tasks.append(self.search_tracker(session, tracker, params_imdb))
logging.info(f"Launching {len(tasks)} search tasks across {len(self.trackers)} trackers")
# Exécution parallèle de toutes les requêtes
responses = await asyncio.gather(*tasks)
# Aplatir les résultats
all_results = []
for resp in responses:
all_results.extend(resp)
# Filtrage et déduplication
unique_results = {} # info_hash -> data
for res in all_results:
info_hash = res.get('info_hash') or res.get('attributes', {}).get('info_hash')
if info_hash:
if info_hash not in unique_results:
unique_results[info_hash] = res
else:
pass
# logging.debug("Item without info_hash ignored")
logging.info(f"Total unique torrents found after deduplication: {len(unique_results)}")
return list(unique_results.values())