ranger/core/cache.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

382 lines
13 KiB
Python

"""
Cache SQLite de Ranger.
Trois usages :
- availability : statut cache des hashes par débrideur (évite de re-vérifier
les mêmes hashes à chaque requête Stremio)
- searches : résultats de recherche par tracker (les épisodes suivants
d'une même série réutilisent la recherche)
- meta : métadonnées TMDB/Cinemeta par ID
Accès synchrone (opérations courtes, WAL activé) — suffisant pour un addon
mono-processus.
"""
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
def fingerprint(*secrets):
"""
Empreinte courte et non réversible d'identifiants (clé API, login...),
utilisée pour namespacer les clés de cache par compte. Sans ça, deux
utilisateurs distincts d'une même instance Ranger (plusieurs profils
d'une même famille, par ex.) avec des credentials différents pourraient
se voir servir les résultats/liens de l'un ou l'autre pendant la fenêtre
de cache. Retourne "" si aucun secret fourni (cache partagé, volontaire
pour les trackers publics sans credential).
"""
raw = "|".join(str(s) for s in secrets if s)
if not raw:
return ""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
DB_PATH = os.getenv("RANGER_DB", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "ranger.db"))
# TTLs (secondes), surchargables par variables d'environnement
TTL_AVAIL_CACHED = int(os.getenv("RANGER_TTL_CACHED", 6 * 3600)) # torrent vu en cache
TTL_AVAIL_MISS = int(os.getenv("RANGER_TTL_UNCACHED", 20 * 60)) # torrent vu non-caché
TTL_SEARCH = int(os.getenv("RANGER_TTL_SEARCH", 30 * 60))
# Un résultat de recherche VIDE est mis en cache moins longtemps qu'un résultat
# non-vide : sinon, un tracker en accroc passager (timeout, blip réseau, API
# qui répond 200 avec 0 item) verrouille "aucun résultat" pour TOUT le monde
# jusqu'à expiration du TTL_SEARCH complet (30 min par défaut), y compris une
# fois le tracker redevenu normal. Voir core/search.py::_cached_search.
TTL_SEARCH_EMPTY = int(os.getenv("RANGER_TTL_SEARCH_EMPTY", 3 * 60))
TTL_META = int(os.getenv("RANGER_TTL_META", 7 * 24 * 3600))
TTL_LINK = int(os.getenv("RANGER_TTL_LINK", 15 * 60)) # lien débrideur résolu
_lock = threading.Lock()
_conn = None
# Compteurs de performance du cache (depuis le démarrage)
_metrics = {"search_hit": 0, "search_miss": 0, "avail_hit": 0, "avail_miss": 0}
def _get_conn():
global _conn
if _conn is None:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA synchronous=NORMAL")
_conn.executescript(
"""
CREATE TABLE IF NOT EXISTS availability (
service TEXT NOT NULL,
hash TEXT NOT NULL,
cached INTEGER NOT NULL,
checked_at INTEGER NOT NULL,
PRIMARY KEY (service, hash)
);
CREATE TABLE IF NOT EXISTS searches (
key TEXT PRIMARY KEY,
results TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS links (
key TEXT PRIMARY KEY,
url TEXT NOT NULL,
created_at INTEGER NOT NULL
);
"""
)
_conn.commit()
logging.info(f"Cache SQLite initialisé : {DB_PATH}")
return _conn
def get_availability(service, hashes):
"""
Retourne (known, unknown) :
known : {hash: bool} pour les hashes dont le statut en cache est encore valide
unknown : liste des hashes à re-vérifier auprès du débrideur
"""
if not hashes:
return {}, []
now = int(time.time())
known = {}
with _lock:
conn = _get_conn()
placeholders = ",".join("?" * len(hashes))
rows = conn.execute(
f"SELECT hash, cached, checked_at FROM availability WHERE service = ? AND hash IN ({placeholders})",
[service] + list(hashes),
).fetchall()
for h, cached, checked_at in rows:
ttl = TTL_AVAIL_CACHED if cached else TTL_AVAIL_MISS
if now - checked_at <= ttl:
known[h] = bool(cached)
unknown = [h for h in hashes if h not in known]
_metrics["avail_hit"] += len(known)
_metrics["avail_miss"] += len(unknown)
return known, unknown
def set_availability(service, availability):
"""Enregistre un dict {hash: bool} pour un débrideur."""
if not availability:
return
now = int(time.time())
with _lock:
conn = _get_conn()
conn.executemany(
"INSERT OR REPLACE INTO availability (service, hash, cached, checked_at) VALUES (?, ?, ?, ?)",
[(service, h, 1 if c else 0, now) for h, c in availability.items()],
)
conn.commit()
def mark_cached(service, info_hash):
"""Marque un hash comme caché (après un débridage réussi)."""
set_availability(service, {info_hash: True})
def get_search(key):
now = int(time.time())
with _lock:
conn = _get_conn()
row = conn.execute("SELECT results, created_at FROM searches WHERE key = ?", (key,)).fetchone()
if row:
try:
results = json.loads(row[0])
ttl = TTL_SEARCH if results else TTL_SEARCH_EMPTY
if now - row[1] <= ttl:
_metrics["search_hit"] += 1
return results
except Exception:
pass
_metrics["search_miss"] += 1
return None
def set_search(key, results):
with _lock:
conn = _get_conn()
conn.execute(
"INSERT OR REPLACE INTO searches (key, results, created_at) VALUES (?, ?, ?)",
(key, json.dumps(results, ensure_ascii=False), int(time.time())),
)
conn.commit()
def get_link(key):
"""Lien débrideur résolu, encore valide (TTL court)."""
now = int(time.time())
with _lock:
conn = _get_conn()
row = conn.execute("SELECT url, created_at FROM links WHERE key = ?", (key,)).fetchone()
if row and now - row[1] <= TTL_LINK:
return row[0]
return None
def set_link(key, url):
if not url:
return
with _lock:
conn = _get_conn()
conn.execute(
"INSERT OR REPLACE INTO links (key, url, created_at) VALUES (?, ?, ?)",
(key, url, int(time.time())),
)
conn.commit()
def get_meta(key):
now = int(time.time())
with _lock:
conn = _get_conn()
row = conn.execute("SELECT value, created_at FROM meta WHERE key = ?", (key,)).fetchone()
if row and now - row[1] <= TTL_META:
try:
return json.loads(row[0])
except Exception:
return None
return None
def set_meta(key, value):
with _lock:
conn = _get_conn()
conn.execute(
"INSERT OR REPLACE INTO meta (key, value, created_at) VALUES (?, ?, ?)",
(key, json.dumps(value, ensure_ascii=False), int(time.time())),
)
conn.commit()
def cleanup():
"""Purge les entrées expirées (appelé périodiquement)."""
now = int(time.time())
with _lock:
conn = _get_conn()
conn.execute("DELETE FROM availability WHERE checked_at < ?", (now - max(TTL_AVAIL_CACHED, TTL_AVAIL_MISS),))
conn.execute("DELETE FROM searches WHERE created_at < ?", (now - TTL_SEARCH,))
conn.execute("DELETE FROM meta WHERE created_at < ?", (now - TTL_META,))
conn.execute("DELETE FROM links WHERE created_at < ?", (now - TTL_LINK,))
conn.commit()
# ============================================================================
# Fonctions d'administration (panel admin)
# ============================================================================
def get_metrics():
"""Compteurs hit/miss du cache depuis le démarrage."""
m = dict(_metrics)
s_tot = m["search_hit"] + m["search_miss"]
a_tot = m["avail_hit"] + m["avail_miss"]
m["search_hit_rate"] = round(m["search_hit"] / s_tot * 100, 1) if s_tot else 0.0
m["avail_hit_rate"] = round(m["avail_hit"] / a_tot * 100, 1) if a_tot else 0.0
return m
def stats():
"""Statistiques globales du cache SQLite pour le dashboard admin."""
with _lock:
conn = _get_conn()
avail_total = conn.execute("SELECT COUNT(*) FROM availability").fetchone()[0]
avail_cached = conn.execute("SELECT COUNT(*) FROM availability WHERE cached = 1").fetchone()[0]
by_service = conn.execute(
"SELECT service, COUNT(*), SUM(cached) FROM availability GROUP BY service ORDER BY COUNT(*) DESC"
).fetchall()
searches_total = conn.execute("SELECT COUNT(*) FROM searches").fetchone()[0]
by_source = conn.execute(
"SELECT substr(key, 8, instr(substr(key, 8), ':') - 1) AS src, COUNT(*) "
"FROM searches GROUP BY src ORDER BY COUNT(*) DESC"
).fetchall()
meta_total = conn.execute("SELECT COUNT(*) FROM meta").fetchone()[0]
links_total = conn.execute("SELECT COUNT(*) FROM links").fetchone()[0]
db_size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
for wal in (DB_PATH + "-wal", DB_PATH + "-shm"):
if os.path.exists(wal):
db_size += os.path.getsize(wal)
return {
"db_path": DB_PATH,
"db_size_bytes": db_size,
"availability": {
"total": avail_total,
"cached": avail_cached,
"uncached": avail_total - avail_cached,
"by_service": [
{"service": s, "total": t, "cached": c or 0} for s, t, c in by_service
],
},
"searches": {
"total": searches_total,
"by_source": [{"source": s or "?", "total": t} for s, t in by_source],
},
"meta": {"total": meta_total},
"links": {"total": links_total},
"ttl": {
"cached": TTL_AVAIL_CACHED,
"uncached": TTL_AVAIL_MISS,
"search": TTL_SEARCH,
"meta": TTL_META,
"link": TTL_LINK,
},
"metrics": get_metrics(),
}
def _parse_search_key(key):
# search:{source}:{type}:{imdb}[:season:episode] (trackers publics, cache partagé)
# search:{source}:{fingerprint}:{type}:{imdb}[:season:episode] (trackers à clé, cache namespacé par compte)
parts = key.split(":")
if len(parts) < 4 or parts[0] != "search":
return {"key": key}
source = parts[1]
if parts[2] in ("movie", "series"):
offset, scoped = 2, False
else:
offset, scoped = 3, True
if len(parts) < offset + 2:
return {"key": key, "source": source}
info = {"key": key, "source": source, "type": parts[offset], "imdb": parts[offset + 1], "scoped": scoped}
if len(parts) >= offset + 4:
info["season"], info["episode"] = parts[offset + 2], parts[offset + 3]
return info
def list_searches(limit=300):
"""Liste les entrées du cache de recherche (clé parsée + âge)."""
now = int(time.time())
with _lock:
conn = _get_conn()
rows = conn.execute(
"SELECT key, created_at, LENGTH(results) FROM searches ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
out = []
for key, created, size in rows:
info = _parse_search_key(key)
info["age_seconds"] = now - created
info["expires_in"] = max(0, TTL_SEARCH - (now - created))
info["bytes"] = size
out.append(info)
return out
def list_meta(limit=300):
now = int(time.time())
with _lock:
conn = _get_conn()
rows = conn.execute(
"SELECT key, created_at FROM meta ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [{"key": k, "age_seconds": now - c, "expires_in": max(0, TTL_META - (now - c))} for k, c in rows]
def delete_key(table, key):
"""Supprime une entrée précise (table = searches|meta)."""
if table not in ("searches", "meta"):
return 0
with _lock:
conn = _get_conn()
cur = conn.execute(f"DELETE FROM {table} WHERE key = ?", (key,))
conn.commit()
return cur.rowcount
def refresh_media(imdb_id):
"""
Force le refresh d'un média : supprime ses recherches et métadonnées en
cache. Le prochain fetch Stremio ré-interrogera les trackers et TMDB.
Retourne le nombre d'entrées supprimées.
"""
imdb_id = (imdb_id or "").strip()
if not imdb_id:
return {"searches": 0, "meta": 0}
like = f"%{imdb_id}%"
with _lock:
conn = _get_conn()
s = conn.execute("DELETE FROM searches WHERE key LIKE ?", (like,)).rowcount
m = conn.execute("DELETE FROM meta WHERE key LIKE ?", (like,)).rowcount
conn.commit()
return {"searches": s, "meta": m}
def flush(table):
"""Vide une table (searches|availability|meta|links|all)."""
targets = ["searches", "availability", "meta", "links"] if table == "all" else [table]
deleted = {}
with _lock:
conn = _get_conn()
for t in targets:
if t in ("searches", "availability", "meta", "links"):
deleted[t] = conn.execute(f"DELETE FROM {t}").rowcount
conn.commit()
return deleted