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

148 lines
7.5 KiB
Python

import aiohttp
import asyncio
import logging
import urllib.parse
import xml.etree.ElementTree as ET
import re
class NyaaService:
def __init__(self):
self.base_url = "https://nyaa.si/"
async def search(self, params, max_attempts=3):
params['page'] = 'rss'
params['c'] = '0_0' # All categories
params['f'] = '0' # No filter
logging.info(f"Nyaa Search: {self.base_url}?{urllib.parse.urlencode(params)}")
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
try:
for attempt in range(max_attempts):
# Nyaa throttle vite (429 dès ~8 requêtes simultanées) : backoff + retry
if attempt > 0:
await asyncio.sleep(1.0 * attempt)
async with session.get(self.base_url, params=params, timeout=20) as response:
if response.status == 429:
logging.warning(f"Nyaa 429 rate-limit (tentative {attempt + 1}/{max_attempts})")
continue
if response.status != 200:
logging.warning(f"Nyaa Error {response.status}")
return []
text = await response.text()
try:
root = ET.fromstring(text)
except ET.ParseError as e:
logging.error(f"Nyaa XML Parse Error: {e}")
return []
items = root.findall('.//item')
logging.info(f"Nyaa found {len(items)} results")
normalized = []
for res in items:
title = res.findtext('title')
description = res.findtext('description', default='')
# Filtre strict FR : on rejette tout ce qui n'a pas de tag FR/VF/VOSTFR dans le titre ou la description
title_upper = (title or "").upper()
desc_upper = description.upper()
has_fr_in_title = any(kw in title_upper for kw in ["FRENCH", "VOSTFR", "SUBFRENCH", "TRUEFRENCH", "VFF", "VF2", "VFQ"]) or re.search(r'\bVF\b', title_upper) or re.search(r'\bFR\b', title_upper)
has_fr_in_desc = any(kw in desc_upper for kw in ["FRENCH", "FRANCAIS", "FRANÇAIS", "VOSTFR"]) or re.search(r'\bVF\b', desc_upper) or re.search(r'\bFR\b', desc_upper)
if not has_fr_in_title and not has_fr_in_desc:
continue # Skip this result as it has no French/VOSTFR
# Nyaa custom namespace for properties
nyaa_ns = {'nyaa': 'https://nyaa.si/xmlns/nyaa'}
seeders = res.findtext('nyaa:seeders', namespaces=nyaa_ns)
leechers = res.findtext('nyaa:leechers', namespaces=nyaa_ns)
size_str = res.findtext('nyaa:size', namespaces=nyaa_ns) # usually like "1.4 GiB"
info_hash = res.findtext('nyaa:infoHash', namespaces=nyaa_ns)
download_link = res.findtext('link')
# Convert size string to bytes roughly (Frenchio uses size in bytes)
size_bytes = 0
if size_str:
try:
parts = size_str.strip().split()
if len(parts) == 2:
val = float(parts[0])
unit = parts[1].lower()
if 'gib' in unit or 'gb' in unit:
size_bytes = int(val * 1024 * 1024 * 1024)
elif 'mib' in unit or 'mb' in unit:
size_bytes = int(val * 1024 * 1024)
elif 'kib' in unit or 'kb' in unit:
size_bytes = int(val * 1024)
else:
size_bytes = int(val)
except Exception:
size_bytes = 0
if not info_hash:
continue
item = {
"name": title,
"size": size_bytes,
"tracker_name": "Nyaa",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "nyaa",
"seeders": int(seeders) if seeders else 0,
"leechers": int(leechers) if leechers else 0
}
normalized.append(item)
return normalized
logging.warning(f"Nyaa: abandon après {max_attempts} tentatives (rate-limit)")
except Exception as e:
logging.error(f"Nyaa Exception: {e}")
return []
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
params = {"q": f"{title} {year}"}
return await self.search(params)
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None, absolute_episode=None):
queries = [title]
if season is not None and episode is not None:
queries.append(f"{title} S{season:02d}E{episode:02d}")
queries.append(f"{title} {episode:02d}")
queries.append(f"{title} S{season:02d}")
queries.append(f"{title} Saison {season}")
queries.append(f"{title} Integrale")
# Numérotation absolue anime (ex: "One Piece 1122", "One Piece S01E1122")
if absolute_episode is not None and absolute_episode != episode:
queries.append(f"{title} {absolute_episode:02d}")
queries.append(f"{title} S01E{absolute_episode:02d}")
# OVA / Spéciaux (saison 0) : nommage fansub sans SxxExx
if season == 0 and episode is not None:
queries.append(f"{title} OVA")
queries.append(f"{title} OAV")
queries.append(f"{title} Special")
queries.append(f"{title} OVA {episode:02d}")
all_results = []
seen_hashes = set()
# Lancement étalé (300ms) pour ne pas déclencher le rate-limit de Nyaa
tasks = []
for q in dict.fromkeys(queries):
tasks.append(asyncio.create_task(self.search({"q": q})))
await asyncio.sleep(0.3)
results_list = await asyncio.gather(*tasks, return_exceptions=True)
for res in results_list:
if isinstance(res, list):
for item in res:
info_hash = item.get('info_hash')
if info_hash and info_hash not in seen_hashes:
seen_hashes.add(info_hash)
all_results.append(item)
return all_results