mirror of
https://github.com/vatax3/ranger.git
synced 2026-07-27 00:52:09 +00:00
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>
154 lines
7.5 KiB
Python
154 lines
7.5 KiB
Python
import aiohttp
|
|
import logging
|
|
import urllib.parse
|
|
import xml.etree.ElementTree as ET
|
|
import re
|
|
|
|
class NekoBTService:
|
|
def __init__(self, apikey):
|
|
self.apikey = apikey
|
|
self.base_url = "https://nekobt.to/api/torznab/api"
|
|
|
|
async def search(self, params):
|
|
if not self.apikey:
|
|
return []
|
|
|
|
params['apikey'] = self.apikey
|
|
params['cat'] = '5070' # Anime category for NekoBT
|
|
params['t'] = 'search' # Force generic search since NekoBT doesn't handle tmdbid well
|
|
|
|
# Log request (masking apikey)
|
|
log_params = params.copy()
|
|
log_params['apikey'] = '***APIKEY***'
|
|
logging.info(f"NekoBT Search: {self.base_url}?{urllib.parse.urlencode(log_params)}")
|
|
|
|
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
|
|
try:
|
|
async with session.get(self.base_url, params=params, timeout=20) as response:
|
|
if response.status == 200:
|
|
text = await response.text()
|
|
|
|
try:
|
|
root = ET.fromstring(text)
|
|
except ET.ParseError as e:
|
|
logging.error(f"NekoBT XML Parse Error: {e}")
|
|
return []
|
|
|
|
items = root.findall('.//item')
|
|
logging.info(f"NekoBT 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
|
|
|
|
torznab_ns = {'torznab': 'http://torznab.com/schemas/2015/feed'}
|
|
|
|
info_hash = None
|
|
seeders = 0
|
|
leechers = 0
|
|
size = int(res.findtext('size', default='0'))
|
|
|
|
for attr in res.findall('torznab:attr', namespaces=torznab_ns):
|
|
name = attr.get('name')
|
|
value = attr.get('value')
|
|
|
|
if name == 'infohash':
|
|
info_hash = value
|
|
elif name == 'seeders':
|
|
seeders = int(value) if value else 0
|
|
elif name == 'peers':
|
|
leechers = int(value) if value else 0
|
|
elif name == 'size' and size == 0:
|
|
size = int(value) if value else 0
|
|
|
|
# Fallback si infohash introuvable
|
|
if not info_hash:
|
|
guid = res.findtext('guid')
|
|
if guid and len(guid) >= 40: # Extrait l'infohash d'une string si c'est un magnet ou un hash brut
|
|
match = re.search(r'([a-fA-F0-9]{40})', guid)
|
|
if match:
|
|
info_hash = match.group(1)
|
|
|
|
if not info_hash:
|
|
continue
|
|
|
|
enclosure = res.find('enclosure')
|
|
download_link = enclosure.get('url') if enclosure is not None else None
|
|
if not download_link:
|
|
download_link = res.findtext('link')
|
|
|
|
item = {
|
|
"name": title,
|
|
"size": size,
|
|
"tracker_name": "NekoBT",
|
|
"info_hash": info_hash,
|
|
"magnet": None,
|
|
"link": download_link,
|
|
"source": "nekobt",
|
|
"seeders": seeders,
|
|
"leechers": leechers
|
|
}
|
|
normalized.append(item)
|
|
return normalized
|
|
else:
|
|
logging.warning(f"NekoBT Error {response.status}")
|
|
except Exception as e:
|
|
logging.error(f"NekoBT 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):
|
|
# Méthode inspirée de UwU-FR : Lancer plusieurs requêtes en parallèle pour contourner
|
|
# la limite de 50/100 résultats de l'API NekoBT et trouver les vieux épisodes/packs.
|
|
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 de rate-limit
|
|
import asyncio
|
|
tasks = []
|
|
for q in dict.fromkeys(queries):
|
|
tasks.append(asyncio.create_task(self.search({"q": q, "limit": "100"})))
|
|
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
|