Feat: score qualité, cache liens résolus, mode d'affichage simplifié

- Score qualité global (résolution/source/HDR/codec/langue/seeders) +
  critère de tri "quality" (), sélectionnable et par défaut en 2e position.
  Tri par défaut élargi à tous les critères (dont priorité tracker).
- Cache court des liens débrideur résolus (TTL 15 min, clé service+hash+SE+ip)
  -> seek instantané, ~5x moins d'appels débrideur/StremThru. Table dédiée,
  visible et purgeable dans l'admin.
- Mode d'affichage : "detailed" (actuel) ou "simple" (famille) = 1 lien par
  résolution, meilleur choix auto (cache puis score qualité).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Valentin 2026-07-18 01:05:38 +02:00
parent f873050b36
commit b2233be562
9 changed files with 224 additions and 24 deletions

View file

@ -21,8 +21,10 @@
| **Filtres** | Taille min/max, résolution, codec, langue, exclusion CAM, exclusion packs, nombre de résultats (global + par résolution). |
| **Tri multi-critères** | Cache → langue → résolution → taille → seeders → tracker, ordre entièrement réordonnable. |
| **Déduplication** | Par info_hash, en gardant le tracker prioritaire et le max de seeders. |
| **Cache-only / non-caché** | Afficher uniquement le caché, ou proposer les non-cachés (le clic les ajoute au débrideur). Option liens **P2P** (moteur torrent de Stremio, sans débrideur). |
| **Cache SQLite** | Statut de cache débrideur, résultats de recherche et métadonnées TMDB mis en cache (TTLs configurables) → réponses quasi instantanées sur les épisodes suivants d'une série. |
| **Cache-only / non-caché** | Afficher uniquement le caché, ou proposer les non-cachés (le clic les ajoute au débrideur). |
| **Mode d'affichage** | *Détaillé* (tous les liens + infos, pour les initiés) ou *Simplifié* (1 choix par résolution, meilleur lien auto-sélectionné — pour la famille). |
| **Score de qualité** | Tri « meilleur d'abord » holistique (résolution + source + HDR/DV + codec + langue + seeders), sélectionnable comme critère de tri. |
| **Cache SQLite** | Statut de cache débrideur, résultats de recherche, métadonnées TMDB et **liens débrideur résolus** mis en cache (TTLs configurables) → épisodes suivants et seeks quasi instantanés. |
---
@ -103,6 +105,7 @@ Accessible sur `/admin` dès que `RANGER_ADMIN_TOKEN` est défini (sinon le pane
| `RANGER_TTL_UNCACHED` | `1200` (20 min) | TTL cache « non caché » |
| `RANGER_TTL_SEARCH` | `1800` (30 min) | TTL résultats de recherche |
| `RANGER_TTL_META` | `604800` (7 j) | TTL métadonnées TMDB |
| `RANGER_TTL_LINK` | `900` (15 min) | TTL des liens débrideur résolus (seek instantané) |
| `HTTP_PROXY` / `HTTPS_PROXY` | — | Proxy sortant optionnel |
---

View file

@ -26,6 +26,7 @@ TTL_AVAIL_CACHED = int(os.getenv("RANGER_TTL_CACHED", 6 * 3600)) # torrent
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))
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
@ -60,6 +61,11 @@ def _get_conn():
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()
@ -139,6 +145,29 @@ def set_search(key, results):
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:
@ -170,6 +199,7 @@ def cleanup():
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()
@ -202,6 +232,7 @@ def stats():
"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"):
@ -224,11 +255,13 @@ def stats():
"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(),
}
@ -304,13 +337,13 @@ def refresh_media(imdb_id):
def flush(table):
"""Vide une table (searches|availability|meta|all)."""
targets = ["searches", "availability", "meta"] if table == "all" else [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"):
if t in ("searches", "availability", "meta", "links"):
deleted[t] = conn.execute(f"DELETE FROM {t}").rowcount
conn.commit()
return deleted

View file

@ -18,7 +18,7 @@ KEYED_TRACKERS = ["c411", "torr9", "tr4ker", "nekobt"]
DEBRID_SERVICES = ["alldebrid", "realdebrid", "torbox", "debridlink"]
# Critères de tri disponibles
SORT_CRITERIA = ["cached", "language", "resolution", "size_desc", "size_asc", "seeders", "tracker"]
SORT_CRITERIA = ["cached", "quality", "language", "resolution", "size_desc", "size_asc", "seeders", "tracker"]
DEFAULT_CONFIG = {
# Clé TMDB (recommandée : titres FR, détection anime, épisodes absolus).
@ -32,6 +32,11 @@ DEFAULT_CONFIG = {
# "all" : un stream par débrideur où le torrent est en cache
"debrid_mode": "first",
# Mode d'affichage des résultats dans Stremio :
# "detailed" : liste complète (tous les liens + détails) — pour les initiés
# "simple" : 1 lien par résolution, meilleur choix auto — pour la famille
"display_mode": "detailed",
# StremThru : proxy d'API débrideur (contourne les blocages IP datacenter)
"stremthru": {"url": "", "auth": ""},
@ -67,7 +72,7 @@ DEFAULT_CONFIG = {
},
# Ordre des critères de tri (appliqués dans l'ordre)
"sort": ["cached", "language", "resolution", "size_desc"],
"sort": ["cached", "quality", "resolution", "language", "size_desc", "seeders", "tracker"],
# Priorités utilisées par les critères "language" / "resolution" / "tracker"
"language_order": ["MULTI", "VFF", "VF", "VFQ", "VOSTFR", "VO"],

View file

@ -121,6 +121,31 @@ def build_debrid_stream(torrent, service, cached, resolve_url):
return stream
def build_simple_stream(torrent, service, cached, resolve_url, resolution):
"""
Stream du mode simplifié (famille) : un seul lien par résolution, présenté
de façon épurée. Le meilleur torrent a déjà été choisi en amont.
"""
meta = torrent["_meta"]
label = resolution or "Auto"
dot = "🟢" if cached else "🟠"
lang = meta["lang_display"] or ", ".join(meta["languages"])
infos = [f"💾 {format_size(torrent.get('size', 0))}"]
if lang:
infos.insert(0, lang)
if not cached:
infos.append("ajout au débrideur")
return {
"name": f"Ranger\n{dot} {label}",
"title": " · ".join(infos),
"description": " · ".join(infos),
"url": resolve_url,
"behaviorHints": _behavior_hints(torrent, service),
**_stream_meta_fields(torrent),
}
def build_p2p_stream(torrent):
"""Stream P2P via le moteur torrent intégré de Stremio (sans débrideur)."""
meta = torrent["_meta"]

View file

@ -139,3 +139,50 @@ def resolution_rank(resolution, resolution_order):
return resolution_order.index(resolution)
except ValueError:
return len(resolution_order)
# --- Score de qualité global (plus élevé = meilleur) ---
_RES_SCORE = {"4K": 4000, "1080p": 3000, "720p": 2000, "SD": 1000}
_SOURCE_SCORE = {
"REMUX": 500, "BluRay": 400, "WEB-DL": 350, "WEBRip": 250,
"WEB": 220, "HDTV": 120, "DVDRip": 100, "CAM": -2000,
}
def quality_score(meta, seeders=0, language_order=None):
"""
Score de qualité d'une release, indépendant du statut de cache.
Combine résolution, source, HDR/DV, codec, langue préférée et seeders.
"""
score = 0
score += _RES_SCORE.get(meta.get("resolution"), 500)
score += _SOURCE_SCORE.get(meta.get("source"), 150)
hdr = meta.get("hdr") or []
if "DV" in hdr:
score += 120
if any(h.startswith("HDR") for h in hdr):
score += 80
if "10bit" in hdr:
score += 30
if meta.get("codec") == "x265":
score += 40 # meilleur ratio qualité/taille
elif meta.get("codec") == "AV1":
score += 50
# Langue préférée : bonus décroissant selon l'ordre de préférence
if language_order:
rank = best_language(meta.get("languages") or [], language_order)
score += max(0, (len(language_order) - rank)) * 25
# Seeders : petit bonus logarithmique (fiabilité), plafonné
try:
s = int(seeders or 0)
if s > 0:
score += min(200, int((s ** 0.5) * 10))
except (TypeError, ValueError):
pass
return score

View file

@ -4,7 +4,7 @@ Moteur de filtrage, déduplication et tri des torrents.
import logging
from core.parsing import parse_release, best_language, resolution_rank
from core.parsing import parse_release, best_language, resolution_rank, quality_score
from utils import (
check_title_match,
check_title_tokens,
@ -129,6 +129,15 @@ def dedupe(torrents, providers_order):
return list(unique.values())
def score_of(torrent, language_order):
"""Score qualité mémoïsé sur le torrent."""
if "_score" not in torrent:
torrent["_score"] = quality_score(
torrent["_meta"], torrent.get("seeders", 0), language_order
)
return torrent["_score"]
def sort_entries(entries, config):
"""
Trie les entrées de stream selon les critères configurés (dans l'ordre).
@ -146,6 +155,8 @@ def sort_entries(entries, config):
for criterion in criteria:
if criterion == "cached":
parts.append(0 if entry["cached"] else 1)
elif criterion == "quality":
parts.append(-score_of(t, lang_order))
elif criterion == "language":
parts.append(best_language(meta["languages"], lang_order))
elif criterion == "resolution":

84
main.py
View file

@ -235,15 +235,17 @@ async def handle_stream(request):
availability[backend.name] = avail
logging.info(f"{backend.name}: {sum(1 for v in avail.values() if v)}/{len(hashes)} en cache")
# 5. Construction des entrées (torrent x débrideur)
entries = _build_entries(torrents, backends, availability, config)
# 5-7. Construction des streams selon le mode d'affichage
if config.get("display_mode") == "simple":
# Mode famille : 1 lien par résolution, meilleur choix automatique
entries = _build_simple_entries(torrents, backends, availability, config)
streams = _serialize_streams(entries, config_str, host_url, stream_type, season, episode, simple=True)
else:
entries = _build_entries(torrents, backends, availability, config)
entries = ranking.sort_entries(entries, config)
entries = ranking.apply_limits(entries, filters)
streams = _serialize_streams(entries, config_str, host_url, stream_type, season, episode)
# 6. Tri + limites
entries = ranking.sort_entries(entries, config)
entries = ranking.apply_limits(entries, filters)
# 7. Sérialisation en streams Stremio
streams = _serialize_streams(entries, config_str, host_url, stream_type, season, episode)
RUNTIME["streams_served"] += len(streams)
logging.info(f"{len(streams)} streams renvoyés à Stremio")
return web.json_response({"streams": streams})
@ -282,7 +284,51 @@ def _build_entries(torrents, backends, availability, config):
return entries
def _serialize_streams(entries, config_str, host_url, stream_type, season, episode):
def _build_simple_entries(torrents, backends, availability, config):
"""
Mode simplifié : une seule entrée par résolution, le meilleur torrent
(cache d'abord, puis score qualité) sur le débrideur prioritaire dispo.
"""
filters = config.get("filters") or {}
cached_only = filters.get("cached_only", False)
show_uncached = filters.get("show_uncached", True) and not cached_only
lang_order = config.get("language_order") or []
res_order = config.get("resolution_order") or ["4K", "1080p", "720p", "SD"]
# Candidats : (torrent, service, cached) au meilleur débrideur possible
candidates = []
for t in torrents:
ih = t["info_hash"]
cached_backends = [b for b in backends if availability.get(b.name, {}).get(b.clean_hash(ih), False)]
if cached_backends:
candidates.append({"torrent": t, "service": cached_backends[0].name, "cached": True})
elif backends and show_uncached:
candidates.append({"torrent": t, "service": backends[0].name, "cached": False})
# Groupement par résolution, meilleur candidat par groupe
best_by_res = {}
for c in candidates:
res = c["torrent"]["_meta"]["resolution"] or "Auto"
rank = (0 if c["cached"] else 1, -ranking.score_of(c["torrent"], lang_order))
if res not in best_by_res or rank < best_by_res[res][0]:
best_by_res[res] = (rank, c)
# Ordonné selon la préférence de résolution
def res_key(res):
try:
return res_order.index(res)
except ValueError:
return len(res_order)
entries = []
for res in sorted(best_by_res, key=res_key):
entry = best_by_res[res][1]
entry["resolution"] = res
entries.append(entry)
return entries
def _serialize_streams(entries, config_str, host_url, stream_type, season, episode, simple=False):
streams = []
for entry in entries:
t = entry["torrent"]
@ -297,7 +343,11 @@ def _serialize_streams(entries, config_str, host_url, stream_type, season, episo
elif stream_type == "movie":
resolve_url += "?type=movie"
streams.append(formatting.build_debrid_stream(t, service, entry["cached"], resolve_url))
if simple:
streams.append(formatting.build_simple_stream(
t, service, entry["cached"], resolve_url, entry.get("resolution", "")))
else:
streams.append(formatting.build_debrid_stream(t, service, entry["cached"], resolve_url))
return streams
@ -316,8 +366,19 @@ async def handle_resolve(request):
season = request.query.get("season")
episode = request.query.get("episode")
media_type = request.query.get("type")
client_ip = get_client_ip(request)
backends = build_backends(config, client_ip=get_client_ip(request))
# Cache court des liens résolus : évite de re-débrider à chaque seek
# (libmpv rappelle /resolve à chaque Range request). Clé liée à l'IP client
# car certains liens débrideur sont générés pour une IP donnée.
link_key = f"{service_name}|{info_hash}|{season or ''}|{episode or ''}|{client_ip or ''}"
cached_url = cache.get_link(link_key)
if cached_url:
RUNTIME["resolve_ok"] += 1
logging.info(f"⚡ Lien résolu servi depuis le cache ({service_name})")
raise web.HTTPFound(cached_url)
backends = build_backends(config, client_ip=client_ip)
backend = next((b for b in backends if b.name == service_name), None)
if not backend:
return web.Response(status=400, text=f"Débrideur non configuré : {service_name}")
@ -330,6 +391,7 @@ async def handle_resolve(request):
)
if url:
RUNTIME["resolve_ok"] += 1
cache.set_link(link_key, url)
raise web.HTTPFound(url)
return web.Response(status=404, text="Impossible de résoudre le stream")

View file

@ -143,6 +143,7 @@
<button class="btn amber sm" onclick="flush('searches')">Vider les recherches</button>
<button class="btn amber sm" onclick="flush('availability')">Vider la dispo débrideur</button>
<button class="btn amber sm" onclick="flush('meta')">Vider les métadonnées</button>
<button class="btn amber sm" onclick="flush('links')">Vider les liens résolus</button>
<button class="btn red sm" onclick="flush('all')">⚠️ Tout vider</button>
</div>
</div>
@ -230,6 +231,7 @@ function renderStats(s) {
${stat(c.availability.total, "Hashes en cache", c.availability.cached + " cachés / " + c.availability.uncached + " non")}
${stat(c.searches.total, "Recherches en cache")}
${stat(c.meta.total, "Métadonnées")}
${stat((c.links && c.links.total) || 0, "Liens résolus en cache")}
${stat(m.search_hit_rate + "%", "Hit-rate recherche", m.search_hit + " hits / " + m.search_miss + " miss")}
${stat(m.avail_hit_rate + "%", "Hit-rate dispo", m.avail_hit + " hits / " + m.avail_miss + " miss")}
`;
@ -301,7 +303,7 @@ async function delKey(table, key) {
} catch (e) { toast(e.message, false); }
}
async function flush(table) {
const labels = { searches:"les recherches", availability:"la dispo débrideur", meta:"les métadonnées", all:"TOUT le cache" };
const labels = { searches:"les recherches", availability:"la dispo débrideur", meta:"les métadonnées", links:"les liens résolus", all:"TOUT le cache" };
if (!confirm(`Vider ${labels[table]} ? Cette action est irréversible.`)) return;
try {
const r = await api("/admin/api/flush", { method:"POST", body: JSON.stringify({ table }) });

View file

@ -124,6 +124,16 @@
</select>
</div>
<!-- Mode d'affichage -->
<div class="card">
<h2>🖥️ Mode d'affichage</h2>
<p class="hint">Comment les résultats apparaissent dans Stremio.</p>
<select id="display_mode">
<option value="detailed">Détaillé — tous les liens et détails (pour les initiés)</option>
<option value="simple">Simplifié — 1 choix par résolution, meilleur lien auto (famille)</option>
</select>
</div>
<!-- StremThru -->
<div class="card">
<h2>🛰️ StremThru <span class="small">(optionnel)</span></h2>
@ -258,8 +268,8 @@ const RESOLUTIONS = ["4K", "1080p", "720p", "SD"];
const CODECS = ["x265", "x264", "AV1"];
const LANGUAGES = ["MULTI", "VFF", "VF", "VFQ", "VOSTFR", "VO"];
const SORT_LABELS = {
cached: "⚡ En cache d'abord", language: "🗣️ Langue", resolution: "📺 Résolution",
size_desc: "💾 Taille décroissante", size_asc: "💾 Taille croissante",
cached: "⚡ En cache d'abord", quality: "⭐ Qualité globale", language: "🗣️ Langue",
resolution: "📺 Résolution", size_desc: "💾 Taille décroissante", size_asc: "💾 Taille croissante",
seeders: "👤 Seeders", tracker: "🌐 Priorité tracker",
};
// Noms lisibles des sources (priorité d'affichage des trackers)
@ -275,7 +285,7 @@ const PREFILL = null;
let debrids = []; // [{service, key}]
let unit3ds = []; // [{url, key}]
let torznabs = []; // [{name, url, apikey}]
let sortOrder = ["cached", "language", "resolution", "size_desc"];
let sortOrder = ["cached", "quality", "resolution", "language", "size_desc", "seeders", "tracker"];
let langOrder = [...LANGUAGES];
let provOrder = ["unit3d", "ygg", "c411", "torr9", "tr4ker", "abn", "torznab", "eztv", "apibay", "nyaa", "nekobt"];
const selectedRes = new Set(), selectedCodec = new Set(), selectedLang = new Set();
@ -446,6 +456,7 @@ function buildConfig() {
tmdb_key: document.getElementById("tmdb_key").value.trim(),
debrids: debrids.filter(d => d.key && d.key.trim()).map(d => ({ service: d.service, key: d.key.trim(), stremthru: d.stremthru !== false })),
debrid_mode: document.getElementById("debrid_mode").value,
display_mode: document.getElementById("display_mode").value,
stremthru: {
url: document.getElementById("stremthru_url").value.trim(),
auth: document.getElementById("stremthru_auth").value.trim(),
@ -534,6 +545,7 @@ function applyPrefill(cfg) {
// Champs simples
setVal("tmdb_key", cfg.tmdb_key);
setVal("debrid_mode", cfg.debrid_mode);
setVal("display_mode", cfg.display_mode);
if (cfg.stremthru) { setVal("stremthru_url", cfg.stremthru.url); setVal("stremthru_auth", cfg.stremthru.auth); }
if (cfg.abn) { setVal("abn_user", cfg.abn.username); setVal("abn_pass", cfg.abn.password); }
setVal("min_size", f.min_size_gb); setVal("max_size", f.max_size_gb);
@ -576,7 +588,7 @@ renderSortable("sortList", sortOrder, k => SORT_LABELS[k] || k);
renderSortable("langOrder", langOrder, k => k);
renderSortable("provOrder", provOrder, k => SOURCE_LABELS[k] || k);
applyPrefillCheckboxes(PREFILL);
["tmdb_key","stremthru_url","stremthru_auth","abn_user","abn_pass","debrid_mode",
["tmdb_key","stremthru_url","stremthru_auth","abn_user","abn_pass","debrid_mode","display_mode",
"min_size","max_size","max_results","max_per_res","exclude_cam","exclude_packs",
"cache_mode"].forEach(id => {
const el = document.getElementById(id);