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

234 lines
11 KiB
Python

import aiohttp
import logging
import asyncio
import re
from utils import check_absolute_episode
class DebridLinkService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://debrid-link.com/api/v2"
async def _list_existing_torrent_ids(self):
"""Récupère les IDs des torrents déjà présents sur le seedbox."""
headers = {
"Authorization": f"Bearer {self.api_key}",
}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
list_url = f"{self.base_url}/seedbox/list"
async with session.get(list_url, headers=headers, timeout=10) as resp:
if resp.status != 200:
return set()
data = await resp.json()
if data.get('success'):
return {t.get('id') for t in data.get('value', []) if t.get('id')}
except Exception as e:
logging.error(f"DebridLink: Error listing existing torrents: {e}")
return set()
async def check_availability(self, hashes):
"""
Vérifie la disponibilité de plusieurs hash en parallèle.
Retourne un dict {hash: bool} indiquant si chaque hash est caché.
Ne supprime que les torrents qu'on a ajouté nous-mêmes (pas les pré-existants).
"""
if not hashes:
return {}
logging.info(f"DebridLink: Checking {len(hashes)} hashes in parallel")
# 1. Snapshot des torrents existants AVANT nos ajouts
existing_ids = await self._list_existing_torrent_ids()
logging.info(f"DebridLink: {len(existing_ids)} torrents already on seedbox")
# 2. Vérifier chaque hash (retourne (is_cached, torrent_id))
tasks = [self._check_single_hash(h) for h in hashes]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 3. Construire les résultats et collecter les IDs à supprimer
availability = {}
ids_to_delete = []
for hash_value, result in zip(hashes, results):
if isinstance(result, Exception):
logging.error(f"DebridLink: Error checking {hash_value}: {result}")
availability[hash_value.lower()] = False
else:
is_cached, torrent_id = result
availability[hash_value.lower()] = is_cached
# Ne supprimer que si c'est un torrent qu'on a ajouté nous-mêmes
if torrent_id and torrent_id not in existing_ids:
ids_to_delete.append(torrent_id)
cached_count = sum(1 for v in availability.values() if v)
logging.info(f"DebridLink: {cached_count}/{len(hashes)} hashes are cached")
# Cleanup en arrière-plan — ne bloque pas le retour des résultats
if ids_to_delete:
async def _cleanup():
headers = {"Authorization": f"Bearer {self.api_key}"}
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
await asyncio.gather(
*[self._remove_torrent(session, headers, tid) for tid in ids_to_delete],
return_exceptions=True
)
asyncio.create_task(_cleanup())
return availability
async def _check_single_hash(self, hash_value):
"""
Vérifie un seul hash en l'ajoutant au seedbox.
Retourne (is_cached, torrent_id) pour que le caller gère le cleanup.
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
add_url = f"{self.base_url}/seedbox/add"
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
payload = {
"url": hash_value,
"wait": False
}
async with session.post(add_url, json=payload, headers=headers, timeout=10) as resp:
if resp.status != 200:
logging.warning(f"DebridLink: Failed to add {hash_value[:8]}... status {resp.status}")
return (False, None)
data = await resp.json()
if not data.get('success'):
logging.debug(f"DebridLink: {hash_value[:8]}... not successful")
return (False, None)
torrent = data.get('value', {})
torrent_id = torrent.get('id')
download_percent = torrent.get('downloadPercent', 0)
error = torrent.get('error', 0)
is_cached = error == 0 and download_percent == 100
if is_cached:
logging.debug(f"DebridLink: {hash_value[:8]}... cached!")
else:
logging.debug(f"DebridLink: {hash_value[:8]}... not cached")
return (is_cached, torrent_id)
except asyncio.TimeoutError:
logging.warning(f"DebridLink: Timeout checking {hash_value[:8]}...")
return (False, None)
except Exception as e:
logging.error(f"DebridLink: Exception checking {hash_value[:8]}...: {e}")
return (False, None)
async def _remove_torrent(self, session, headers, torrent_id):
"""Supprime un torrent du seedbox"""
try:
remove_url = f"{self.base_url}/seedbox/{torrent_id}/remove"
async with session.delete(remove_url, headers=headers, timeout=5) as resp:
if resp.status == 200:
logging.debug(f"DebridLink: Removed torrent {torrent_id}")
else:
logging.warning(f"DebridLink: Failed to remove {torrent_id}: {resp.status}")
except Exception as e:
logging.error(f"DebridLink: Error removing {torrent_id}: {e}")
async def unlock_magnet(self, info_hash, season=None, episode=None, media_type=None, absolute_episode=None):
"""
Déverrouille un magnet et retourne l'URL de streaming
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
add_url = f"{self.base_url}/seedbox/add"
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
# Ajouter le torrent
payload = {
"url": info_hash,
"wait": False
}
async with session.post(add_url, json=payload, headers=headers, timeout=15) as resp:
if resp.status != 200:
logging.error(f"DebridLink: Failed to add torrent: {resp.status}")
return None
data = await resp.json()
if not data.get('success'):
logging.error("DebridLink: Add torrent failed")
return None
torrent = data.get('value', {})
torrent_id = torrent.get('id')
files = torrent.get('files', [])
if not files:
logging.error("DebridLink: No files in torrent")
return None
# Sélectionner le bon fichier
selected_file = None
if season is not None and episode is not None:
patterns = [
rf"S{season:02d}E{episode:02d}",
rf"S{season}E{episode:02d}",
rf"{season}x{episode:02d}",
rf"{season}x{episode}\b",
]
for f in files:
filename = f.get('name', '')
if any(re.search(p, filename, re.IGNORECASE) for p in patterns):
selected_file = f
break
# Numérotation absolue (fansub anime) : les packs
# multi-épisodes anime ne matchent pas SxxExx.
if not selected_file and absolute_episode is not None:
for f in files:
if check_absolute_episode(f.get('name', ''), absolute_episode, exclude_packs=True):
selected_file = f
break
else:
# Film : prendre le plus gros fichier vidéo
video_extensions = ('.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts')
video_files = [f for f in files if f.get('name', '').lower().endswith(video_extensions)]
if video_files:
selected_file = max(video_files, key=lambda x: x.get('size', 0))
else:
# Fallback si pas d'extension trouvée (nom sans extension ?)
# On exclut au moins les fichiers connus pour ne pas être des vidéos
bad_extensions = ('.iso', '.pdf', '.epub', '.txt', '.nfo', '.rar', '.zip')
filtered_files = [f for f in files if not f.get('name', '').lower().endswith(bad_extensions)]
if filtered_files:
selected_file = max(filtered_files, key=lambda x: x.get('size', 0))
if selected_file:
download_url = selected_file.get('downloadUrl')
if download_url:
logging.info(f"DebridLink: Stream URL found for torrent {torrent_id}")
return download_url
logging.error("DebridLink: Could not find suitable file")
return None
except Exception as e:
logging.error(f"DebridLink: Exception in unlock_magnet: {e}")
return None