ranger/services/torbox.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

429 lines
17 KiB
Python

"""
TorBox Debrid Service
Converti en async avec aiohttp et compatible avec l'architecture Frenchio
"""
import aiohttp
import asyncio
import logging
import re
from utils import check_absolute_episode
class TorBoxService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.torbox.app/v1/api"
self.headers = {
"Authorization": f"Bearer {api_key}",
}
async def check_availability(self, magnet_hash):
"""
Vérifie si un hash est en cache sur TorBox.
Args:
magnet_hash: Hash du torrent
Returns:
dict ou None: Informations si disponible
"""
url = f"{self.base_url}/torrents/checkcached"
params = {
"hash": magnet_hash,
"format": "object",
"list_files": "true"
}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
logging.warning(f"TorBox check availability returned {response.status}")
return None
data = await response.json()
if data.get("success") and data.get("data"):
# data est un dict avec le hash comme clé
torrent_info = data["data"].get(magnet_hash)
if torrent_info:
return {
"name": torrent_info.get("name", ""),
"size": torrent_info.get("size", 0),
"files": torrent_info.get("files", []),
"cached": True
}
return None
except Exception as e:
logging.error(f"TorBox check availability error: {e}")
return None
async def add_magnet(self, magnet_link):
"""
Ajoute un magnet à TorBox.
Args:
magnet_link: Lien magnet
Returns:
dict ou None: {"torrent_id": id, "hash": hash, "is_cached": bool}
"""
url = f"{self.base_url}/torrents/createtorrent"
data = {
"magnet": magnet_link,
"seed": 2 # Mode de seed
}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
async with session.post(url, headers=self.headers, data=data) as response:
if response.status != 200:
text = await response.text()
logging.error(f"TorBox add magnet failed: {response.status} - {text}")
return None
result = await response.json()
if result.get("success"):
data = result.get("data", {})
cached = "Found Cached Torrent" in result.get("detail", "")
return {
"torrent_id": data.get("torrent_id"),
"hash": data.get("hash"),
"is_cached": cached
}
else:
logging.error(f"TorBox add magnet failed: {result}")
return None
except Exception as e:
logging.error(f"TorBox add magnet error: {e}")
return None
async def get_torrent_info(self, torrent_hash):
"""
Récupère les informations d'un torrent via checkcached (pour vérification cache).
Args:
torrent_hash: Hash du torrent
Returns:
dict ou None
"""
url = f"{self.base_url}/torrents/checkcached"
params = {
"hash": torrent_hash,
"format": "object",
"list_files": "true"
}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
return None
data = await response.json()
if data.get("success") and data.get("data"):
return data["data"].get(torrent_hash)
return None
except Exception as e:
logging.error(f"TorBox get torrent info error: {e}")
return None
async def get_torrent_details(self, torrent_id):
"""
Récupère les détails complets d'un torrent ajouté via mylist.
Retourne les fichiers avec leurs vrais IDs assignés par TorBox.
Args:
torrent_id: ID du torrent sur TorBox
Returns:
dict ou None: Contient les fichiers avec leur champ "id"
"""
url = f"{self.base_url}/torrents/mylist"
params = {"id": torrent_id}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
text = await response.text()
logging.error(f"TorBox get torrent details failed: {response.status} - {text}")
return None
data = await response.json()
if data.get("success"):
return data.get("data")
else:
logging.error(f"TorBox get torrent details failed: {data}")
return None
except Exception as e:
logging.error(f"TorBox get torrent details error: {e}")
return None
async def wait_for_files(self, torrent_hash, timeout=30, interval=5):
"""
Attend que les fichiers soient disponibles (pour torrents non-cachés).
Args:
torrent_hash: Hash du torrent
timeout: Timeout en secondes
interval: Intervalle entre les vérifications
Returns:
list ou None: Liste des fichiers
"""
start_time = asyncio.get_event_loop().time()
while (asyncio.get_event_loop().time() - start_time) < timeout:
info = await self.get_torrent_info(torrent_hash)
if info and "files" in info:
files = info["files"]
if files:
logging.info(f"TorBox: Files ready ({len(files)} files)")
return files
logging.info("TorBox: Files not ready yet, waiting...")
await asyncio.sleep(interval)
logging.error("TorBox: Timeout waiting for files")
return None
async def get_download_link(self, torrent_id, file_id, max_retries=3):
"""
Récupère le lien de téléchargement direct d'un fichier.
Avec retry automatique en cas d'erreur 500 (DATABASE_ERROR).
Args:
torrent_id: ID du torrent sur TorBox
file_id: ID/index du fichier
max_retries: Nombre maximum de tentatives (défaut: 3)
Returns:
str ou None: Lien de téléchargement
"""
url = f"{self.base_url}/torrents/requestdl"
params = {
"token": self.api_key,
"torrent_id": torrent_id,
"file_id": file_id,
"zip_link": "false",
"torrent_file": "false"
}
for attempt in range(max_retries):
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
if attempt > 0:
logging.info(f"TorBox: Retry attempt {attempt + 1}/{max_retries}")
await asyncio.sleep(1 + attempt) # Délai croissant: 1s, 2s, 3s
logging.debug(f"TorBox: Requesting download with params: {params}")
async with session.get(url, headers=self.headers, params=params) as response:
text = await response.text()
if response.status == 500:
# Erreur serveur temporaire, on va retry
logging.warning(f"TorBox: Server error 500, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
continue # Retry
else:
logging.error(f"TorBox: Max retries reached - {text}")
return None
if response.status != 200:
logging.error(f"TorBox get download link failed: {response.status} - {text}")
logging.error(f"TorBox: URL was: {url} with params: {params}")
return None
data = await response.json()
if data.get("success"):
download_url = data.get("data")
logging.info(f"TorBox: Download link obtained: {download_url}")
return download_url
else:
logging.error(f"TorBox download link failed: {data}")
return None
except Exception as e:
logging.error(f"TorBox get download link error (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1 + attempt)
continue
return None
async def get_stream_link(self, magnet_link, stream_type, season=None, episode=None, absolute_episode=None):
"""
Obtient un lien de streaming depuis un magnet.
Args:
magnet_link: Lien magnet
stream_type: "movie" ou "series"
season: Numéro de saison (pour séries)
episode: Numéro d'épisode (pour séries)
Returns:
str ou None: Lien de streaming
"""
# 1. Ajouter le magnet
magnet_data = await self.add_magnet(magnet_link)
if not magnet_data:
logging.error("TorBox: Failed to add magnet")
return None
torrent_id = magnet_data.get("torrent_id")
torrent_hash = magnet_data.get("hash")
is_cached = magnet_data.get("is_cached", False)
if not torrent_id or not torrent_hash:
logging.error("TorBox: Missing torrent_id or hash")
return None
# 2. Récupérer les fichiers avec leurs IDs réels via mylist
logging.info(f"TorBox: Fetching torrent details for ID {torrent_id}")
torrent_details = await self.get_torrent_details(torrent_id)
if not torrent_details or "files" not in torrent_details:
logging.error("TorBox: Failed to get torrent details or no files")
return None
files = torrent_details["files"]
logging.info(f"TorBox: Found {len(files)} files in torrent")
# 3. Sélectionner le fichier approprié
# IMPORTANT : Utiliser le champ "id" du fichier, pas l'index dans le tableau
# Log tous les fichiers pour debug
logging.info(f"TorBox: All files in torrent:")
for f in files:
file_id_debug = f.get('id', 'N/A')
logging.info(f" [id={file_id_debug}] {f.get('name')} - {f.get('size', 0)} bytes - video: {self._is_video_file(f.get('name', ''))}")
if stream_type == "movie":
# Plus gros fichier vidéo
video_files = [
f for f in files
if self._is_video_file(f.get("name", ""))
]
if not video_files:
logging.error("TorBox: No video files found")
return None
selected_file = max(video_files, key=lambda x: x.get("size", 0))
# Utiliser le champ "id" du fichier
file_id = selected_file.get("id")
if file_id is None:
logging.error("TorBox: Selected file has no 'id' field")
return None
logging.info(f"TorBox: Selected file (id={file_id}): {selected_file.get('name')}")
elif stream_type == "series":
# Fichier correspondant à S{season}E{episode}
matching_files = []
for f in files:
filename = f.get("name", "")
is_video = self._is_video_file(filename)
matches_ep = self._matches_episode(filename, season, episode)
logging.debug(f" File {filename}: is_video={is_video}, matches_S{season:02d}E{episode:02d}={matches_ep}")
if is_video and matches_ep:
matching_files.append(f)
# Numérotation absolue (fansub anime) : les packs multi-épisodes
# anime ne matchent pas SxxExx.
if not matching_files and absolute_episode is not None:
for f in files:
if self._is_video_file(f.get("name", "")) and check_absolute_episode(
f.get("name", ""), absolute_episode, exclude_packs=True
):
matching_files.append(f)
if matching_files:
logging.info(f"TorBox: Match absolu trouvé (épisode {absolute_episode})")
if not matching_files:
logging.error(f"TorBox: No video file matching S{season:02d}E{episode:02d}")
return None
logging.info(f"TorBox: Found {len(matching_files)} matching video file(s)")
# Prendre le plus gros si plusieurs matchent
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
# Utiliser le champ "id" du fichier
file_id = selected_file.get("id")
if file_id is None:
logging.error("TorBox: Selected file has no 'id' field")
return None
logging.info(f"TorBox: Selected episode file (id={file_id}): {selected_file.get('name')}")
else:
logging.error(f"TorBox: Unsupported stream type: {stream_type}")
return None
# 4. Obtenir le lien de téléchargement
# Utiliser le champ "id" du fichier comme file_id
logging.info(f"TorBox: Requesting download link with file_id={file_id} (torrent_id={torrent_id})")
download_link = await self.get_download_link(torrent_id, file_id)
if download_link:
logging.info(f"TorBox: Stream link obtained successfully: {download_link}")
return download_link
else:
logging.error(f"TorBox: Failed to get download link")
return None
def _is_video_file(self, filename):
"""Vérifie si un fichier est une vidéo."""
video_extensions = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts', '.vob'}
excluded_extensions = {'.nfo', '.txt', '.srt', '.sub', '.idx', '.jpg', '.png', '.gif', '.iso', '.pdf', '.epub', '.zip', '.rar'}
filename_lower = filename.lower()
# Exclure explicitement les fichiers non-vidéo
if any(filename_lower.endswith(ext) for ext in excluded_extensions):
return False
# Vérifier que c'est une vidéo
return any(filename_lower.endswith(ext) for ext in video_extensions)
def _matches_episode(self, filename, season, episode):
"""
Vérifie si un nom de fichier correspond à un épisode.
Patterns supportés:
- S01E01
- 1x01
- s01e01
- etc.
"""
if not season or not episode:
return False
patterns = [
rf"[Ss]{season:02d}[Ee]{episode:02d}", # S01E01
rf"[Ss]{season}[Ee]{episode}", # S1E1
rf"{season}x{episode:02d}", # 1x01
rf"{season}x{episode}", # 1x1
]
for pattern in patterns:
if re.search(pattern, filename):
return True
return False