mirror of
https://github.com/vatax3/ranger.git
synced 2026-07-27 05:02: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>
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
"""
|
||
Métadonnées du média demandé : titre FR + titre original + année + détection
|
||
anime + numérotation absolue des épisodes.
|
||
|
||
Source principale : TMDB (clé utilisateur). Fallback : Cinemeta (sans clé,
|
||
titres anglais uniquement).
|
||
Résultats mis en cache SQLite (TTL 7 jours).
|
||
"""
|
||
|
||
import logging
|
||
|
||
import aiohttp
|
||
|
||
from core import cache
|
||
|
||
TMDB_BASE = "https://api.themoviedb.org/3"
|
||
CINEMETA_BASE = "https://v3-cinemeta.strem.io/meta"
|
||
|
||
|
||
async def get_media_info(imdb_id, stream_type, tmdb_key):
|
||
"""
|
||
Retourne un dict :
|
||
{tmdb_id, title, original_title, year, is_anime, seasons: [{season_number, episode_count}]}
|
||
ou None si introuvable.
|
||
"""
|
||
cache_key = f"media:{stream_type}:{imdb_id}:{'tmdb' if tmdb_key else 'cinemeta'}"
|
||
cached = cache.get_meta(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
info = None
|
||
if tmdb_key:
|
||
info = await _from_tmdb(imdb_id, stream_type, tmdb_key)
|
||
if not info:
|
||
info = await _from_cinemeta(imdb_id, stream_type)
|
||
|
||
if info:
|
||
cache.set_meta(cache_key, info)
|
||
return info
|
||
|
||
|
||
async def _from_tmdb(imdb_id, stream_type, tmdb_key):
|
||
kind = "movie" if stream_type == "movie" else "tv"
|
||
try:
|
||
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
|
||
# 1. IMDB -> TMDB
|
||
async with session.get(
|
||
f"{TMDB_BASE}/find/{imdb_id}",
|
||
params={"api_key": tmdb_key, "external_source": "imdb_id"},
|
||
timeout=aiohttp.ClientTimeout(total=10),
|
||
) as resp:
|
||
if resp.status != 200:
|
||
logging.warning(f"TMDB find HTTP {resp.status}")
|
||
return None
|
||
data = await resp.json()
|
||
results = data.get("movie_results" if kind == "movie" else "tv_results", [])
|
||
if not results:
|
||
return None
|
||
tmdb_id = results[0]["id"]
|
||
|
||
# 2. Détails complets en FR (titre FR + saisons + genres)
|
||
async with session.get(
|
||
f"{TMDB_BASE}/{kind}/{tmdb_id}",
|
||
params={"api_key": tmdb_key, "language": "fr-FR"},
|
||
timeout=aiohttp.ClientTimeout(total=10),
|
||
) as resp:
|
||
if resp.status != 200:
|
||
return None
|
||
details = await resp.json()
|
||
except Exception as e:
|
||
logging.error(f"TMDB error: {e}")
|
||
return None
|
||
|
||
title = details.get("title") or details.get("name") or ""
|
||
original_title = details.get("original_title") or details.get("original_name") or ""
|
||
date = details.get("release_date") or details.get("first_air_date") or ""
|
||
year = date.split("-")[0] if date else ""
|
||
|
||
# Détection anime : origine JP/KR/CN ET genre Animation (16) requis.
|
||
# Un simple "or orig_lang == 'ja'" classerait n'importe quel drama
|
||
# live-action japonais comme anime — le genre Animation est nécessaire
|
||
# dans tous les cas.
|
||
orig_lang = details.get("original_language", "")
|
||
origin_country = details.get("origin_country", [])
|
||
genre_ids = [g.get("id") for g in details.get("genres", [])]
|
||
is_anime = False
|
||
if (orig_lang in ("ja", "ko", "zh") or "JP" in origin_country) and 16 in genre_ids:
|
||
is_anime = True
|
||
if "anime" in (details.get("overview") or "").lower() and 16 in genre_ids:
|
||
is_anime = True
|
||
|
||
seasons = [
|
||
{"season_number": s.get("season_number"), "episode_count": s.get("episode_count")}
|
||
for s in details.get("seasons", [])
|
||
if s.get("season_number") is not None
|
||
]
|
||
|
||
return {
|
||
"tmdb_id": tmdb_id,
|
||
"title": title,
|
||
"original_title": original_title,
|
||
"year": year,
|
||
"is_anime": is_anime,
|
||
"seasons": seasons,
|
||
}
|
||
|
||
|
||
async def _from_cinemeta(imdb_id, stream_type):
|
||
kind = "movie" if stream_type == "movie" else "series"
|
||
try:
|
||
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=20)) as session:
|
||
async with session.get(
|
||
f"{CINEMETA_BASE}/{kind}/{imdb_id}.json",
|
||
timeout=aiohttp.ClientTimeout(total=10),
|
||
) as resp:
|
||
if resp.status != 200:
|
||
return None
|
||
data = await resp.json()
|
||
except Exception as e:
|
||
logging.error(f"Cinemeta error: {e}")
|
||
return None
|
||
|
||
meta = data.get("meta") or {}
|
||
if not meta:
|
||
return None
|
||
|
||
name = meta.get("name", "")
|
||
year = str(meta.get("year") or "").split("–")[0].split("-")[0]
|
||
|
||
# Détection anime approximative via genres Cinemeta
|
||
genres = [g.lower() for g in (meta.get("genres") or [])]
|
||
is_anime = "animation" in genres and (meta.get("country") or "").lower() in ("japan", "jp")
|
||
|
||
# Saisons reconstruites depuis la liste des épisodes (pour l'épisode absolu)
|
||
season_counts = {}
|
||
for video in meta.get("videos") or []:
|
||
s = video.get("season")
|
||
if s is not None and s > 0:
|
||
season_counts[s] = season_counts.get(s, 0) + 1
|
||
seasons = [{"season_number": s, "episode_count": c} for s, c in sorted(season_counts.items())]
|
||
|
||
return {
|
||
"tmdb_id": None,
|
||
"title": name,
|
||
"original_title": name,
|
||
"year": year,
|
||
"is_anime": is_anime,
|
||
"seasons": seasons,
|
||
}
|
||
|
||
|
||
def compute_absolute_episode(media_info, season, episode):
|
||
"""
|
||
Numérotation absolue pour les animes (nommage fansub "One Piece 1122").
|
||
Somme des épisodes des saisons précédentes + numéro dans la saison.
|
||
"""
|
||
if not media_info or not season or not episode:
|
||
return None
|
||
if season == 1:
|
||
return episode
|
||
prev = [
|
||
s for s in media_info.get("seasons", [])
|
||
if s.get("season_number") and 0 < s["season_number"] < season
|
||
]
|
||
if len(prev) == season - 1 and all(s.get("episode_count") for s in prev):
|
||
return sum(s["episode_count"] for s in prev) + episode
|
||
return None
|