diff --git a/README.md b/README.md index 2de9d05..d41e79a 100644 --- a/README.md +++ b/README.md @@ -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 | --- diff --git a/core/cache.py b/core/cache.py index 7c3e5c3..9712e95 100644 --- a/core/cache.py +++ b/core/cache.py @@ -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 diff --git a/core/config.py b/core/config.py index 75a59ad..e07f40f 100644 --- a/core/config.py +++ b/core/config.py @@ -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"], diff --git a/core/formatting.py b/core/formatting.py index e92f4de..3b51823 100644 --- a/core/formatting.py +++ b/core/formatting.py @@ -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"] diff --git a/core/parsing.py b/core/parsing.py index 148519e..85fd4fd 100644 --- a/core/parsing.py +++ b/core/parsing.py @@ -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 diff --git a/core/ranking.py b/core/ranking.py index ab23161..0c4b7ab 100644 --- a/core/ranking.py +++ b/core/ranking.py @@ -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": diff --git a/main.py b/main.py index f8cca08..4d34b86 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/templates/admin.html b/templates/admin.html index be1ac03..ea2c365 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -143,6 +143,7 @@ + @@ -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 }) }); diff --git a/templates/configure.html b/templates/configure.html index a6828f2..07d1339 100644 --- a/templates/configure.html +++ b/templates/configure.html @@ -124,6 +124,16 @@ + +
Comment les résultats apparaissent dans Stremio.
+ +