mirror of
https://github.com/vatax3/ranger.git
synced 2026-07-27 09:12:08 +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>
382 lines
13 KiB
Python
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
|