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

104 lines
3.8 KiB
Python

"""
ThePirateBay via l'API publique apibay.org (JSON, pas de clé requise).
Bon complément international (VO) aux trackers français.
"""
import asyncio
import logging
import aiohttp
API_URL = "https://apibay.org/q.php"
# Catégories vidéo TPB : 200-299 (dont 201 films, 205 TV, 207 films HD, 208 TV HD)
def _is_video_category(cat):
try:
return 200 <= int(cat) < 300
except (TypeError, ValueError):
return False
class ApibayService:
async def _query(self, query):
try:
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
async with session.get(
API_URL,
params={"q": query},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
logging.warning(f"apibay HTTP {resp.status}")
return []
data = await resp.json(content_type=None)
except Exception as e:
logging.error(f"apibay error: {e}")
return []
if not isinstance(data, list):
return []
results = []
for item in data:
# apibay renvoie une entrée factice quand il n'y a aucun résultat
if item.get("id") == "0" or item.get("info_hash", "").strip("0") == "":
continue
if not _is_video_category(item.get("category")):
continue
results.append({
"name": item.get("name", ""),
"size": int(item.get("size", 0) or 0),
"tracker_name": "TPB",
"info_hash": (item.get("info_hash") or "").lower(),
"magnet": None,
"link": None,
"source": "apibay",
"seeders": int(item.get("seeders", 0) or 0),
"leechers": int(item.get("leechers", 0) or 0),
"imdb": item.get("imdb") or "",
})
return results
async def _search_many(self, queries, imdb_id=None):
results_list = await asyncio.gather(
*[self._query(q) for q in queries if q], return_exceptions=True
)
merged, seen = [], set()
for results in results_list:
if isinstance(results, Exception):
continue
for r in results:
# Si apibay connaît l'IMDB du torrent, on écarte les hors-sujet
if imdb_id and r.get("imdb") and r["imdb"] != imdb_id:
continue
ih = r["info_hash"]
if ih and ih not in seen:
seen.add(ih)
merged.append(r)
logging.info(f"apibay: {len(merged)} résultats")
return merged
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
queries = []
if imdb_id:
queries.append(imdb_id) # apibay indexe les IMDB IDs
if title:
queries.append(f"{title} {year}".strip())
if original_title and original_title != title:
queries.append(f"{original_title} {year}".strip())
return await self._search_many(queries, imdb_id=imdb_id)
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
queries = []
se = f"S{int(season):02d}E{int(episode):02d}" if season and episode is not None else ""
s_only = f"S{int(season):02d}" if season else ""
for t in dict.fromkeys([title, original_title]):
if not t:
continue
if se:
queries.append(f"{t} {se}")
if s_only:
queries.append(f"{t} {s_only}")
if imdb_id:
queries.append(imdb_id)
return await self._search_many(queries, imdb_id=imdb_id)