Ranger v1.0.0 — addon Stremio ultime multi-trackers/multi-débrideurs

Addon Stremio complet, films/séries/anime, contenu FR et international :
- Multi-débrideurs avec priorité (AllDebrid, Real-Debrid, TorBox, DebridLink)
- StremThru intégré (contournement blocage IP datacenter)
- Compat AIOStreams (tags [XX+], statut cache)
- TMDB + fallback Cinemeta, détection anime, numérotation absolue
- Trackers publics (YGG, TPB, EZTV, Nyaa) + privés (C411, Torr9, Tr4ker,
  NekoBT, ABN, UNIT3D) + Torznab générique (Jackett/Prowlarr)
- Filtres taille/résolution/codec/langue, tri multi-critères, déduplication
- Cache SQLite (dispo débrideur + recherches + métadonnées)
- Page /configure interactive, image Docker + docker-compose

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Valentin 2026-07-17 14:56:22 +02:00
commit 709265ccb8
36 changed files with 5991 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
data/
*.db
*.db-wal
*.db-shm
.venv/
venv/
.env
README.md

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
data/
*.db
*.db-wal
*.db-shm
.venv/
venv/
.env
.DS_Store

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=7000 \
RANGER_DB=/data/ranger.db
WORKDIR /app
# Dépendances (couche cache)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Code
COPY . .
# Volume pour la base de cache SQLite
RUN mkdir -p /data
VOLUME ["/data"]
EXPOSE 7000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,os; urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','7000')+'/health').read()" || exit 1
CMD ["python", "main.py"]

119
README.md Normal file
View file

@ -0,0 +1,119 @@
# Ranger 🎯
**L'addon Stremio ultime** — multi-trackers / multi-débrideurs, pensé pour le contenu **français comme international**. Films, séries et anime, avec parsing metadata soigné, filtres, tri par priorité, déduplication et cache.
Écrit en Python (aiohttp), sans dépendance lourde, déployable en une commande Docker sur un VPS.
---
## ✨ Fonctionnalités
| | |
|---|---|
| **Multi-débrideurs + priorité** | AllDebrid, Real-Debrid, TorBox, DebridLink. Ordre de priorité configurable (glisser-déposer). Mode « prioritaire uniquement » ou « tous les débrideurs en cache ». |
| **StremThru intégré** | Proxy d'API débrideur pour contourner les blocages d'IP datacenter (VPS Oracle/OVH/etc.). Optionnel, par simple URL. |
| **Compatible AIOStreams** | Tags `[AD+]`, `[RD+]`… (convention Torrentio) pour l'identification du service et du statut de cache. |
| **Clé TMDB** | Titres FR, détection anime, numérotation absolue des épisodes. Fallback **Cinemeta** sans clé. |
| **Trackers publics** | YGG (leak, relais Nostr), ThePirateBay (apibay), EZTV, Nyaa (anime). Activables à la case. |
| **Trackers privés / semi** | C411, Torr9, Tr4ker, NekoBT (clé/passkey), ABN (login), UNIT3D (multi). |
| **Torznab générique** | Branchez **Jackett / Prowlarr** → des centaines de trackers publics et privés. |
| **Films / séries / anime** | Parsing metadata dédié : résolution, codec, source, HDR/DV, audio, langues (MULTI/VFF/VF/VFQ/VOSTFR/VO). Matching saison/épisode + numérotation absolue anime. |
| **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. |
---
## 🚀 Déploiement (VPS, Docker)
```bash
git clone <votre-repo> ranger && cd ranger
docker compose up -d --build
```
L'addon écoute sur le port **7000**. La base de cache SQLite est persistée dans le volume `ranger_data`.
### Derrière un reverse proxy (recommandé, HTTPS obligatoire pour Stremio Web)
Exemple **Caddy** :
```caddyfile
ranger.mondomaine.fr {
reverse_proxy localhost:7000
}
```
Stremio exige du HTTPS pour les addons distants — un reverse proxy avec certificat (Caddy/Traefik/nginx) est indispensable en production.
### Sans Docker
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python main.py # écoute sur :7000 (surchargeable via PORT)
```
---
## ⚙️ Configuration & installation dans Stremio
1. Ouvrez `https://ranger.mondomaine.fr/configure`
2. Renseignez votre clé TMDB, vos débrideurs (avec ordre de priorité), cochez les trackers, ajustez filtres et tri.
3. Cliquez **Installer dans Stremio** (ou copiez l'URL du manifest).
Toute la configuration est encodée dans l'URL du manifest (aucune donnée stockée côté serveur hormis le cache technique).
---
## 🔧 Variables d'environnement
| Variable | Défaut | Rôle |
|---|---|---|
| `PORT` | `7000` | Port d'écoute |
| `RANGER_DB` | `/data/ranger.db` | Chemin de la base SQLite |
| `RANGER_TTL_CACHED` | `21600` (6 h) | TTL cache « en cache » |
| `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 |
| `HTTP_PROXY` / `HTTPS_PROXY` | — | Proxy sortant optionnel |
---
## 🏗️ Architecture
```
main.py # Routes aiohttp : manifest, stream, resolve, configure, health
core/
config.py # Encodage/décodage de la config (base64 JSON dans l'URL)
cache.py # Cache SQLite (availability / searches / meta)
metadata.py # TMDB + fallback Cinemeta, détection anime, épisode absolu
search.py # Orchestrateur : lance tous les trackers en parallèle (avec cache)
debrid.py # Abstraction débrideur (natif + StremThru) unifiée
parsing.py # Parsing de release (résolution/codec/source/HDR/audio/langues)
ranking.py # Filtres, déduplication, tri multi-critères, limites
formatting.py # Construction des objets stream Stremio (compat AIOStreams)
services/ # Un module par tracker/débrideur (portés de Frenchio + nouveaux)
templates/configure.html
```
### Flux d'une requête `/stream`
1. Décodage de la config depuis l'URL.
2. Métadonnées du média (TMDB/Cinemeta, en cache).
3. Recherche parallèle sur tous les trackers activés (en cache SQLite par tracker).
4. Filtrage pertinence (titre + saison/épisode) → dédup → filtres utilisateur.
5. Vérification de disponibilité auprès des débrideurs (en cache SQLite par hash).
6. Construction des entrées (torrent × débrideur), tri, limites.
7. Sérialisation en streams Stremio.
---
## ⚠️ Avertissement
Ranger est un **agrégateur / proxy** : il n'héberge ni n'indexe aucun contenu. Vous êtes responsable de l'usage que vous en faites et du respect des lois de votre juridiction ainsi que des conditions d'utilisation des trackers et débrideurs que vous configurez.
## 📝 Licence
MIT

0
core/__init__.py Normal file
View file

165
core/cache.py Normal file
View file

@ -0,0 +1,165 @@
"""
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 json
import logging
import os
import sqlite3
import threading
import time
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))
TTL_META = int(os.getenv("RANGER_TTL_META", 7 * 24 * 3600))
_lock = threading.Lock()
_conn = None
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
);
"""
)
_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]
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 and now - row[1] <= TTL_SEARCH:
try:
return json.loads(row[0])
except Exception:
return None
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_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.commit()

125
core/config.py Normal file
View file

@ -0,0 +1,125 @@
"""
Configuration Ranger : encodage/décodage de la config utilisateur (base64 JSON
dans l'URL, comme Torrentio) et valeurs par défaut.
"""
import base64
import copy
import json
import logging
# Trackers publics activables par simple case à cocher (pas de clé requise)
PUBLIC_TRACKERS = ["ygg", "apibay", "eztv", "nyaa"]
# Trackers privés/semi-privés nécessitant une clé/passkey
KEYED_TRACKERS = ["c411", "torr9", "tr4ker", "nekobt"]
# Débrideurs supportés (ordre d'affichage par défaut)
DEBRID_SERVICES = ["alldebrid", "realdebrid", "torbox", "debridlink"]
# Critères de tri disponibles
SORT_CRITERIA = ["cached", "language", "resolution", "size_desc", "size_asc", "seeders", "tracker"]
DEFAULT_CONFIG = {
# Clé TMDB (recommandée : titres FR, détection anime, épisodes absolus).
# Sans clé, fallback sur Cinemeta (titres anglais uniquement).
"tmdb_key": "",
# Débrideurs, dans l'ordre de priorité. [{"service": "alldebrid", "key": "..."}]
"debrids": [],
# "first" : n'affiche que le débrideur prioritaire où le torrent est en cache
# "all" : un stream par débrideur où le torrent est en cache
"debrid_mode": "first",
# StremThru : proxy d'API débrideur (contourne les blocages IP datacenter)
"stremthru": {"url": "", "auth": ""},
# Trackers cochés (publics sans clé + privés si clé fournie)
"trackers": ["ygg", "apibay", "eztv", "nyaa"],
# Clés des trackers privés/semi-privés
"tracker_keys": {}, # {"c411": "...", "torr9": "...", "tr4ker": "...", "nekobt": "..."}
# ABN (login/mot de passe)
"abn": {"username": "", "password": ""},
# Trackers UNIT3D : [{"url": "https://...", "key": "..."}]
"unit3d": [],
# Indexeurs Torznab (Jackett/Prowlarr) : accès à des centaines de trackers
# [{"name": "MonJackett", "url": "http://host:9117/api/v2.0/indexers/xxx/results/torznab", "apikey": "..."}]
"torznab": [],
"filters": {
"min_size_gb": 0,
"max_size_gb": 0, # 0 = illimité
"resolutions": [], # vide = tout, sinon sous-ensemble de ["4K", "1080p", "720p", "SD"]
"codecs": [], # vide = tout, sinon sous-ensemble de ["x265", "x264", "AV1"]
"languages": [], # vide = tout, sinon ["MULTI", "VFF", "VF", "VFQ", "VOSTFR", "VO"]
"exclude_cam": True,
"exclude_season_packs": False,
"max_results": 30, # nombre max de streams renvoyés
"max_per_resolution": 0, # 0 = illimité
"cached_only": False, # ne montre que les torrents en cache débrideur
"show_uncached": True, # affiche les non-cachés (clic = ajout au débrideur)
"show_p2p": False, # streams P2P via le moteur torrent de Stremio (sans débrideur)
},
# Ordre des critères de tri (appliqués dans l'ordre)
"sort": ["cached", "language", "resolution", "size_desc"],
# Priorités utilisées par les critères "language" / "resolution" / "tracker"
"language_order": ["MULTI", "VFF", "VF", "VFQ", "VOSTFR", "VO"],
"resolution_order": ["4K", "1080p", "720p", "SD"],
"providers_order": [],
}
def _deep_merge(base, override):
"""Fusionne récursivement override dans base (sans modifier base)."""
result = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def decode_config(config_str):
"""Décode la config base64 de l'URL et la fusionne avec les défauts."""
if not config_str:
return None
try:
padded = config_str + "=" * (-len(config_str) % 4)
try:
decoded = base64.urlsafe_b64decode(padded).decode("utf-8")
except Exception:
decoded = base64.b64decode(padded).decode("utf-8")
user_config = json.loads(decoded)
if not isinstance(user_config, dict):
return None
return _deep_merge(DEFAULT_CONFIG, user_config)
except Exception as e:
logging.error(f"Config decode error: {e}")
return None
def encode_config(config):
"""Encode une config en base64 URL-safe (utilisé par la page /configure)."""
raw = json.dumps(config, separators=(",", ":"), ensure_ascii=False)
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
def get_debrids(config):
"""Liste [(service, key)] dans l'ordre de priorité, entrées valides uniquement."""
out = []
seen = set()
for entry in config.get("debrids", []):
service = (entry.get("service") or "").strip().lower()
key = (entry.get("key") or "").strip()
if service in DEBRID_SERVICES and key and service not in seen:
out.append((service, key))
seen.add(service)
return out

122
core/debrid.py Normal file
View file

@ -0,0 +1,122 @@
"""
Couche d'abstraction débrideur.
Unifie les services natifs (AllDebrid, Real-Debrid, TorBox, DebridLink) et leur
proxy StremThru derrière une interface commune :
- check_availability(hashes) -> {hash: bool}
- resolve(hash, season, episode, media_type) -> url directe
Le cache SQLite est consulté avant tout appel réseau ; StremThru remplace le
service natif quand il est configuré (contournement blocage IP).
"""
import asyncio
import logging
from core import cache
from services.alldebrid import AllDebridService
from services.realdebrid import RealDebridService
from services.torbox import TorBoxService
from services.debridlink import DebridLinkService
from services.stremthru import StremThruService, STREMTHRU_STORES
NATIVE_FACTORIES = {
"alldebrid": AllDebridService,
"realdebrid": RealDebridService,
"torbox": TorBoxService,
"debridlink": DebridLinkService,
}
class DebridBackend:
"""Un débrideur configuré (natif ou via StremThru)."""
def __init__(self, service_name, api_key, stremthru=None, client_ip=None):
self.name = service_name
self.api_key = api_key
self._stremthru_cfg = stremthru or {}
self._client_ip = client_ip
self._impl = self._build_impl()
def _build_impl(self):
url = (self._stremthru_cfg.get("url") or "").strip().rstrip("/")
if url and self.name in STREMTHRU_STORES:
logging.info(f"StremThru actif pour {self.name} ({url})")
return StremThruService(
url, self.name, self.api_key,
auth=(self._stremthru_cfg.get("auth") or "").strip() or None,
client_ip=self._client_ip,
)
return NATIVE_FACTORIES[self.name](self.api_key)
@property
def uses_stremthru(self):
return isinstance(self._impl, StremThruService)
def clean_hash(self, info_hash):
if hasattr(self._impl, "_clean_hash"):
return self._impl._clean_hash(info_hash)
return info_hash.lower().strip()
async def check_availability(self, hashes):
"""Vérifie la disponibilité en cache, cache SQLite d'abord."""
if not hashes:
return {}
cleaned = [self.clean_hash(h) for h in hashes]
cleaned = list(dict.fromkeys([h for h in cleaned if h]))
known, unknown = cache.get_availability(self.name, cleaned)
if unknown:
fresh = await self._check_remote(unknown)
cache.set_availability(self.name, fresh)
known.update(fresh)
return known
async def _check_remote(self, hashes):
try:
if self.name == "torbox" and not self.uses_stremthru:
# TorBox natif : un appel par hash
results = await asyncio.gather(
*[self._impl.check_availability(h) for h in hashes],
return_exceptions=True,
)
return {
h: bool(r)
for h, r in zip(hashes, results)
if not isinstance(r, Exception)
}
avail = await self._impl.check_availability(hashes)
# Normalise les clés en hash nettoyés
return {h: bool(avail.get(h, avail.get(self.clean_hash(h), False))) for h in hashes}
except Exception as e:
logging.error(f"{self.name}: check_availability a échoué: {e}")
return {}
async def resolve(self, info_hash, season=None, episode=None, media_type=None):
"""Génère l'URL de lecture directe (ajoute au débrideur si non caché)."""
try:
if self.name == "torbox" and not self.uses_stremthru:
magnet = f"magnet:?xt=urn:btih:{info_hash}"
stream_type = "series" if (season and episode) else "movie"
url = await self._impl.get_stream_link(magnet, stream_type, season=season, episode=episode)
else:
url = await self._impl.unlock_magnet(info_hash, season=season, episode=episode, media_type=media_type)
if url:
cache.mark_cached(self.name, self.clean_hash(info_hash))
return url
except Exception as e:
logging.error(f"{self.name}: resolve a échoué: {e}")
return None
def build_backends(config, client_ip=None):
"""Construit les DebridBackend dans l'ordre de priorité de la config."""
from core.config import get_debrids
stremthru = config.get("stremthru") or {}
backends = []
for service, key in get_debrids(config):
try:
backends.append(DebridBackend(service, key, stremthru=stremthru, client_ip=client_ip))
except Exception as e:
logging.error(f"Impossible d'initialiser {service}: {e}")
return backends

110
core/formatting.py Normal file
View file

@ -0,0 +1,110 @@
"""
Construction des objets stream Stremio.
Conventions de nommage compatibles AIOStreams (héritées de Torrentio) :
- "[AD+]" -> service identifié + confirmé en cache
- "[AD download]" -> service identifié, torrent non caché (clic = ajout)
"""
from utils import format_size
DEBRID_TAGS = {"alldebrid": "AD", "realdebrid": "RD", "torbox": "TB", "debridlink": "DL"}
SOURCE_EMOJIS = {
"ygg": "🐝 YGG",
"abn": "🎬 ABN",
"c411": "📡 C411",
"torr9": "🔥 Torr9",
"tr4ker": "🎯 Tr4ker",
"nyaa": "🐈 Nyaa",
"nekobt": "🐾 NekoBT",
"apibay": "🏴‍☠️ TPB",
"eztv": "📺 EZTV",
"unit3d": "🌐",
"torznab": "🗂️",
}
def _tracker_label(torrent):
source = torrent.get("source", "")
label = SOURCE_EMOJIS.get(source, f"🌐 {source}")
if source in ("unit3d", "torznab"):
raw = torrent.get("tracker_name", "")
if raw.startswith("http"):
from urllib.parse import urlparse
raw = (urlparse(raw).hostname or raw).split(".")[0].capitalize()
label = f"{label} {raw}".strip()
return label
def _description(torrent):
meta = torrent["_meta"]
tech = []
if meta["resolution"]:
tech.append(f"📺 {meta['resolution']}")
if meta["source"]:
tech.append(f"📦 {meta['source']}")
if meta["codec"]:
tech.append(f"🎞️ {meta['codec']}")
if meta["hdr"]:
tech.append(f"{' '.join(meta['hdr'])}")
if meta["audio"]:
tech.append(f"🔊 {' '.join(meta['audio'][:2])}")
stats = [f"💾 {format_size(torrent.get('size', 0))}"]
seeders = torrent.get("seeders")
if seeders:
stats.append(f"👤 {seeders}")
stats.append(_tracker_label(torrent))
lines = []
if tech:
lines.append(" | ".join(tech))
if meta["lang_display"]:
lines.append(meta["lang_display"])
lines.append(torrent.get("name", ""))
lines.append(" ".join(stats))
return "\n".join(lines)
def _behavior_hints(torrent, service=None):
meta = torrent["_meta"]
hints = {
"bingeGroup": f"ranger|{service or 'p2p'}|{meta['resolution']}|{meta['codec']}",
"filename": torrent.get("name", ""),
}
size = torrent.get("size", 0)
if size:
hints["videoSize"] = size
return hints
def build_debrid_stream(torrent, service, cached, resolve_url):
"""Stream débrideur (caché ou non). resolve_url : URL /resolve à la lecture."""
meta = torrent["_meta"]
tag = DEBRID_TAGS.get(service, service.upper())
status = f"[{tag}+]" if cached else f"[{tag} download]"
res = f" {meta['resolution']}" if meta["resolution"] else ""
description = _description(torrent)
if not cached:
description = "📥 Non caché — lecture = ajout au débrideur\n" + description
return {
"name": f"Ranger {status}{res}",
"description": description,
"url": resolve_url,
"behaviorHints": _behavior_hints(torrent, service),
}
def build_p2p_stream(torrent):
"""Stream P2P via le moteur torrent intégré de Stremio (sans débrideur)."""
meta = torrent["_meta"]
res = f" {meta['resolution']}" if meta["resolution"] else ""
return {
"name": f"Ranger [P2P]{res}",
"description": _description(torrent),
"infoHash": torrent["info_hash"],
"behaviorHints": _behavior_hints(torrent),
}

165
core/metadata.py Normal file
View file

@ -0,0 +1,165 @@
"""
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) 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 (logique héritée de Frenchio)
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:
if 16 in genre_ids or orig_lang == "ja":
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) 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

141
core/parsing.py Normal file
View file

@ -0,0 +1,141 @@
"""
Parsing des noms de release : résolution, codec, source, HDR, audio, langues.
Pensé pour le contenu français (MULTI/VFF/VOSTFR...) comme étranger.
"""
import re
RESOLUTIONS = ["4K", "1080p", "720p", "SD"]
LANGS = ["MULTI", "VFF", "VF", "VFQ", "VOSTFR", "VO"]
LANG_FLAGS = {
"MULTI": "🇫🇷🇬🇧 MULTI",
"VFF": "🇫🇷 VFF",
"VF": "🇫🇷 VF",
"VFQ": "🇨🇦 VFQ",
"VOSTFR": "🇯🇵🇫🇷 VOSTFR",
"VO": "🇬🇧 VO",
}
_WORD = lambda w: re.compile(r"(?<![A-Z0-9])" + w + r"(?![A-Z0-9])")
_RES_PATTERNS = [
("4K", re.compile(r"2160P|4K|UHD")),
("1080p", re.compile(r"1080P")),
("720p", re.compile(r"720P")),
("SD", re.compile(r"480P|576P|\bSD\b|DVDRIP")),
]
_CODEC_PATTERNS = [
("x265", re.compile(r"X265|HEVC|H\.?265")),
("x264", re.compile(r"X264|AVC|H\.?264")),
("AV1", _WORD("AV1")),
]
_SOURCE_PATTERNS = [
("REMUX", _WORD("REMUX")),
("BluRay", re.compile(r"BLU-?RAY|BRRIP|BDRIP|BDLIGHT|\bBD\b")),
("WEB-DL", re.compile(r"WEB-?DL")),
("WEBRip", _WORD("WEBRIP")),
("WEB", _WORD("WEB")),
("HDTV", _WORD("HDTV")),
("DVDRip", _WORD("DVDRIP")),
("CAM", re.compile(r"\bCAM\b|\bHDCAM\b|\bHDTS\b|HD-TS|\bTELESYNC\b|\bTS\b(?!C)")),
]
_AUDIO_PATTERNS = [
("Atmos", _WORD("ATMOS")),
("TrueHD", re.compile(r"TRUE-?HD")),
("DTS-HD", re.compile(r"DTS-?HD")),
("DTS", _WORD("DTS")),
("DD+", re.compile(r"DDP|EAC-?3|DD\+")),
("AC3", re.compile(r"\bAC-?3\b|\bDD5[. ]?1\b")),
("AAC", _WORD("AAC")),
]
def parse_release(name):
"""Analyse un nom de release et retourne un dict structuré."""
if not name:
return {
"resolution": "", "codec": "", "source": "", "hdr": [],
"audio": [], "languages": [], "lang_display": "",
}
up = name.upper().replace("_", " ")
resolution = ""
for label, pattern in _RES_PATTERNS:
if pattern.search(up):
resolution = label
break
codec = ""
for label, pattern in _CODEC_PATTERNS:
if pattern.search(up):
codec = label
break
source = ""
for label, pattern in _SOURCE_PATTERNS:
if pattern.search(up):
source = label
break
hdr = []
if re.search(r"\bHDR10\+", up):
hdr.append("HDR10+")
elif re.search(r"\bHDR", up):
hdr.append("HDR")
if re.search(r"\bDV\b|DOLBY[. ]?VISION|\bDOVI\b", up):
hdr.append("DV")
if "10BIT" in up or "10-BIT" in up:
hdr.append("10bit")
audio = [label for label, pattern in _AUDIO_PATTERNS if pattern.search(up)]
# Langues — un torrent peut en cumuler (MULTI VFF, VOSTFR + VO...)
languages = []
if re.search(r"\bMULTI\b|\bMULTI3\b|\bMULTILANG", up):
languages.append("MULTI")
if re.search(r"TRUEFRENCH|\bVFF\b|\bVFI\b|\bVF2\b", up):
languages.append("VFF")
if re.search(r"\bVFQ\b", up):
languages.append("VFQ")
if re.search(r"\bVOSTFR\b|\bSUBFRENCH\b|\bSTFR\b", up):
languages.append("VOSTFR")
if not any(l in languages for l in ("MULTI", "VFF", "VFQ")) and re.search(r"\bFRENCH\b|\bVF\b", up):
languages.append("VF")
if not languages:
# Pas de tag FR : release VO (cas standard des trackers internationaux)
languages.append("VO")
display = " ".join(LANG_FLAGS.get(l, l) for l in languages)
return {
"resolution": resolution,
"codec": codec,
"source": source,
"hdr": hdr,
"audio": audio,
"languages": languages,
"lang_display": display,
}
def best_language(languages, language_order):
"""Rang de la meilleure langue du torrent selon l'ordre de préférence."""
best = len(language_order)
for lang in languages or []:
try:
best = min(best, language_order.index(lang))
except ValueError:
continue
return best
def resolution_rank(resolution, resolution_order):
try:
return resolution_order.index(resolution)
except ValueError:
return len(resolution_order)

190
core/ranking.py Normal file
View file

@ -0,0 +1,190 @@
"""
Moteur de filtrage, déduplication et tri des torrents.
"""
import logging
from core.parsing import parse_release, best_language, resolution_rank
from utils import (
check_title_match,
check_title_tokens,
check_season_episode,
check_absolute_episode,
check_special_episode,
is_video_file,
)
ANIME_SOURCES = ("nyaa", "nekobt")
def enrich(torrents):
"""Attache le parsing de release à chaque torrent (clé '_meta')."""
for t in torrents:
if "_meta" not in t:
t["_meta"] = parse_release(t.get("name", ""))
return torrents
def filter_relevance(torrents, stream_type, media_info, season, episode, absolute_episode, exclude_packs=False):
"""
Filtre de pertinence : bon titre, bonne saison/épisode, fichier vidéo.
(Indépendant des préférences utilisateur toujours appliqué.)
"""
title = (media_info or {}).get("title", "")
original_title = (media_info or {}).get("original_title", "")
year = (media_info or {}).get("year", "")
kept = []
for t in torrents:
name = t.get("name", "")
if not t.get("info_hash"):
continue
if not is_video_file(name):
continue
source = t.get("source", "")
is_anime_source = source in ANIME_SOURCES
# Vérification du titre (sauf nommage fansub, trop variable)
if not is_anime_source:
if not check_title_match(name, title, original_title, year=year, is_movie=(stream_type == "movie")):
continue
# Vérification saison/épisode
if stream_type == "series" and season is not None:
if season == 0 and is_anime_source:
se_ok = check_special_episode(name, episode, exclude_packs=exclude_packs)
else:
se_ok = check_season_episode(name, season, episode, exclude_packs=exclude_packs)
if not se_ok and is_anime_source:
se_ok = check_absolute_episode(name, absolute_episode, exclude_packs=exclude_packs)
# Les matchs "mous" (packs, absolu) des sources anime doivent contenir le titre
if se_ok and is_anime_source and not check_season_episode(name, season, episode, exclude_packs=True):
se_ok = check_title_tokens(name, title, original_title)
if not se_ok:
continue
kept.append(t)
return kept
def filter_preferences(torrents, filters):
"""Filtres de préférence utilisateur : taille, résolution, codec, langue, CAM."""
min_size = (filters.get("min_size_gb") or 0) * 1024**3
max_size = (filters.get("max_size_gb") or 0) * 1024**3
allowed_res = set(filters.get("resolutions") or [])
allowed_codecs = set(filters.get("codecs") or [])
allowed_langs = set(filters.get("languages") or [])
exclude_cam = filters.get("exclude_cam", True)
kept = []
for t in torrents:
meta = t["_meta"]
size = t.get("size", 0) or 0
# Les tailles à 0 (inconnues) ne sont pas filtrées
if size:
if min_size and size < min_size:
continue
if max_size and size > max_size:
continue
if exclude_cam and meta["source"] == "CAM":
continue
if allowed_res and meta["resolution"] and meta["resolution"] not in allowed_res:
continue
if allowed_codecs and meta["codec"] and meta["codec"] not in allowed_codecs:
continue
if allowed_langs and not (set(meta["languages"]) & allowed_langs):
continue
kept.append(t)
return kept
def dedupe(torrents, providers_order):
"""
Déduplication par info_hash. En cas de doublon, on garde celui du tracker
le mieux classé dans providers_order et on fusionne les seeders max.
"""
def prov_rank(t):
try:
return providers_order.index(t.get("source", ""))
except ValueError:
return len(providers_order)
ordered = sorted(torrents, key=prov_rank) if providers_order else torrents
unique = {}
for t in ordered:
ih = (t.get("info_hash") or "").lower().strip()
if not ih:
continue
if ih in unique:
existing = unique[ih]
existing["seeders"] = max(existing.get("seeders", 0) or 0, t.get("seeders", 0) or 0)
else:
t["info_hash"] = ih
unique[ih] = t
return list(unique.values())
def sort_entries(entries, config):
"""
Trie les entrées de stream selon les critères configurés (dans l'ordre).
Une entrée = dict {torrent, service (débrideur ou None), cached (bool)}.
"""
criteria = config.get("sort") or ["cached", "language", "resolution", "size_desc"]
lang_order = config.get("language_order") or []
res_order = config.get("resolution_order") or []
prov_order = config.get("providers_order") or []
def key(entry):
t = entry["torrent"]
meta = t["_meta"]
parts = []
for criterion in criteria:
if criterion == "cached":
parts.append(0 if entry["cached"] else 1)
elif criterion == "language":
parts.append(best_language(meta["languages"], lang_order))
elif criterion == "resolution":
parts.append(resolution_rank(meta["resolution"], res_order))
elif criterion == "size_desc":
parts.append(-(t.get("size", 0) or 0))
elif criterion == "size_asc":
parts.append(t.get("size", 0) or 0)
elif criterion == "seeders":
parts.append(-(t.get("seeders", 0) or 0))
elif criterion == "tracker":
source = t.get("source", "")
try:
parts.append(prov_order.index(source))
except ValueError:
parts.append(len(prov_order))
return tuple(parts)
entries.sort(key=key)
return entries
def apply_limits(entries, filters):
"""Limite le nombre de résultats (global et par résolution)."""
max_results = filters.get("max_results") or 0
max_per_res = filters.get("max_per_resolution") or 0
if max_per_res:
counts = {}
limited = []
for entry in entries:
res = entry["torrent"]["_meta"]["resolution"] or "?"
counts[res] = counts.get(res, 0) + 1
if counts[res] <= max_per_res:
limited.append(entry)
entries = limited
if max_results:
entries = entries[:max_results]
logging.info(f"Après limites : {len(entries)} streams")
return entries

170
core/search.py Normal file
View file

@ -0,0 +1,170 @@
"""
Orchestrateur de recherche : lance en parallèle tous les trackers activés,
avec cache SQLite par tracker. Retourne une liste brute de torrents normalisés.
"""
import asyncio
import logging
from core import cache
from services.unit3d import Unit3DService
from services.ygg import YggService
from services.abn import ABNService
from services.c411 import C411Service
from services.torr9 import Torr9Service
from services.tr4ker import Tr4kerService
from services.nekobt import NekoBTService
from services.nyaa import NyaaService
from services.apibay import ApibayService
from services.eztv import EztvService
from services.torznab import TorznabService
def _search_cache_key(source, stream_type, imdb_id, season, episode):
part = f"{stream_type}:{imdb_id}"
if stream_type == "series":
part += f":{season}:{episode}"
return f"search:{source}:{part}"
async def _cached_search(source, coro_factory, stream_type, imdb_id, season, episode):
"""Exécute une recherche avec mise en cache SQLite du résultat brut."""
key = _search_cache_key(source, stream_type, imdb_id, season, episode)
cached = cache.get_search(key)
if cached is not None:
logging.info(f"[{source}] {len(cached)} résultats (cache)")
return cached
try:
results = await coro_factory()
except Exception as e:
logging.error(f"[{source}] erreur: {e}")
return []
results = results or []
for r in results:
r.setdefault("source", source)
cache.set_search(key, results)
logging.info(f"[{source}] {len(results)} résultats (frais)")
return results
async def run_search(config, media_info, stream_type, imdb_id, tmdb_id, season, episode, absolute_episode):
"""Lance toutes les recherches activées et retourne la liste fusionnée brute."""
trackers = set(config.get("trackers") or [])
keys = config.get("tracker_keys") or {}
title = (media_info or {}).get("title", "")
original_title = (media_info or {}).get("original_title", "")
year = (media_info or {}).get("year", "")
is_anime = (media_info or {}).get("is_anime", False)
tasks = []
abn_service = None # référence pour fermeture propre
def add(source, factory):
tasks.append(_cached_search(source, factory, stream_type, imdb_id, season, episode))
# --- UNIT3D (multi-trackers privés) ---
unit3d_cfg = [
{"url": t["url"].rstrip("/"), "token": t.get("key", ""), "categories": []}
for t in (config.get("unit3d") or []) if t.get("url") and t.get("key")
]
if unit3d_cfg:
svc = Unit3DService(unit3d_cfg)
add("unit3d", lambda: svc.search_all(
tmdb_id=tmdb_id, imdb_id=imdb_id, type=stream_type, season=season, episode=episode))
# --- YGG (public, relais Nostr yggleak) ---
if "ygg" in trackers:
svc = YggService()
if stream_type == "movie":
add("ygg", lambda: svc.search_movie(title, year, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("ygg", lambda: svc.search_series(title, season, episode, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- ABN (login/mot de passe) ---
abn_cfg = config.get("abn") or {}
if abn_cfg.get("username") and abn_cfg.get("password"):
abn_service = ABNService(username=abn_cfg["username"], password=abn_cfg["password"])
if stream_type == "movie":
add("abn", lambda: abn_service.search_movie(title, year, original_title=original_title))
else:
add("abn", lambda: abn_service.search_series(title, season, episode, original_title=original_title))
# --- C411 (clé API) ---
if "c411" in trackers and keys.get("c411"):
svc = C411Service(keys["c411"])
if stream_type == "movie":
add("c411", lambda: svc.search_movie(title, year, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("c411", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- Torr9 (passkey Torznab) ---
if "torr9" in trackers and keys.get("torr9"):
svc = Torr9Service(keys["torr9"])
if stream_type == "movie":
add("torr9", lambda: svc.search_movie(title, year, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("torr9", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- Tr4ker (clé API) ---
if "tr4ker" in trackers and keys.get("tr4ker"):
svc = Tr4kerService(keys["tr4ker"])
if stream_type == "movie":
add("tr4ker", lambda: svc.search_movie(title, year, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("tr4ker", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- apibay / ThePirateBay (public, VO) ---
if "apibay" in trackers:
svc = ApibayService()
if stream_type == "movie":
add("apibay", lambda: svc.search_movie(title, year, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("apibay", lambda: svc.search_series(title, season, episode, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- EZTV (public, séries VO) ---
if "eztv" in trackers and stream_type == "series":
svc = EztvService()
add("eztv", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- Torznab (Jackett/Prowlarr : trackers publics & privés) ---
torznab_cfg = config.get("torznab") or []
if torznab_cfg:
svc = TorznabService(torznab_cfg)
if stream_type == "movie":
add("torznab", lambda: svc.search_movie(title, year, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("torznab", lambda: svc.search_series(title, season, episode, original_title=original_title, imdb_id=imdb_id, tmdb_id=tmdb_id))
# --- Trackers anime (uniquement si le média est un anime) ---
if is_anime:
if "nekobt" in trackers and keys.get("nekobt"):
svc = NekoBTService(keys["nekobt"])
if stream_type == "movie":
add("nekobt", lambda: svc.search_movie(title, year, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("nekobt", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id, absolute_episode=absolute_episode))
if "nyaa" in trackers:
svc = NyaaService()
if stream_type == "movie":
add("nyaa", lambda: svc.search_movie(title, year, imdb_id=imdb_id, tmdb_id=tmdb_id))
else:
add("nyaa", lambda: svc.search_series(title, season, episode, imdb_id=imdb_id, tmdb_id=tmdb_id, absolute_episode=absolute_episode))
if not tasks:
return []
try:
results_list = await asyncio.gather(*tasks, return_exceptions=True)
finally:
if abn_service:
await abn_service.close()
merged = []
for results in results_list:
if isinstance(results, Exception):
logging.error(f"Tâche de recherche échouée: {results}")
continue
merged.extend(results)
logging.info(f"Recherche totale : {len(merged)} torrents bruts")
return merged

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
services:
ranger:
build: .
image: ranger:latest
container_name: ranger
restart: unless-stopped
ports:
- "7000:7000"
environment:
- PORT=7000
- RANGER_DB=/data/ranger.db
# TTLs de cache (secondes) — décommenter pour surcharger
# - RANGER_TTL_CACHED=21600
# - RANGER_TTL_UNCACHED=1200
# - RANGER_TTL_SEARCH=1800
# Proxy sortant optionnel (si l'IP du VPS est bloquée sans StremThru)
# - HTTP_PROXY=http://user:pass@proxyhost:port
# - HTTPS_PROXY=http://user:pass@proxyhost:port
volumes:
- ranger_data:/data
volumes:
ranger_data:

338
main.py Normal file
View file

@ -0,0 +1,338 @@
"""
Ranger Stremio addon multi-trackers / multi-débrideurs.
v1 : films / séries / anime, contenu français comme étranger.
- Multi-débrideurs avec priorité (AllDebrid, Real-Debrid, TorBox, DebridLink)
- StremThru intégré (contournement blocage IP datacenter)
- Trackers publics (YGG, TPB, EZTV, Nyaa) + privés/semi (C411, Torr9, Tr4ker,
NekoBT, UNIT3D) + Torznab générique (Jackett/Prowlarr)
- TMDB (titres FR, anime, épisode absolu) avec fallback Cinemeta
- Filtres taille/résolution/codec/langue, tri multi-critères, déduplication
- Cache SQLite (dispo débrideur + recherches + métadonnées)
- Compatible AIOStreams (tags [XX+] / statut de cache)
"""
import asyncio
import logging
import os
import aiofiles
import aiohttp
from aiohttp import web
from core import cache
from core.config import decode_config, DEFAULT_CONFIG
from core.debrid import build_backends
from core.metadata import get_media_info, compute_absolute_episode
from core.search import run_search
from core import ranking
from core import formatting
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
APP_VERSION = "1.0.0"
ADDON_ID = "community.ranger.addon"
PORT = int(os.getenv("PORT", "7000"))
BASE_PATH = os.path.dirname(os.path.abspath(__file__))
# ============================================================================
# Middleware
# ============================================================================
@web.middleware
async def cors_middleware(request, handler):
if request.method == "OPTIONS":
response = web.Response(status=204)
else:
response = await handler(request)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return response
def get_client_ip(request):
"""IP publique du lecteur Stremio, transmise à StremThru pour le débrideur."""
import ipaddress
forwarded = request.headers.get("X-Forwarded-For", "")
ip = forwarded.split(",")[0].strip() if forwarded else request.remote
try:
if ip and ipaddress.ip_address(ip).is_global:
return ip
except ValueError:
pass
return None
# ============================================================================
# Page de configuration
# ============================================================================
async def handle_configure(request):
try:
async with aiofiles.open(os.path.join(BASE_PATH, "templates", "configure.html"),
mode="r", encoding="utf-8") as f:
content = await f.read()
content = content.replace("__APP_VERSION__", APP_VERSION)
return web.Response(text=content, content_type="text/html")
except Exception as e:
logging.error(f"configure error: {e}")
return web.Response(text=str(e), status=500)
# ============================================================================
# Manifest
# ============================================================================
def _manifest(configured):
return {
"id": ADDON_ID,
"version": APP_VERSION,
"name": "Ranger",
"description": "Addon ultime multi-trackers / multi-débrideurs (FR & international) — films, séries, anime.",
"logo": "https://i.imgur.com/MgdGxnR.png",
"types": ["movie", "series"],
"catalogs": [],
"resources": ["stream"],
"idPrefixes": ["tt"],
"behaviorHints": {
"configurable": True,
"configurationRequired": not configured,
},
}
async def handle_manifest_no_config(request):
return web.json_response(_manifest(configured=False))
async def handle_manifest(request):
config = decode_config(request.match_info.get("config", ""))
if not config:
return web.json_response(_manifest(configured=False))
return web.json_response(_manifest(configured=True))
async def handle_stream_no_config(request):
host_url = f"{request.scheme}://{request.host}"
return web.json_response({"streams": [{
"name": "Ranger",
"title": "⚙️ Configure Ranger",
"externalUrl": f"{host_url}/configure",
}]})
# ============================================================================
# Stream
# ============================================================================
def _parse_stream_id(stream_id):
imdb_id, season, episode = stream_id, None, None
if ":" in stream_id:
parts = stream_id.split(":")
imdb_id = parts[0]
if len(parts) >= 3:
try:
season, episode = int(parts[1]), int(parts[2])
except ValueError:
pass
return imdb_id, season, episode
async def handle_stream(request):
config = decode_config(request.match_info.get("config", ""))
if not config:
return web.json_response({"streams": []})
stream_type = request.match_info.get("type")
stream_id = request.match_info.get("id")
imdb_id, season, episode = _parse_stream_id(stream_id)
logging.info(f"Stream {stream_type} {imdb_id} S{season}E{episode}")
config_str = request.match_info.get("config", "")
host_url = f"{request.scheme}://{request.host}"
filters = config.get("filters") or {}
# 1. Métadonnées (TMDB ou Cinemeta)
media_info = await get_media_info(imdb_id, stream_type, (config.get("tmdb_key") or "").strip())
if not media_info:
media_info = {"title": "", "original_title": "", "year": "", "is_anime": False, "seasons": [], "tmdb_id": None}
tmdb_id = media_info.get("tmdb_id")
absolute_episode = compute_absolute_episode(media_info, season, episode) if media_info.get("is_anime") else None
# 2. Recherche parallèle sur tous les trackers activés
raw = await run_search(config, media_info, stream_type, imdb_id, tmdb_id, season, episode, absolute_episode)
if not raw:
return web.json_response({"streams": []})
# 3. Filtrage pertinence + préférences, enrichissement parsing, dédup
torrents = ranking.enrich(raw)
torrents = ranking.filter_relevance(
torrents, stream_type, media_info, season, episode, absolute_episode,
exclude_packs=filters.get("exclude_season_packs", False),
)
torrents = ranking.dedupe(torrents, config.get("providers_order") or [])
torrents = ranking.filter_preferences(torrents, filters)
if not torrents:
return web.json_response({"streams": []})
logging.info(f"{len(torrents)} torrents pertinents après filtrage")
# 4. Disponibilité débrideur (parallèle) + cache SQLite
backends = build_backends(config, client_ip=get_client_ip(request))
hashes = [t["info_hash"] for t in torrents if t.get("info_hash")]
availability = {}
if backends and hashes:
results = await asyncio.gather(*[b.check_availability(hashes) for b in backends])
for backend, avail in zip(backends, results):
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)
# 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)
logging.info(f"{len(streams)} streams renvoyés à Stremio")
return web.json_response({"streams": streams})
def _build_entries(torrents, backends, availability, config):
"""
Une entrée = {torrent, service|None, cached}.
- debrid_mode 'first' : premier débrideur (par priorité) c'est caché.
- debrid_mode 'all' : une entrée par débrideur c'est caché.
- non caché : selon show_uncached / cached_only.
- P2P : selon show_p2p.
"""
filters = config.get("filters") or {}
debrid_mode = config.get("debrid_mode", "first")
cached_only = filters.get("cached_only", False)
show_uncached = filters.get("show_uncached", True) and not cached_only
show_p2p = filters.get("show_p2p", False)
entries = []
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:
chosen = cached_backends if debrid_mode == "all" else cached_backends[:1]
for b in chosen:
entries.append({"torrent": t, "service": b.name, "cached": True})
elif backends and show_uncached:
# Non caché : proposé via le débrideur prioritaire (clic = ajout)
entries.append({"torrent": t, "service": backends[0].name, "cached": False})
if show_p2p:
entries.append({"torrent": t, "service": None, "cached": False})
return entries
def _serialize_streams(entries, config_str, host_url, stream_type, season, episode):
streams = []
for entry in entries:
t = entry["torrent"]
service = entry["service"]
if service is None:
streams.append(formatting.build_p2p_stream(t))
continue
resolve_url = f"{host_url}/{config_str}/resolve/{service}/{t['info_hash']}"
if season is not None and episode is not None:
resolve_url += f"?season={season}&episode={episode}"
elif stream_type == "movie":
resolve_url += "?type=movie"
streams.append(formatting.build_debrid_stream(t, service, entry["cached"], resolve_url))
return streams
# ============================================================================
# Resolve (au moment de la lecture)
# ============================================================================
async def handle_resolve(request):
config = decode_config(request.match_info.get("config", ""))
if not config:
return web.Response(status=400, text="Config invalide")
service_name = request.match_info.get("service")
info_hash = request.match_info.get("hash")
season = request.query.get("season")
episode = request.query.get("episode")
media_type = request.query.get("type")
backends = build_backends(config, client_ip=get_client_ip(request))
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}")
url = await backend.resolve(
info_hash,
season=int(season) if season else None,
episode=int(episode) if episode else None,
media_type=media_type,
)
if url:
raise web.HTTPFound(url)
return web.Response(status=404, text="Impossible de résoudre le stream")
# ============================================================================
# Divers
# ============================================================================
async def handle_health(request):
return web.json_response({"status": "ok", "version": APP_VERSION})
async def _cache_cleanup_loop(app):
"""Tâche de fond : purge périodique du cache SQLite."""
try:
while True:
await asyncio.sleep(3600)
try:
cache.cleanup()
logging.info("Cache SQLite purgé")
except Exception as e:
logging.error(f"Cache cleanup error: {e}")
except asyncio.CancelledError:
pass
async def _on_startup(app):
app["cleanup_task"] = asyncio.create_task(_cache_cleanup_loop(app))
async def _on_cleanup(app):
task = app.get("cleanup_task")
if task:
task.cancel()
def get_app():
app = web.Application(middlewares=[cors_middleware])
app.router.add_get("/", handle_configure)
app.router.add_get("/configure", handle_configure)
app.router.add_get("/health", handle_health)
app.router.add_get("/manifest.json", handle_manifest_no_config)
app.router.add_get("/stream/{type}/{id}.json", handle_stream_no_config)
app.router.add_get("/{config}/configure", handle_configure)
app.router.add_get("/{config}/manifest.json", handle_manifest)
app.router.add_get("/{config}/stream/{type}/{id}.json", handle_stream)
app.router.add_get("/{config}/resolve/{service}/{hash}", handle_resolve)
app.on_startup.append(_on_startup)
app.on_cleanup.append(_on_cleanup)
return app
if __name__ == "__main__":
web.run_app(get_app(), host="0.0.0.0", port=PORT)

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
aiohttp==3.9.5
aiofiles==23.2.1

0
services/__init__.py Normal file
View file

435
services/abn.py Normal file
View file

@ -0,0 +1,435 @@
import aiohttp
import logging
import asyncio
import re
from urllib.parse import urlencode
class ABNService:
"""
Service pour le tracker ABNormal (ABN)
Tracker privé français avec authentification par username/password
"""
def __init__(self, username, password, base_url="https://abn.lol"):
self.username = username
self.password = password
self.base_url = base_url.rstrip('/')
self.session = None
self._login_lock = None
async def close(self):
"""Ferme proprement la session"""
if self.session:
await self.session.close()
self.session = None
logging.debug("ABN: Session closed")
async def _ensure_session(self):
"""Crée et authentifie une session persistante"""
if self.session is not None:
return True
# Créer une session avec cookie jar
self.session = aiohttp.ClientSession(trust_env=True)
login_url = f"{self.base_url}/Home/Login"
# Première requête pour obtenir le token CSRF
try:
async with self.session.get(login_url, timeout=10) as resp:
if resp.status != 200:
logging.error(f"ABN: Failed to get login page: {resp.status}")
await self.session.close()
self.session = None
return False
html = await resp.text()
# Extraction du token CSRF
token_match = re.search(r'name="__RequestVerificationToken".*?value="([^"]+)"', html)
if not token_match:
logging.error("ABN: Could not find CSRF token")
await self.session.close()
self.session = None
return False
csrf_token = token_match.group(1)
# Authentification
login_data = {
'Username': self.username,
'Password': self.password,
'RememberMe': 'true',
'__RequestVerificationToken': csrf_token
}
async with self.session.post(login_url, data=login_data, allow_redirects=True, timeout=10) as resp:
if resp.status == 200:
# Vérifier qu'on est bien connecté
html = await resp.text()
if 'logoutForm' in html or 'Déconnexion' in html or 'Logout' in html:
logging.info("ABN: Login successful - session established")
return True
else:
logging.error("ABN: Login failed - bad credentials")
await self.session.close()
self.session = None
return False
else:
logging.error(f"ABN: Login failed with status {resp.status}")
await self.session.close()
self.session = None
return False
except Exception as e:
logging.error(f"ABN: Login exception: {e}")
if self.session:
await self.session.close()
self.session = None
return False
async def search(self, params):
"""
Recherche générique sur ABN
params peut contenir: q, categories, freeleech, etc.
"""
if not self.username or not self.password:
return []
# S'assurer d'avoir une session authentifiée
if not await self._ensure_session():
logging.error("ABN: Cannot search without valid session")
return []
search_url = f"{self.base_url}/Torrent"
# Paramètres de recherche de base
search_params = {
'Search': params.get('q', ''),
'UserId': '',
'YearOperator': '',
'Year': '',
'RatingOperator': '',
'Rating': '',
'Pending': '',
'Pack': '',
'Scene': '',
'Freeleech': '',
'SortOn': 'Created',
'SortOrder': 'desc'
}
logging.info(f"ABN: Searching with query: {params.get('q', '')}")
# Construire l'URL avec les catégories
# ABN utilise SelectedCats=X pour chaque catégorie
if 'categories' in params and params['categories']:
# Construire les params avec les catégories multiples
cat_params = '&'.join([f'SelectedCats={cat}' for cat in params['categories']])
base_params = urlencode(search_params)
full_url = f"{search_url}?{base_params}&{cat_params}"
else:
full_url = f"{search_url}?{urlencode(search_params)}"
try:
async with self.session.get(full_url, timeout=10) as response:
if response.status == 200:
html = await response.text()
results = self._parse_results(html)
logging.info(f"ABN: Found {len(results)} results")
return results
else:
logging.warning(f"ABN: Search error {response.status}")
return []
except Exception as e:
logging.error(f"ABN: Search exception: {e}")
return []
def _parse_results(self, html):
"""Parse les résultats HTML de ABN"""
results = []
# Méthode 1: Parser les liens de détails d'abord
# Format: <a href="/Torrent/Details?ReleaseId=XXXXX">Nom du torrent</a>
details_pattern = re.compile(r'href="/Torrent/Details\?ReleaseId=(\d+)"[^>]*>([^<]+)</a>', re.IGNORECASE)
details_matches = list(details_pattern.finditer(html))
logging.debug(f"ABN: Found {len(details_matches)} potential torrent links")
if not details_matches:
logging.warning("ABN: No torrent details links found in HTML")
# Essayer de voir s'il y a des download links
download_pattern = re.compile(r'href="/Torrent/Download\?ReleaseId=(\d+)"', re.IGNORECASE)
download_matches = list(download_pattern.finditer(html))
logging.debug(f"ABN: Found {len(download_matches)} download links")
return results
# Méthode 2: Pour chaque torrent, extraire les infos complémentaires
for idx, match in enumerate(details_matches):
torrent_id = match.group(1)
name = match.group(2).strip()
# Nettoyer le nom (supprimer les espaces multiples, etc.)
name = re.sub(r'\s+', ' ', name)
logging.debug(f"ABN: Processing torrent {idx+1}/{len(details_matches)}: ID={torrent_id}, name={name[:50]}...")
# Chercher la ligne complète du torrent dans le tableau
# On cherche les td suivants après le lien de détails
start_pos = match.start()
# Trouver le début de la ligne <tr>
tr_start = html.rfind('<tr', 0, start_pos)
if tr_start == -1:
logging.debug(f"ABN: No <tr> found before torrent {torrent_id}")
tr_start = start_pos
# Extraire jusqu'à la fin de la ligne </tr>
tr_end = html.find('</tr>', start_pos)
if tr_end == -1:
logging.debug(f"ABN: No </tr> found after torrent {torrent_id}")
continue
row_html = html[tr_start:tr_end]
# Extraire taille, seeders, leechers depuis cette ligne
# Taille format: "X,XX Go" ou "XXX Mo" ou "XXX Ko"
size_match = re.search(r'([\d,.]+ [KMGTkmgt][Oo])', row_html, re.IGNORECASE)
size_str = size_match.group(1) if size_match else "0 o"
# Seeders et leechers (dernières colonnes numériques)
numbers = re.findall(r'<td[^>]*>(\d+)</td>', row_html)
seeders = int(numbers[-2]) if len(numbers) >= 2 else 0
leechers = int(numbers[-1]) if len(numbers) >= 1 else 0
# Conversion de la taille en bytes
size_bytes = self._parse_size(size_str)
# Construction du lien de téléchargement
download_url = f"{self.base_url}/Torrent/Download?ReleaseId={torrent_id}"
details_url = f"{self.base_url}/Torrent/Details?ReleaseId={torrent_id}"
result = {
'name': name,
'size': size_bytes,
'tracker_name': 'ABN',
'info_hash': None, # ABN ne fournit pas le hash dans la liste
'magnet': None,
'link': download_url,
'source': 'abn',
'seeders': seeders,
'leechers': leechers,
'details_url': details_url,
'torrent_id': torrent_id
}
results.append(result)
logging.info(f"ABN: Successfully parsed {len(results)} torrents from HTML")
return results
def _parse_size(self, size_str):
"""Convertit une taille (ex: '1.5 Go') en bytes"""
size_str = size_str.replace(',', '.').replace('o', 'B')
match = re.match(r'([\d.]+)\s*([KMGT]?)B?', size_str, re.IGNORECASE)
if not match:
return 0
value = float(match.group(1))
unit = match.group(2).upper()
multipliers = {
'': 1,
'K': 1024,
'M': 1024**2,
'G': 1024**3,
'T': 1024**4
}
return int(value * multipliers.get(unit, 1))
async def get_torrent_hash(self, torrent_id):
"""Récupère le hash d'un torrent depuis sa page de détails"""
details_url = f"{self.base_url}/Torrent/Details?ReleaseId={torrent_id}"
try:
async with self.session.get(details_url, timeout=5) as resp:
if resp.status == 200:
html = await resp.text()
# Chercher le hash dans la page de détails
# Format ABN: Hash : <span class="text-italic">HASH_VALUE</span>
hash_match = re.search(r'Hash\s*:\s*<span[^>]*>([a-fA-F0-9]{40})</span>', html, re.IGNORECASE)
if hash_match:
hash_value = hash_match.group(1).lower()
logging.debug(f"ABN: Found hash for torrent {torrent_id}: {hash_value[:8]}...")
return hash_value
# Fallback: essayer un format plus simple
hash_match = re.search(r'Hash[:\s]+([a-fA-F0-9]{40})', html, re.IGNORECASE)
if hash_match:
hash_value = hash_match.group(1).lower()
logging.debug(f"ABN: Found hash (fallback) for torrent {torrent_id}: {hash_value[:8]}...")
return hash_value
logging.warning(f"ABN: No hash found in details page for torrent {torrent_id}")
else:
logging.warning(f"ABN: Failed to get details page for torrent {torrent_id}: status {resp.status}")
except Exception as e:
logging.error(f"ABN: Error getting hash for torrent {torrent_id}: {e}")
return None
async def enrich_with_hashes(self, results):
"""Enrichit les résultats avec les info_hash en récupérant les pages de détails"""
if not results:
return results
# Limiter à 15 pour plus de rapidité
limit = min(len(results), 15)
logging.info(f"ABN: Enriching {limit} torrents with hashes (parallel)...")
# S'assurer d'avoir une session authentifiée
if not await self._ensure_session():
logging.warning("ABN: Cannot enrich hashes without valid session")
return results
# Récupérer les hash en parallèle
tasks = []
indices = []
for i, result in enumerate(results[:limit]):
if result.get('torrent_id'):
tasks.append(self.get_torrent_hash(result['torrent_id']))
indices.append(i)
if tasks:
# Utiliser un timeout pour ne pas attendre trop longtemps
try:
hashes = await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=10.0 # Max 10 secondes pour tous les hash
)
enriched_count = 0
for idx, hash_value in zip(indices, hashes):
if not isinstance(hash_value, Exception) and hash_value:
results[idx]['info_hash'] = hash_value
enriched_count += 1
logging.info(f"ABN: Successfully enriched {enriched_count}/{len(tasks)} torrents with hashes")
except asyncio.TimeoutError:
logging.warning(f"ABN: Hash enrichment timed out after 10s")
return results
async def download_torrent(self, download_url):
"""Télécharge le fichier .torrent depuis ABN"""
# S'assurer qu'on est connecté
if not await self._ensure_session():
return None
try:
async with self.session.get(download_url, timeout=15) as resp:
if resp.status == 200:
return await resp.read()
logging.error(f"ABN: Download error {resp.status}")
except Exception as e:
logging.error(f"ABN: Download exception: {e}")
return None
async def search_movie(self, title, year, original_title=None):
"""Recherche de films sur ABN (en français et anglais en parallèle)"""
tasks = []
# Préparer les recherches en parallèle
if title:
q = f"{title} {year}".strip()
logging.info(f"ABN: Launching parallel search with French title: {q}")
tasks.append(self.search({
'q': q,
'categories': [2] # 2 = Movies selon la config Jackett
}))
# Recherche avec le titre original (anglais) si différent
if original_title and original_title != title:
q = f"{original_title} {year}".strip()
logging.info(f"ABN: Launching parallel search with English title: {q}")
tasks.append(self.search({
'q': q,
'categories': [2]
}))
# Exécuter toutes les recherches en parallèle
if not tasks:
return []
results_list = await asyncio.gather(*tasks, return_exceptions=True)
# Fusionner et dédupliquer
all_results = []
seen_ids = set()
for results in results_list:
if isinstance(results, Exception):
logging.error(f"ABN: Search error: {results}")
continue
for r in results:
if r.get('torrent_id') not in seen_ids:
all_results.append(r)
seen_ids.add(r.get('torrent_id'))
# Enrichir avec les hash si possible
all_results = await self.enrich_with_hashes(all_results)
return all_results
async def search_series(self, title, season, episode, original_title=None):
"""Recherche de séries sur ABN (en français et anglais en parallèle)"""
tasks = []
titles_to_search = [(title, "French")]
if original_title and original_title != title:
titles_to_search.append((original_title, "English"))
# Préparer toutes les recherches en parallèle
for search_title, lang in titles_to_search:
# Recherche avec SxxExx
if season is not None and episode is not None:
s_str = f"S{int(season):02d}"
e_str = f"E{int(episode):02d}"
q = f"{search_title} {s_str}{e_str}"
logging.info(f"ABN: Launching parallel search with {lang} title: {q}")
tasks.append(self.search({
'q': q,
'categories': [1] # 1 = Series selon la config Jackett
}))
# Recherche pack saison
if season is not None:
q = f"{search_title} S{int(season):02d}"
logging.info(f"ABN: Launching parallel search for season pack with {lang} title: {q}")
tasks.append(self.search({
'q': q,
'categories': [1]
}))
# Exécuter toutes les recherches en parallèle
if not tasks:
return []
results_list = await asyncio.gather(*tasks, return_exceptions=True)
# Fusionner et dédupliquer
all_results = []
seen_ids = set()
for results in results_list:
if isinstance(results, Exception):
logging.error(f"ABN: Search error: {results}")
continue
for r in results:
if r.get('torrent_id') not in seen_ids:
all_results.append(r)
seen_ids.add(r.get('torrent_id'))
# Enrichir avec les hash
all_results = await self.enrich_with_hashes(all_results)
return all_results

433
services/alldebrid.py Normal file
View file

@ -0,0 +1,433 @@
import aiohttp
import logging
import math
import json
import re
import traceback
import binascii
import asyncio
class AllDebridService:
def __init__(self, api_key):
self.api_key = api_key
# On s'assure qu'il n'y a pas de slash final pour éviter les doubles //
self.base_url = "https://api.alldebrid.com/v4.1"
self.agent = "jackett"
def _clean_hash(self, hash_str):
"""
Nettoie le hash si nécessaire.
"""
if not hash_str:
return None
clean = hash_str.strip().lower()
if len(clean) == 80:
try:
decoded = binascii.unhexlify(clean).decode('utf-8')
if len(decoded) == 40 and all(c in '0123456789abcdef' for c in decoded):
return decoded
except Exception:
pass
return clean
def _extract_files_recursive(self, entries, path=""):
"""
Extrait récursivement tous les fichiers d'une structure de dossiers AllDebrid.
Args:
entries: Liste d'entrées (fichiers ou dossiers)
path: Chemin du dossier parent (pour logging)
Returns:
Liste de dicts avec {link, filename, size}
"""
files = []
for entry in entries:
name = entry.get('n', '')
# Si l'entrée a un lien 'l', c'est un fichier
if 'l' in entry:
files.append({
'link': entry['l'],
'filename': f"{path}/{name}" if path else name,
'size': entry.get('s', 0)
})
# Si l'entrée a des sous-entrées 'e', c'est un dossier
elif 'e' in entry:
# Récursion dans le sous-dossier
new_path = f"{path}/{name}" if path else name
files.extend(self._extract_files_recursive(entry['e'], new_path))
return files
async def _delete_magnets(self, magnet_ids):
"""
Supprime des magnets spécifiques par leurs IDs.
Traite en petits lots pour éviter le rate-limiting AllDebrid.
"""
if not magnet_ids:
return
logging.info(f"Cleanup: Deleting {len(magnet_ids)} magnets we uploaded")
delete_url = f"{self.base_url}/magnet/delete"
batch_size = 5
failed_ids = []
success_count = 0
async with aiohttp.ClientSession(trust_env=True) as session:
try:
for i in range(0, len(magnet_ids), batch_size):
batch = magnet_ids[i:i + batch_size]
async def _delete_one(mid):
data = {
"agent": self.agent,
"apikey": self.api_key,
"id": mid
}
async with session.post(delete_url, data=data) as resp:
if resp.status == 200:
js = await resp.json()
if js.get('status') == 'success':
return True
logging.warning(f"Cleanup: Delete magnet {mid} failed: HTTP {resp.status}, response={js}")
else:
logging.warning(f"Cleanup: Delete magnet {mid} failed: HTTP {resp.status}")
return False
results = await asyncio.gather(*[_delete_one(mid) for mid in batch], return_exceptions=True)
for mid, res in zip(batch, results):
if isinstance(res, Exception):
logging.warning(f"Cleanup: Delete magnet {mid} exception: {res}")
failed_ids.append(mid)
elif res:
success_count += 1
else:
failed_ids.append(mid)
# Petite pause entre les lots pour ne pas rate-limit
if i + batch_size < len(magnet_ids):
await asyncio.sleep(0.5)
# Retry les échecs une fois
if failed_ids:
logging.info(f"Cleanup: Retrying {len(failed_ids)} failed deletions...")
await asyncio.sleep(1)
for mid in failed_ids:
try:
data = {
"agent": self.agent,
"apikey": self.api_key,
"id": mid
}
async with session.post(delete_url, data=data) as resp:
if resp.status == 200:
js = await resp.json()
if js.get('status') == 'success':
success_count += 1
await asyncio.sleep(0.3)
except:
pass
logging.info(f"Cleanup: Deleted {success_count}/{len(magnet_ids)} magnets")
except Exception as e:
logging.error(f"Cleanup Error: {e}")
async def check_availability(self, hashes):
"""
Vérifie la disponibilité en UPLOADANT les magnets.
Supprime ensuite uniquement les magnets qu'on a uploadé (pas ceux pré-existants).
"""
if not hashes:
return {}
# Nettoyage des hashs avant envoi
cleaned_hashes = []
for h in hashes:
cleaned = self._clean_hash(h)
if cleaned:
cleaned_hashes.append(cleaned)
if not cleaned_hashes:
return {}
# Découpage en lots
batch_size = 20
all_availability = {}
uploaded_ids = [] # Track des IDs qu'on a uploadé nous-mêmes
logging.info(f"Checking availability via UPLOAD for {len(cleaned_hashes)} hashes")
for i in range(0, len(cleaned_hashes), batch_size):
batch = cleaned_hashes[i:i + batch_size]
url = f"{self.base_url}/magnet/upload"
data = {
"agent": self.agent,
"apikey": self.api_key,
"magnets[]": batch
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.post(url, data=data) as response:
if response.status == 200:
resp_json = await response.json()
if i == 0:
logging.info(f"DEBUG AD Response (First Batch Sample): {json.dumps(resp_json)[:1000]}")
if resp_json.get('status') == 'success':
magnets_data = resp_json.get('data', {}).get('magnets', [])
instant_count = 0
for m in magnets_data:
h = m.get('hash') or m.get('magnet')
# Collecter l'ID pour suppression ultérieure
magnet_id = m.get('id')
if magnet_id:
uploaded_ids.append(magnet_id)
is_ready = m.get('ready', False)
status_code = m.get('statusCode')
if not is_ready and status_code == 4:
is_ready = True
if h:
h_clean = self._clean_hash(h)
all_availability[h_clean] = is_ready
if h != h_clean:
all_availability[h] = is_ready
if is_ready:
instant_count += 1
logging.info(f"Batch {i//batch_size + 1}: {instant_count} ready / {len(batch)} uploaded")
else:
logging.warning(f"AllDebrid Upload Error: {resp_json.get('error')}")
else:
logging.warning(f"AllDebrid Upload HTTP Error: {response.status}")
except Exception as e:
logging.error(f"Erreur AllDebrid Upload Batch {i}: {e}")
# Suppression en arrière-plan — ne bloque pas le retour des résultats
if uploaded_ids:
asyncio.create_task(self._delete_magnets(uploaded_ids))
return all_availability
async def unlock_magnet(self, magnet_hash, season=None, episode=None, media_type=None):
"""
Upload magnet -> Get link -> Unlock
"""
# Nettoyage hash
magnet_hash = self._clean_hash(magnet_hash)
logging.info(f"🔓 AD unlock_magnet: hash={magnet_hash}, S{season}E{episode}, type={media_type}")
async with aiohttp.ClientSession(trust_env=True) as session:
# 1. Upload Magnet
upload_url = f"{self.base_url}/magnet/upload"
params = {
"agent": self.agent,
"apikey": self.api_key,
"magnets[]": magnet_hash
}
logging.info(f"📤 AD Uploading to {upload_url}")
try:
async with session.post(upload_url, data=params) as resp:
data = await resp.json()
logging.info(f"📤 AD Upload response: {json.dumps(data)[:500]}")
if data.get('status') != 'success':
logging.error(f"❌ AD Upload Failed: {data}")
return None
magnets = data.get('data', {}).get('magnets', [])
if not magnets:
logging.error(f"❌ AD No magnets in response")
return None
magnet_info = magnets[0]
magnet_id = magnet_info['id']
is_ready = magnet_info.get('ready', False)
has_links = 'links' in magnet_info and magnet_info['links']
logging.info(f"✅ AD Magnet uploaded: id={magnet_id}, ready={is_ready}, has_links={has_links}")
# Si ready, on a les liens
if is_ready and has_links:
logging.info(f"⚡ AD Instant ready with {len(magnet_info['links'])} links")
target_link = self._select_link(magnet_info['links'], season, episode, media_type)
if target_link:
logging.info(f"🔓 AD Unlocking instant link...")
unlocked = await self._unlock_link(session, target_link)
if unlocked:
logging.info(f"✅ AD Instant unlock successful")
return unlocked
else:
logging.error(f"❌ AD No suitable file selected from instant links")
except Exception as e:
logging.error(f"❌ Exception AD Upload: {e}")
logging.error(traceback.format_exc())
return None
# 2. Get Files (l'API v4.1 utilise /magnet/files)
logging.info(f"📊 AD Fetching files for magnet_id={magnet_id}")
files_url = f"{self.base_url}/magnet/files"
# L'API /magnet/files attend un POST avec id[] (peut être un array)
post_data = {
"agent": self.agent,
"apikey": self.api_key,
"id[]": [magnet_id]
}
try:
async with session.post(files_url, data=post_data) as resp:
data = await resp.json()
logging.info(f"📊 AD Files response: {json.dumps(data)[:800]}")
if data.get('status') != 'success':
logging.error(f"❌ AD Files failed: {data}")
return None
# L'API retourne data.magnets qui est une liste
magnets_list = data.get('data', {}).get('magnets', [])
if not magnets_list:
logging.error(f"❌ AD No magnets in files response")
return None
# Trouver notre magnet par ID
magnet_data = None
for m in magnets_list:
if str(m.get('id')) == str(magnet_id):
magnet_data = m
break
if not magnet_data:
logging.error(f"❌ AD Magnet {magnet_id} not found in response")
return None
# Vérifier si une erreur est retournée pour ce magnet
if 'error' in magnet_data:
logging.error(f"❌ AD Magnet error: {magnet_data['error']}")
return None
# Extraire récursivement tous les fichiers
files_structure = magnet_data.get('files', [])
if not files_structure:
logging.error(f"❌ AD No files in magnet data")
return None
links = self._extract_files_recursive(files_structure)
if not links:
logging.error(f"❌ AD No files extracted from structure")
return None
logging.info(f"🔗 AD Extracted {len(links)} files from recursive structure")
target_link = self._select_link(links, season, episode, media_type)
if not target_link:
logging.error(f"❌ AD No suitable file selected")
return None
# Les liens de /magnet/files doivent encore être unlock pour obtenir le lien direct
logging.info(f"🔓 AD Unlocking file link...")
unlocked = await self._unlock_link(session, target_link)
if unlocked:
logging.info(f"✅ AD Unlocked successfully")
return unlocked
except Exception as e:
logging.error(f"❌ Exception AD Files: {e}")
logging.error(traceback.format_exc())
return None
return None
def _select_link(self, links, season, episode, media_type):
"""Sélectionne le bon fichier dans le torrent"""
if not links:
logging.error(f"❌ AD _select_link: No links provided")
return None
logging.info(f"🎯 AD Selecting file for S{season}E{episode} (type={media_type}) among {len(links)} files")
# Si épisode spécifique
if season is not None and episode is not None:
# Patterns pour S01E01, 1x01, etc.
s_str = f"{int(season):02d}"
e_str = f"{int(episode):02d}"
patterns = [
f"S{s_str}E{e_str}", # S01E01
f"{int(season)}x{e_str}", # 1x01
f"S{int(season)}E{e_str}", # S1E01
f"S{s_str}.E{e_str}" # S01.E01
]
for link in links:
filename = link.get('filename', '').upper()
for pat in patterns:
if pat.upper() in filename:
logging.info(f"Match found: {filename} (Pattern: {pat})")
return link['link']
logging.warning(f"No strict match found for S{season}E{episode}. Files available: {[l.get('filename') for l in links[:5]]}...")
# Filtrage par extension (Vidéos uniquement)
video_extensions = ('.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts', '.vob')
bad_extensions = ('.iso', '.pdf', '.epub', '.txt', '.nfo', '.jpg', '.jpeg', '.png', '.rar', '.zip')
video_links = [l for l in links if any(l.get('filename', '').lower().endswith(ext) for ext in video_extensions)]
# Si aucun lien avec extension vidéo, on exclut au moins les extensions interdites
if not video_links:
video_links = [lnk for lnk in links if not any(lnk.get('filename', '').lower().endswith(ext) for ext in bad_extensions)]
if not video_links:
logging.error(f"❌ AD _select_link: No video files found in torrent")
return None
# Si Film ou pas trouvé par pattern, on prend le plus gros fichier parmi les vidéos
sorted_links = sorted(video_links, key=lambda x: x.get('size', 0), reverse=True)
best_link = sorted_links[0]
logging.info(f"Fallback/Movie: Selected largest video: {best_link.get('filename')} ({best_link.get('size')} bytes)")
return best_link['link']
async def _unlock_link(self, session, link):
unlock_url = f"{self.base_url}/link/unlock"
params = {
"agent": self.agent,
"apikey": self.api_key,
"link": link
}
logging.info(f"🔐 AD Unlocking link: {link[:80]}...")
try:
async with session.get(unlock_url, params=params) as resp:
data = await resp.json()
logging.info(f"🔐 AD Unlock response: {json.dumps(data)[:300]}")
if data.get('status') == 'success':
unlocked = data['data']['link']
logging.info(f"✅ AD Successfully unlocked: {unlocked[:80]}...")
return unlocked
else:
logging.error(f"❌ AD Unlock failed: {data}")
except Exception as e:
logging.error(f"❌ Exception AD Unlock: {e}")
logging.error(traceback.format_exc())
return None

104
services/apibay.py Normal file
View file

@ -0,0 +1,104 @@
"""
ThePirateBay via l'API publique apibay.org (JSON, pas de clé requise).
Bon complément international (VO) aux trackers français.
"""
import asyncio
import logging
import aiohttp
API_URL = "https://apibay.org/q.php"
# Catégories vidéo TPB : 200-299 (dont 201 films, 205 TV, 207 films HD, 208 TV HD)
def _is_video_category(cat):
try:
return 200 <= int(cat) < 300
except (TypeError, ValueError):
return False
class ApibayService:
async def _query(self, query):
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
API_URL,
params={"q": query},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
logging.warning(f"apibay HTTP {resp.status}")
return []
data = await resp.json(content_type=None)
except Exception as e:
logging.error(f"apibay error: {e}")
return []
if not isinstance(data, list):
return []
results = []
for item in data:
# apibay renvoie une entrée factice quand il n'y a aucun résultat
if item.get("id") == "0" or item.get("info_hash", "").strip("0") == "":
continue
if not _is_video_category(item.get("category")):
continue
results.append({
"name": item.get("name", ""),
"size": int(item.get("size", 0) or 0),
"tracker_name": "TPB",
"info_hash": (item.get("info_hash") or "").lower(),
"magnet": None,
"link": None,
"source": "apibay",
"seeders": int(item.get("seeders", 0) or 0),
"leechers": int(item.get("leechers", 0) or 0),
"imdb": item.get("imdb") or "",
})
return results
async def _search_many(self, queries, imdb_id=None):
results_list = await asyncio.gather(
*[self._query(q) for q in queries if q], return_exceptions=True
)
merged, seen = [], set()
for results in results_list:
if isinstance(results, Exception):
continue
for r in results:
# Si apibay connaît l'IMDB du torrent, on écarte les hors-sujet
if imdb_id and r.get("imdb") and r["imdb"] != imdb_id:
continue
ih = r["info_hash"]
if ih and ih not in seen:
seen.add(ih)
merged.append(r)
logging.info(f"apibay: {len(merged)} résultats")
return merged
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
queries = []
if imdb_id:
queries.append(imdb_id) # apibay indexe les IMDB IDs
if title:
queries.append(f"{title} {year}".strip())
if original_title and original_title != title:
queries.append(f"{original_title} {year}".strip())
return await self._search_many(queries, imdb_id=imdb_id)
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
queries = []
se = f"S{int(season):02d}E{int(episode):02d}" if season and episode is not None else ""
s_only = f"S{int(season):02d}" if season else ""
for t in dict.fromkeys([title, original_title]):
if not t:
continue
if se:
queries.append(f"{t} {se}")
if s_only:
queries.append(f"{t} {s_only}")
if imdb_id:
queries.append(imdb_id)
return await self._search_many(queries, imdb_id=imdb_id)

118
services/c411.py Normal file
View file

@ -0,0 +1,118 @@
import aiohttp
import logging
import urllib.parse
from utils import check_season_episode
class C411Service:
def __init__(self, apikey):
self.apikey = apikey
self.base_url = "https://c411.org/api"
async def search(self, params):
if not self.apikey:
return []
params['apikey'] = self.apikey
params['o'] = 'json'
# Log request (masking apikey)
log_params = params.copy()
log_params['apikey'] = '***APIKEY***'
logging.info(f"C411 Search: {self.base_url}?{urllib.parse.urlencode(log_params)}")
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(self.base_url, params=params, timeout=20) as response:
if response.status == 200:
data = await response.json()
channel = data.get('channel', {})
items = channel.get('item', [])
# Handle single item case (JSON conversion of XML sometimes makes single item an object instead of list)
if isinstance(items, dict):
items = [items]
logging.info(f"C411 found {len(items)} results")
normalized = []
for res in items:
# Extract torznab attributes
attrs = res.get('torznab:attr', [])
if isinstance(attrs, dict):
attrs = [attrs]
info_hash = None
seeders = 0
leechers = 0
for attr in attrs:
attr_data = attr.get('@attributes', {})
name = attr_data.get('name')
value = attr_data.get('value')
if name == 'infohash':
info_hash = value
elif name == 'seeders':
seeders = int(value) if value else 0
elif name == 'peers': # Some torznab use peers/leechers
leechers = int(value) if value else 0
# Fallback hash to guid if infohash not found (guid is often hash in torznab)
if not info_hash:
info_hash = res.get('guid')
enclosure = res.get('enclosure', {}).get('@attributes', {})
download_link = enclosure.get('url')
item = {
"name": res.get("title"),
"size": int(res.get("size", 0)),
"tracker_name": "C411",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "c411",
"seeders": seeders,
"leechers": leechers
}
normalized.append(item)
return normalized
else:
logging.warning(f"C411 Error {response.status}")
text = await response.text()
logging.warning(f"C411 Body: {text[:200]}")
except Exception as e:
logging.error(f"C411 Exception: {e}")
return []
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
params = {"t": "movie"}
if imdb_id:
if not str(imdb_id).startswith('tt'):
imdb_id = f"tt{imdb_id}"
params["imdbid"] = imdb_id
elif tmdb_id:
params["tmdbid"] = tmdb_id
else:
params["q"] = f"{title} {year}"
return await self.search(params)
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None):
params = {"t": "tvsearch"}
if imdb_id:
if not str(imdb_id).startswith('tt'):
imdb_id = f"tt{imdb_id}"
params["imdbid"] = imdb_id
elif tmdb_id:
params["tmdbid"] = tmdb_id
else:
params["q"] = title
# Torznab filters for season/episode if supported by tracker
if season is not None:
params["season"] = season
if episode is not None:
params["episode"] = episode
return await self.search(params)

224
services/debridlink.py Normal file
View file

@ -0,0 +1,224 @@
import aiohttp
import logging
import asyncio
import re
class DebridLinkService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://debrid-link.com/api/v2"
async def _list_existing_torrent_ids(self):
"""Récupère les IDs des torrents déjà présents sur le seedbox."""
headers = {
"Authorization": f"Bearer {self.api_key}",
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
list_url = f"{self.base_url}/seedbox/list"
async with session.get(list_url, headers=headers, timeout=10) as resp:
if resp.status != 200:
return set()
data = await resp.json()
if data.get('success'):
return {t.get('id') for t in data.get('value', []) if t.get('id')}
except Exception as e:
logging.error(f"DebridLink: Error listing existing torrents: {e}")
return set()
async def check_availability(self, hashes):
"""
Vérifie la disponibilité de plusieurs hash en parallèle.
Retourne un dict {hash: bool} indiquant si chaque hash est caché.
Ne supprime que les torrents qu'on a ajouté nous-mêmes (pas les pré-existants).
"""
if not hashes:
return {}
logging.info(f"DebridLink: Checking {len(hashes)} hashes in parallel")
# 1. Snapshot des torrents existants AVANT nos ajouts
existing_ids = await self._list_existing_torrent_ids()
logging.info(f"DebridLink: {len(existing_ids)} torrents already on seedbox")
# 2. Vérifier chaque hash (retourne (is_cached, torrent_id))
tasks = [self._check_single_hash(h) for h in hashes]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 3. Construire les résultats et collecter les IDs à supprimer
availability = {}
ids_to_delete = []
for hash_value, result in zip(hashes, results):
if isinstance(result, Exception):
logging.error(f"DebridLink: Error checking {hash_value}: {result}")
availability[hash_value.lower()] = False
else:
is_cached, torrent_id = result
availability[hash_value.lower()] = is_cached
# Ne supprimer que si c'est un torrent qu'on a ajouté nous-mêmes
if torrent_id and torrent_id not in existing_ids:
ids_to_delete.append(torrent_id)
cached_count = sum(1 for v in availability.values() if v)
logging.info(f"DebridLink: {cached_count}/{len(hashes)} hashes are cached")
# Cleanup en arrière-plan — ne bloque pas le retour des résultats
if ids_to_delete:
async def _cleanup():
headers = {"Authorization": f"Bearer {self.api_key}"}
async with aiohttp.ClientSession(trust_env=True) as session:
await asyncio.gather(
*[self._remove_torrent(session, headers, tid) for tid in ids_to_delete],
return_exceptions=True
)
asyncio.create_task(_cleanup())
return availability
async def _check_single_hash(self, hash_value):
"""
Vérifie un seul hash en l'ajoutant au seedbox.
Retourne (is_cached, torrent_id) pour que le caller gère le cleanup.
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
add_url = f"{self.base_url}/seedbox/add"
async with aiohttp.ClientSession(trust_env=True) as session:
try:
payload = {
"url": hash_value,
"wait": False
}
async with session.post(add_url, json=payload, headers=headers, timeout=10) as resp:
if resp.status != 200:
logging.warning(f"DebridLink: Failed to add {hash_value[:8]}... status {resp.status}")
return (False, None)
data = await resp.json()
if not data.get('success'):
logging.debug(f"DebridLink: {hash_value[:8]}... not successful")
return (False, None)
torrent = data.get('value', {})
torrent_id = torrent.get('id')
download_percent = torrent.get('downloadPercent', 0)
error = torrent.get('error', 0)
is_cached = error == 0 and download_percent == 100
if is_cached:
logging.debug(f"DebridLink: {hash_value[:8]}... cached!")
else:
logging.debug(f"DebridLink: {hash_value[:8]}... not cached")
return (is_cached, torrent_id)
except asyncio.TimeoutError:
logging.warning(f"DebridLink: Timeout checking {hash_value[:8]}...")
return (False, None)
except Exception as e:
logging.error(f"DebridLink: Exception checking {hash_value[:8]}...: {e}")
return (False, None)
async def _remove_torrent(self, session, headers, torrent_id):
"""Supprime un torrent du seedbox"""
try:
remove_url = f"{self.base_url}/seedbox/{torrent_id}/remove"
async with session.delete(remove_url, headers=headers, timeout=5) as resp:
if resp.status == 200:
logging.debug(f"DebridLink: Removed torrent {torrent_id}")
else:
logging.warning(f"DebridLink: Failed to remove {torrent_id}: {resp.status}")
except Exception as e:
logging.error(f"DebridLink: Error removing {torrent_id}: {e}")
async def unlock_magnet(self, info_hash, season=None, episode=None, media_type=None):
"""
Déverrouille un magnet et retourne l'URL de streaming
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
add_url = f"{self.base_url}/seedbox/add"
async with aiohttp.ClientSession(trust_env=True) as session:
try:
# Ajouter le torrent
payload = {
"url": info_hash,
"wait": False
}
async with session.post(add_url, json=payload, headers=headers, timeout=15) as resp:
if resp.status != 200:
logging.error(f"DebridLink: Failed to add torrent: {resp.status}")
return None
data = await resp.json()
if not data.get('success'):
logging.error("DebridLink: Add torrent failed")
return None
torrent = data.get('value', {})
torrent_id = torrent.get('id')
files = torrent.get('files', [])
if not files:
logging.error("DebridLink: No files in torrent")
return None
# Sélectionner le bon fichier
selected_file = None
if season is not None and episode is not None:
patterns = [
rf"S{season:02d}E{episode:02d}",
rf"S{season}E{episode:02d}",
rf"{season}x{episode:02d}",
rf"{season}x{episode}\b",
]
for f in files:
filename = f.get('name', '')
if any(re.search(p, filename, re.IGNORECASE) for p in patterns):
selected_file = f
break
else:
# Film : prendre le plus gros fichier vidéo
video_extensions = ('.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts')
video_files = [f for f in files if f.get('name', '').lower().endswith(video_extensions)]
if video_files:
selected_file = max(video_files, key=lambda x: x.get('size', 0))
else:
# Fallback si pas d'extension trouvée (nom sans extension ?)
# On exclut au moins les fichiers connus pour ne pas être des vidéos
bad_extensions = ('.iso', '.pdf', '.epub', '.txt', '.nfo', '.rar', '.zip')
filtered_files = [f for f in files if not f.get('name', '').lower().endswith(bad_extensions)]
if filtered_files:
selected_file = max(filtered_files, key=lambda x: x.get('size', 0))
if selected_file:
download_url = selected_file.get('downloadUrl')
if download_url:
logging.info(f"DebridLink: Stream URL found for torrent {torrent_id}")
return download_url
logging.error("DebridLink: Could not find suitable file")
return None
except Exception as e:
logging.error(f"DebridLink: Exception in unlock_magnet: {e}")
return None

79
services/eztv.py Normal file
View file

@ -0,0 +1,79 @@
"""
EZTV via son API publique (recherche par IMDB ID, séries uniquement).
"""
import logging
import aiohttp
API_URL = "https://eztvx.to/api/get-torrents"
FALLBACK_URLS = ["https://eztv.re/api/get-torrents", "https://eztv1.xyz/api/get-torrents"]
class EztvService:
async def _fetch(self, imdb_numeric, page=1):
params = {"imdb_id": imdb_numeric, "limit": 100, "page": page}
for url in [API_URL] + FALLBACK_URLS:
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status != 200:
continue
data = await resp.json(content_type=None)
return data.get("torrents") or []
except Exception as e:
logging.debug(f"EZTV {url} error: {e}")
continue
logging.warning("EZTV: tous les miroirs ont échoué")
return []
@staticmethod
def _normalize(item):
return {
"name": item.get("title", "").replace(" EZTV", ""),
"size": int(item.get("size_bytes", 0) or 0),
"tracker_name": "EZTV",
"info_hash": (item.get("hash") or "").lower(),
"magnet": item.get("magnet_url"),
"link": item.get("magnet_url"),
"source": "eztv",
"seeders": int(item.get("seeds", 0) or 0),
"leechers": int(item.get("peers", 0) or 0),
"_season": int(item.get("season", 0) or 0),
"_episode": int(item.get("episode", 0) or 0),
}
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
# EZTV n'indexe que des séries
return []
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
if not imdb_id:
return []
imdb_numeric = str(imdb_id).replace("tt", "").lstrip("0")
if not imdb_numeric:
return []
torrents = await self._fetch(imdb_numeric)
if len(torrents) == 100:
torrents += await self._fetch(imdb_numeric, page=2)
results = []
for item in torrents:
r = self._normalize(item)
if not r["info_hash"]:
continue
# episode 0 = pack de saison ; sinon match exact via les champs de l'API
if season is not None:
if r["_season"] != int(season):
continue
if episode is not None and r["_episode"] not in (0, int(episode)):
continue
r.pop("_season", None)
r.pop("_episode", None)
results.append(r)
logging.info(f"EZTV: {len(results)} résultats")
return results

154
services/nekobt.py Normal file
View file

@ -0,0 +1,154 @@
import aiohttp
import logging
import urllib.parse
import xml.etree.ElementTree as ET
import re
class NekoBTService:
def __init__(self, apikey):
self.apikey = apikey
self.base_url = "https://nekobt.to/api/torznab/api"
async def search(self, params):
if not self.apikey:
return []
params['apikey'] = self.apikey
params['cat'] = '5070' # Anime category for NekoBT
params['t'] = 'search' # Force generic search since NekoBT doesn't handle tmdbid well
# Log request (masking apikey)
log_params = params.copy()
log_params['apikey'] = '***APIKEY***'
logging.info(f"NekoBT Search: {self.base_url}?{urllib.parse.urlencode(log_params)}")
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(self.base_url, params=params, timeout=20) as response:
if response.status == 200:
text = await response.text()
try:
root = ET.fromstring(text)
except ET.ParseError as e:
logging.error(f"NekoBT XML Parse Error: {e}")
return []
items = root.findall('.//item')
logging.info(f"NekoBT found {len(items)} results")
normalized = []
for res in items:
title = res.findtext('title')
description = res.findtext('description', default='')
# Filtre strict FR : on rejette tout ce qui n'a pas de tag FR/VF/VOSTFR dans le titre ou la description
title_upper = (title or "").upper()
desc_upper = description.upper()
has_fr_in_title = any(kw in title_upper for kw in ["FRENCH", "VOSTFR", "SUBFRENCH", "TRUEFRENCH", "VFF", "VF2", "VFQ"]) or re.search(r'\bVF\b', title_upper) or re.search(r'\bFR\b', title_upper)
has_fr_in_desc = any(kw in desc_upper for kw in ["FRENCH", "FRANCAIS", "FRANÇAIS", "VOSTFR"]) or re.search(r'\bVF\b', desc_upper) or re.search(r'\bFR\b', desc_upper)
if not has_fr_in_title and not has_fr_in_desc:
continue # Skip this result as it has no French/VOSTFR
torznab_ns = {'torznab': 'http://torznab.com/schemas/2015/feed'}
info_hash = None
seeders = 0
leechers = 0
size = int(res.findtext('size', default='0'))
for attr in res.findall('torznab:attr', namespaces=torznab_ns):
name = attr.get('name')
value = attr.get('value')
if name == 'infohash':
info_hash = value
elif name == 'seeders':
seeders = int(value) if value else 0
elif name == 'peers':
leechers = int(value) if value else 0
elif name == 'size' and size == 0:
size = int(value) if value else 0
# Fallback si infohash introuvable
if not info_hash:
guid = res.findtext('guid')
if guid and len(guid) >= 40: # Extrait l'infohash d'une string si c'est un magnet ou un hash brut
match = re.search(r'([a-fA-F0-9]{40})', guid)
if match:
info_hash = match.group(1)
if not info_hash:
continue
enclosure = res.find('enclosure')
download_link = enclosure.get('url') if enclosure is not None else None
if not download_link:
download_link = res.findtext('link')
item = {
"name": title,
"size": size,
"tracker_name": "NekoBT",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "nekobt",
"seeders": seeders,
"leechers": leechers
}
normalized.append(item)
return normalized
else:
logging.warning(f"NekoBT Error {response.status}")
except Exception as e:
logging.error(f"NekoBT Exception: {e}")
return []
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
params = {"q": f"{title} {year}"}
return await self.search(params)
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None, absolute_episode=None):
# Méthode inspirée de UwU-FR : Lancer plusieurs requêtes en parallèle pour contourner
# la limite de 50/100 résultats de l'API NekoBT et trouver les vieux épisodes/packs.
queries = [title]
if season is not None and episode is not None:
queries.append(f"{title} S{season:02d}E{episode:02d}")
queries.append(f"{title} {episode:02d}")
queries.append(f"{title} S{season:02d}")
queries.append(f"{title} Saison {season}")
queries.append(f"{title} Integrale")
# Numérotation absolue anime (ex: "One Piece 1122", "One Piece S01E1122")
if absolute_episode is not None and absolute_episode != episode:
queries.append(f"{title} {absolute_episode:02d}")
queries.append(f"{title} S01E{absolute_episode:02d}")
# OVA / Spéciaux (saison 0) : nommage fansub sans SxxExx
if season == 0 and episode is not None:
queries.append(f"{title} OVA")
queries.append(f"{title} OAV")
queries.append(f"{title} Special")
queries.append(f"{title} OVA {episode:02d}")
all_results = []
seen_hashes = set()
# Lancement étalé (300ms) pour ne pas déclencher de rate-limit
import asyncio
tasks = []
for q in dict.fromkeys(queries):
tasks.append(asyncio.create_task(self.search({"q": q, "limit": "100"})))
await asyncio.sleep(0.3)
results_list = await asyncio.gather(*tasks, return_exceptions=True)
for res in results_list:
if isinstance(res, list):
for item in res:
info_hash = item.get('info_hash')
if info_hash and info_hash not in seen_hashes:
seen_hashes.add(info_hash)
all_results.append(item)
return all_results

148
services/nyaa.py Normal file
View file

@ -0,0 +1,148 @@
import aiohttp
import asyncio
import logging
import urllib.parse
import xml.etree.ElementTree as ET
import re
class NyaaService:
def __init__(self):
self.base_url = "https://nyaa.si/"
async def search(self, params, max_attempts=3):
params['page'] = 'rss'
params['c'] = '0_0' # All categories
params['f'] = '0' # No filter
logging.info(f"Nyaa Search: {self.base_url}?{urllib.parse.urlencode(params)}")
async with aiohttp.ClientSession(trust_env=True) as session:
try:
for attempt in range(max_attempts):
# Nyaa throttle vite (429 dès ~8 requêtes simultanées) : backoff + retry
if attempt > 0:
await asyncio.sleep(1.0 * attempt)
async with session.get(self.base_url, params=params, timeout=20) as response:
if response.status == 429:
logging.warning(f"Nyaa 429 rate-limit (tentative {attempt + 1}/{max_attempts})")
continue
if response.status != 200:
logging.warning(f"Nyaa Error {response.status}")
return []
text = await response.text()
try:
root = ET.fromstring(text)
except ET.ParseError as e:
logging.error(f"Nyaa XML Parse Error: {e}")
return []
items = root.findall('.//item')
logging.info(f"Nyaa found {len(items)} results")
normalized = []
for res in items:
title = res.findtext('title')
description = res.findtext('description', default='')
# Filtre strict FR : on rejette tout ce qui n'a pas de tag FR/VF/VOSTFR dans le titre ou la description
title_upper = (title or "").upper()
desc_upper = description.upper()
has_fr_in_title = any(kw in title_upper for kw in ["FRENCH", "VOSTFR", "SUBFRENCH", "TRUEFRENCH", "VFF", "VF2", "VFQ"]) or re.search(r'\bVF\b', title_upper) or re.search(r'\bFR\b', title_upper)
has_fr_in_desc = any(kw in desc_upper for kw in ["FRENCH", "FRANCAIS", "FRANÇAIS", "VOSTFR"]) or re.search(r'\bVF\b', desc_upper) or re.search(r'\bFR\b', desc_upper)
if not has_fr_in_title and not has_fr_in_desc:
continue # Skip this result as it has no French/VOSTFR
# Nyaa custom namespace for properties
nyaa_ns = {'nyaa': 'https://nyaa.si/xmlns/nyaa'}
seeders = res.findtext('nyaa:seeders', namespaces=nyaa_ns)
leechers = res.findtext('nyaa:leechers', namespaces=nyaa_ns)
size_str = res.findtext('nyaa:size', namespaces=nyaa_ns) # usually like "1.4 GiB"
info_hash = res.findtext('nyaa:infoHash', namespaces=nyaa_ns)
download_link = res.findtext('link')
# Convert size string to bytes roughly (Frenchio uses size in bytes)
size_bytes = 0
if size_str:
try:
parts = size_str.strip().split()
if len(parts) == 2:
val = float(parts[0])
unit = parts[1].lower()
if 'gib' in unit or 'gb' in unit:
size_bytes = int(val * 1024 * 1024 * 1024)
elif 'mib' in unit or 'mb' in unit:
size_bytes = int(val * 1024 * 1024)
elif 'kib' in unit or 'kb' in unit:
size_bytes = int(val * 1024)
else:
size_bytes = int(val)
except Exception:
size_bytes = 0
if not info_hash:
continue
item = {
"name": title,
"size": size_bytes,
"tracker_name": "Nyaa",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "nyaa",
"seeders": int(seeders) if seeders else 0,
"leechers": int(leechers) if leechers else 0
}
normalized.append(item)
return normalized
logging.warning(f"Nyaa: abandon après {max_attempts} tentatives (rate-limit)")
except Exception as e:
logging.error(f"Nyaa Exception: {e}")
return []
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
params = {"q": f"{title} {year}"}
return await self.search(params)
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None, absolute_episode=None):
queries = [title]
if season is not None and episode is not None:
queries.append(f"{title} S{season:02d}E{episode:02d}")
queries.append(f"{title} {episode:02d}")
queries.append(f"{title} S{season:02d}")
queries.append(f"{title} Saison {season}")
queries.append(f"{title} Integrale")
# Numérotation absolue anime (ex: "One Piece 1122", "One Piece S01E1122")
if absolute_episode is not None and absolute_episode != episode:
queries.append(f"{title} {absolute_episode:02d}")
queries.append(f"{title} S01E{absolute_episode:02d}")
# OVA / Spéciaux (saison 0) : nommage fansub sans SxxExx
if season == 0 and episode is not None:
queries.append(f"{title} OVA")
queries.append(f"{title} OAV")
queries.append(f"{title} Special")
queries.append(f"{title} OVA {episode:02d}")
all_results = []
seen_hashes = set()
# Lancement étalé (300ms) pour ne pas déclencher le rate-limit de Nyaa
tasks = []
for q in dict.fromkeys(queries):
tasks.append(asyncio.create_task(self.search({"q": q})))
await asyncio.sleep(0.3)
results_list = await asyncio.gather(*tasks, return_exceptions=True)
for res in results_list:
if isinstance(res, list):
for item in res:
info_hash = item.get('info_hash')
if info_hash and info_hash not in seen_hashes:
seen_hashes.add(info_hash)
all_results.append(item)
return all_results

222
services/realdebrid.py Normal file
View file

@ -0,0 +1,222 @@
import aiohttp
import logging
import asyncio
class RealDebridService:
"""
Service Real-Debrid via StremThru proxy.
StremThru gère le rate-limiting et fournit une API unifiée
avec un endpoint check_magnets en batch (pas de rate-limit).
"""
STREMTHRU_URL = "https://stremthru.13377001.xyz"
def __init__(self, api_key):
self.api_key = api_key
self.base_url = self.STREMTHRU_URL
self.headers = {
"X-StremThru-Store-Name": "realdebrid",
"X-StremThru-Store-Authorization": f"Bearer {api_key}",
}
async def check_availability(self, hashes):
"""
Vérifie la disponibilité via GET /v0/store/magnets/check.
StremThru supporte le batch check en un seul appel pas de rate-limit.
Retourne un dict {hash: bool}.
"""
if not hashes:
return {}
all_availability = {}
batch_size = 50
logging.info(f"RD (StremThru): Checking availability for {len(hashes)} hashes")
async with aiohttp.ClientSession(trust_env=True) as session:
for i in range(0, len(hashes), batch_size):
batch = hashes[i:i + batch_size]
params = [("magnet", f"magnet:?xt=urn:btih:{h}") for h in batch]
try:
url = f"{self.base_url}/v0/store/magnets/check"
async with session.get(url, headers=self.headers, params=params) as resp:
if resp.status == 200:
result = await resp.json()
items = result.get("data", {}).get("items", [])
for item in items:
h = item.get("hash", "").lower()
status = item.get("status", "")
is_cached = status == "cached"
all_availability[h] = is_cached
else:
text = await resp.text()
logging.warning(f"RD (StremThru) check failed: HTTP {resp.status} - {text[:300]}")
for h in batch:
all_availability[h.lower()] = False
except Exception as e:
logging.error(f"RD (StremThru) check exception: {e}")
for h in batch:
all_availability[h.lower()] = False
if i + batch_size < len(hashes):
await asyncio.sleep(0.2)
instant_count = sum(1 for v in all_availability.values() if v)
logging.info(f"RD (StremThru): {instant_count}/{len(hashes)} cached")
return all_availability
async def unlock_magnet(self, magnet_hash, season=None, episode=None, media_type=None):
"""
Déverrouille un magnet via StremThru :
1. POST /v0/store/magnets (add magnet)
2. GET /v0/store/magnets/{id} (get files)
3. POST /v0/store/link/generate (generate download link)
"""
magnet_hash = magnet_hash.strip().lower()
logging.info(f"🔓 RD (StremThru) unlock: hash={magnet_hash[:16]}..., S{season}E{episode}, type={media_type}")
async with aiohttp.ClientSession(trust_env=True) as session:
# 1. Ajouter le magnet
magnet_link = f"magnet:?xt=urn:btih:{magnet_hash}"
add_url = f"{self.base_url}/v0/store/magnets"
try:
async with session.post(add_url, headers=self.headers, json={"magnet": magnet_link}) as resp:
if resp.status not in (200, 201):
text = await resp.text()
logging.error(f"❌ RD (StremThru) add magnet failed: HTTP {resp.status} - {text[:300]}")
return None
result = await resp.json()
magnet_data = result.get("data", {})
magnet_id = magnet_data.get("id")
status = magnet_data.get("status", "")
files = magnet_data.get("files", [])
logging.info(f"✅ RD (StremThru) magnet added: id={magnet_id}, status={status}, files={len(files)}")
except Exception as e:
logging.error(f"❌ RD (StremThru) add magnet exception: {e}")
return None
# 2. Si pas encore de fichiers ou status non final, attendre
if not files or status not in ("downloaded", "cached"):
max_wait = 15
poll_interval = 1.5
elapsed = 0
while elapsed < max_wait:
try:
info_url = f"{self.base_url}/v0/store/magnets/{magnet_id}"
async with session.get(info_url, headers=self.headers) as resp:
if resp.status == 200:
result = await resp.json()
magnet_data = result.get("data", {})
status = magnet_data.get("status", "")
files = magnet_data.get("files", [])
if status in ("downloaded", "cached") and files:
logging.info(f"✅ RD (StremThru) ready: {len(files)} files")
break
elif status in ("failed", "invalid"):
logging.error(f"❌ RD (StremThru) magnet failed: status={status}")
return None
logging.info(f"⏳ RD (StremThru) waiting: status={status}")
else:
logging.warning(f"RD (StremThru) info failed: HTTP {resp.status}")
except Exception as e:
logging.error(f"RD (StremThru) info exception: {e}")
await asyncio.sleep(poll_interval)
elapsed += poll_interval
if not files:
logging.error(f"❌ RD (StremThru) no files after {max_wait}s")
return None
# 3. Sélectionner le bon fichier
target_file = self._select_best_file(files, season, episode, media_type)
if not target_file:
logging.error("❌ RD (StremThru) no suitable file found")
return None
file_link = target_file.get("link")
if not file_link:
logging.error("❌ RD (StremThru) file has no link")
return None
logging.info(f"🎯 RD (StremThru) selected: {target_file.get('name')} ({target_file.get('size', 0)} bytes)")
# 4. Générer le lien de téléchargement
try:
gen_url = f"{self.base_url}/v0/store/link/generate"
async with session.post(gen_url, headers=self.headers, json={"link": file_link}) as resp:
if resp.status == 200:
result = await resp.json()
download_link = result.get("data", {}).get("link")
if download_link:
logging.info(f"✅ RD (StremThru) link generated successfully")
return download_link
else:
logging.error("❌ RD (StremThru) no link in generate response")
else:
text = await resp.text()
logging.error(f"❌ RD (StremThru) generate link failed: HTTP {resp.status} - {text[:300]}")
except Exception as e:
logging.error(f"❌ RD (StremThru) generate link exception: {e}")
return None
def _select_best_file(self, files, season=None, episode=None, media_type=None):
"""
Sélectionne le meilleur fichier parmi la liste StremThru.
Format: [{index, link, name, path, size}, ...]
"""
if not files:
return None
video_extensions = ('.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts', '.vob')
bad_extensions = ('.iso', '.pdf', '.epub', '.txt', '.nfo', '.jpg', '.jpeg', '.png', '.rar', '.zip')
video_files = [f for f in files if any(
f.get('name', '').lower().endswith(ext) for ext in video_extensions
)]
if not video_files:
# Fallback : exclure les extensions interdites si aucune vidéo n'est trouvée
video_files = [f for f in files if not any(
f.get('name', '').lower().endswith(ext) for ext in bad_extensions
)]
if not video_files:
logging.error("RD (StremThru): No video files found in torrent")
return None
if season is not None and episode is not None:
s_str = f"{int(season):02d}"
e_str = f"{int(episode):02d}"
patterns = [
f"S{s_str}E{e_str}",
f"{int(season)}x{e_str}",
f"S{int(season)}E{e_str}",
f"S{s_str}.E{e_str}"
]
for f in video_files:
name = (f.get('name', '') or f.get('path', '')).upper()
for pat in patterns:
if pat.upper() in name:
logging.info(f"RD (StremThru): Episode match: {f.get('name')} (pattern: {pat})")
return f
logging.warning(f"RD (StremThru): No episode match for S{season}E{episode}")
best = max(video_files, key=lambda x: x.get('size', 0))
logging.info(f"RD (StremThru): Selected largest: {best.get('name')} ({best.get('size', 0)} bytes)")
return best

268
services/stremthru.py Normal file
View file

@ -0,0 +1,268 @@
import aiohttp
import asyncio
import base64
import binascii
import json
import logging
# Stores supportés par StremThru et mapping vers les clés de config Frenchio
STREMTHRU_STORES = {
"alldebrid": "alldebrid_key",
"torbox": "torbox_key",
"debridlink": "debridlink_key",
"realdebrid": "realdebrid_key",
}
class StremThruService:
"""
Proxy les appels à l'API du débrideur via une instance StremThru
(https://github.com/MunifTanjim/stremthru).
Utile quand l'IP du serveur Frenchio est bloquée par le débrideur
(ex: AllDebrid refuse les IP de datacenter). StremThru expose une API
"store" unifiée : c'est lui qui parle au débrideur, avec sa propre IP.
Interface identique aux services natifs : check_availability + unlock_magnet.
"""
# Statuts de magnet StremThru considérés comme lisibles immédiatement
READY_STATUSES = ("cached", "downloaded")
def __init__(self, url, store_name, api_key, auth=None, client_ip=None):
self.base_url = url.rstrip('/') + "/v0/store"
self.store_name = store_name
self.api_key = api_key
self.auth = auth # "user:pass" si l'instance est protégée (STREMTHRU_AUTH)
self.client_ip = client_ip
def _headers(self):
headers = {
"X-StremThru-Store-Name": self.store_name,
"X-StremThru-Store-Authorization": f"Bearer {self.api_key}",
"User-Agent": "frenchio",
}
if self.auth:
encoded = base64.b64encode(self.auth.encode('utf-8')).decode('ascii')
headers["Proxy-Authorization"] = f"Basic {encoded}"
return headers
def _params(self, extra=None):
params = dict(extra or {})
if self.client_ip:
params["client_ip"] = self.client_ip
return params
def _clean_hash(self, hash_str):
"""Nettoie le hash (même logique que les services natifs)."""
if not hash_str:
return None
clean = hash_str.strip().lower()
# Certains trackers renvoient le hash hex-encodé deux fois (80 chars)
if len(clean) == 80:
try:
decoded = binascii.unhexlify(clean).decode('utf-8')
if len(decoded) == 40 and all(c in '0123456789abcdef' for c in decoded):
return decoded
except Exception:
pass
return clean
@staticmethod
def _extract_error(data):
"""Extrait un message d'erreur lisible d'une réponse StremThru."""
if not isinstance(data, dict):
return None
error = data.get('error')
if isinstance(error, dict):
upstream = error.get('__upstream_cause__') or {}
return error.get('message') or upstream.get('detail') or upstream.get('message') or json.dumps(error)[:300]
if error:
return str(error)
return None
async def check_availability(self, hashes):
"""
Vérifie la disponibilité en cache via GET /v0/store/magnets/check.
Retourne un dict {hash: bool}.
"""
if not hashes:
return {}
cleaned_hashes = []
seen = set()
for h in hashes:
cleaned = self._clean_hash(h)
if cleaned and cleaned not in seen:
seen.add(cleaned)
cleaned_hashes.append(cleaned)
if not cleaned_hashes:
return {}
availability = {}
batch_size = 100
url = f"{self.base_url}/magnets/check"
logging.info(f"StremThru [{self.store_name}]: Checking availability for {len(cleaned_hashes)} hashes")
async with aiohttp.ClientSession(trust_env=True) as session:
async def _check_batch(batch):
params = self._params({"magnet": ",".join(batch)})
try:
async with session.get(url, params=params, headers=self._headers()) as resp:
data = await resp.json(content_type=None)
error = self._extract_error(data)
if error or resp.status >= 400:
logging.warning(f"StremThru [{self.store_name}] check error (HTTP {resp.status}): {error}")
return []
return data.get('data', {}).get('items', [])
except Exception as e:
logging.error(f"StremThru [{self.store_name}] check exception: {e}")
return []
batches = [cleaned_hashes[i:i + batch_size] for i in range(0, len(cleaned_hashes), batch_size)]
results = await asyncio.gather(*[_check_batch(b) for b in batches])
cached_count = 0
for items in results:
for item in items:
h = (item.get('hash') or '').lower()
if not h:
continue
is_cached = item.get('status') == 'cached'
availability[h] = is_cached
if is_cached:
cached_count += 1
logging.info(f"StremThru [{self.store_name}]: {cached_count}/{len(cleaned_hashes)} cached")
return availability
async def unlock_magnet(self, magnet_hash, season=None, episode=None, media_type=None):
"""
Ajoute le magnet au store via StremThru puis génère le lien direct.
POST /v0/store/magnets -> sélection du fichier -> POST /v0/store/link/generate
"""
magnet_hash = self._clean_hash(magnet_hash)
logging.info(f"🔓 StremThru [{self.store_name}] unlock_magnet: hash={magnet_hash}, S{season}E{episode}, type={media_type}")
magnet_uri = f"magnet:?xt=urn:btih:{magnet_hash}"
async with aiohttp.ClientSession(trust_env=True) as session:
# 1. Ajout du magnet au store
try:
async with session.post(
f"{self.base_url}/magnets",
params=self._params(),
json={"magnet": magnet_uri},
headers=self._headers()
) as resp:
data = await resp.json(content_type=None)
error = self._extract_error(data)
if error or resp.status >= 400:
logging.error(f"❌ StremThru [{self.store_name}] add magnet failed (HTTP {resp.status}): {error}")
return None
except Exception as e:
logging.error(f"❌ StremThru [{self.store_name}] add magnet exception: {e}")
return None
magnet_data = data.get('data', {})
status = magnet_data.get('status', '')
if status not in self.READY_STATUSES:
logging.error(f"❌ StremThru [{self.store_name}]: Magnet not cached (status: {status})")
return None
files = [
{
'link': f.get('link'),
'filename': f.get('name') or f.get('path', ''),
'size': f.get('size', 0)
}
for f in magnet_data.get('files', [])
if f.get('link')
]
if not files:
logging.error(f"❌ StremThru [{self.store_name}]: No files in magnet response")
return None
logging.info(f"🔗 StremThru [{self.store_name}]: {len(files)} files in torrent")
target_link = self._select_link(files, season, episode, media_type)
if not target_link:
logging.error(f"❌ StremThru [{self.store_name}]: No suitable file selected")
return None
# 2. Génération du lien direct
try:
async with session.post(
f"{self.base_url}/link/generate",
params=self._params(),
json={"link": target_link},
headers=self._headers()
) as resp:
data = await resp.json(content_type=None)
error = self._extract_error(data)
if error or resp.status >= 400:
logging.error(f"❌ StremThru [{self.store_name}] link generation failed (HTTP {resp.status}): {error}")
return None
link = data.get('data', {}).get('link')
if link:
logging.info(f"✅ StremThru [{self.store_name}] unlocked: {link[:80]}...")
else:
logging.error(f"❌ StremThru [{self.store_name}]: No link in generation response")
return link
except Exception as e:
logging.error(f"❌ StremThru [{self.store_name}] link generation exception: {e}")
return None
def _select_link(self, links, season, episode, media_type):
"""Sélectionne le bon fichier dans le torrent (même logique qu'AllDebrid)."""
if not links:
return None
logging.info(f"🎯 StremThru selecting file for S{season}E{episode} (type={media_type}) among {len(links)} files")
# Si épisode spécifique
if season is not None and episode is not None:
s_str = f"{int(season):02d}"
e_str = f"{int(episode):02d}"
patterns = [
f"S{s_str}E{e_str}", # S01E01
f"{int(season)}x{e_str}", # 1x01
f"S{int(season)}E{e_str}", # S1E01
f"S{s_str}.E{e_str}" # S01.E01
]
for link in links:
filename = link.get('filename', '').upper()
for pat in patterns:
if pat.upper() in filename:
logging.info(f"Match found: {link.get('filename')} (Pattern: {pat})")
return link['link']
logging.warning(f"No strict match found for S{season}E{episode}. Files available: {[l.get('filename') for l in links[:5]]}...")
# Filtrage par extension (Vidéos uniquement)
video_extensions = ('.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts', '.vob')
bad_extensions = ('.iso', '.pdf', '.epub', '.txt', '.nfo', '.jpg', '.jpeg', '.png', '.rar', '.zip')
video_links = [l for l in links if any(l.get('filename', '').lower().endswith(ext) for ext in video_extensions)]
if not video_links:
video_links = [lnk for lnk in links if not any(lnk.get('filename', '').lower().endswith(ext) for ext in bad_extensions)]
if not video_links:
logging.error(f"❌ StremThru _select_link: No video files found in torrent")
return None
# Si Film ou pas trouvé par pattern, on prend le plus gros fichier parmi les vidéos
sorted_links = sorted(video_links, key=lambda x: x.get('size', 0), reverse=True)
best_link = sorted_links[0]
logging.info(f"Fallback/Movie: Selected largest video: {best_link.get('filename')} ({best_link.get('size')} bytes)")
return best_link['link']

35
services/tmdb.py Normal file
View file

@ -0,0 +1,35 @@
import aiohttp
import logging
class TMDBService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.themoviedb.org/3"
async def get_tmdb_id(self, imdb_id, media_type):
"""
Convertit un IMDB ID en TMDB ID via l'endpoint find
"""
url = f"{self.base_url}/find/{imdb_id}"
params = {
"api_key": self.api_key,
"external_source": "imdb_id"
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(url, params=params) as response:
if response.status == 200:
data = await response.json()
results = []
if media_type == "movie":
results = data.get("movie_results", [])
elif media_type == "series":
results = data.get("tv_results", [])
if results:
return results[0]["id"]
except Exception as e:
logging.error(f"Erreur TMDB Find: {e}")
return None

416
services/torbox.py Normal file
View file

@ -0,0 +1,416 @@
"""
TorBox Debrid Service
Converti en async avec aiohttp et compatible avec l'architecture Frenchio
"""
import aiohttp
import asyncio
import logging
import re
class TorBoxService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.torbox.app/v1/api"
self.headers = {
"Authorization": f"Bearer {api_key}",
}
async def check_availability(self, magnet_hash):
"""
Vérifie si un hash est en cache sur TorBox.
Args:
magnet_hash: Hash du torrent
Returns:
dict ou None: Informations si disponible
"""
url = f"{self.base_url}/torrents/checkcached"
params = {
"hash": magnet_hash,
"format": "object",
"list_files": "true"
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
logging.warning(f"TorBox check availability returned {response.status}")
return None
data = await response.json()
if data.get("success") and data.get("data"):
# data est un dict avec le hash comme clé
torrent_info = data["data"].get(magnet_hash)
if torrent_info:
return {
"name": torrent_info.get("name", ""),
"size": torrent_info.get("size", 0),
"files": torrent_info.get("files", []),
"cached": True
}
return None
except Exception as e:
logging.error(f"TorBox check availability error: {e}")
return None
async def add_magnet(self, magnet_link):
"""
Ajoute un magnet à TorBox.
Args:
magnet_link: Lien magnet
Returns:
dict ou None: {"torrent_id": id, "hash": hash, "is_cached": bool}
"""
url = f"{self.base_url}/torrents/createtorrent"
data = {
"magnet": magnet_link,
"seed": 2 # Mode de seed
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.post(url, headers=self.headers, data=data) as response:
if response.status != 200:
text = await response.text()
logging.error(f"TorBox add magnet failed: {response.status} - {text}")
return None
result = await response.json()
if result.get("success"):
data = result.get("data", {})
cached = "Found Cached Torrent" in result.get("detail", "")
return {
"torrent_id": data.get("torrent_id"),
"hash": data.get("hash"),
"is_cached": cached
}
else:
logging.error(f"TorBox add magnet failed: {result}")
return None
except Exception as e:
logging.error(f"TorBox add magnet error: {e}")
return None
async def get_torrent_info(self, torrent_hash):
"""
Récupère les informations d'un torrent via checkcached (pour vérification cache).
Args:
torrent_hash: Hash du torrent
Returns:
dict ou None
"""
url = f"{self.base_url}/torrents/checkcached"
params = {
"hash": torrent_hash,
"format": "object",
"list_files": "true"
}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
return None
data = await response.json()
if data.get("success") and data.get("data"):
return data["data"].get(torrent_hash)
return None
except Exception as e:
logging.error(f"TorBox get torrent info error: {e}")
return None
async def get_torrent_details(self, torrent_id):
"""
Récupère les détails complets d'un torrent ajouté via mylist.
Retourne les fichiers avec leurs vrais IDs assignés par TorBox.
Args:
torrent_id: ID du torrent sur TorBox
Returns:
dict ou None: Contient les fichiers avec leur champ "id"
"""
url = f"{self.base_url}/torrents/mylist"
params = {"id": torrent_id}
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(url, headers=self.headers, params=params) as response:
if response.status != 200:
text = await response.text()
logging.error(f"TorBox get torrent details failed: {response.status} - {text}")
return None
data = await response.json()
if data.get("success"):
return data.get("data")
else:
logging.error(f"TorBox get torrent details failed: {data}")
return None
except Exception as e:
logging.error(f"TorBox get torrent details error: {e}")
return None
async def wait_for_files(self, torrent_hash, timeout=30, interval=5):
"""
Attend que les fichiers soient disponibles (pour torrents non-cachés).
Args:
torrent_hash: Hash du torrent
timeout: Timeout en secondes
interval: Intervalle entre les vérifications
Returns:
list ou None: Liste des fichiers
"""
start_time = asyncio.get_event_loop().time()
while (asyncio.get_event_loop().time() - start_time) < timeout:
info = await self.get_torrent_info(torrent_hash)
if info and "files" in info:
files = info["files"]
if files:
logging.info(f"TorBox: Files ready ({len(files)} files)")
return files
logging.info("TorBox: Files not ready yet, waiting...")
await asyncio.sleep(interval)
logging.error("TorBox: Timeout waiting for files")
return None
async def get_download_link(self, torrent_id, file_id, max_retries=3):
"""
Récupère le lien de téléchargement direct d'un fichier.
Avec retry automatique en cas d'erreur 500 (DATABASE_ERROR).
Args:
torrent_id: ID du torrent sur TorBox
file_id: ID/index du fichier
max_retries: Nombre maximum de tentatives (défaut: 3)
Returns:
str ou None: Lien de téléchargement
"""
url = f"{self.base_url}/torrents/requestdl"
params = {
"token": self.api_key,
"torrent_id": torrent_id,
"file_id": file_id,
"zip_link": "false",
"torrent_file": "false"
}
for attempt in range(max_retries):
async with aiohttp.ClientSession(trust_env=True) as session:
try:
if attempt > 0:
logging.info(f"TorBox: Retry attempt {attempt + 1}/{max_retries}")
await asyncio.sleep(1 + attempt) # Délai croissant: 1s, 2s, 3s
logging.debug(f"TorBox: Requesting download with params: {params}")
async with session.get(url, headers=self.headers, params=params) as response:
text = await response.text()
if response.status == 500:
# Erreur serveur temporaire, on va retry
logging.warning(f"TorBox: Server error 500, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
continue # Retry
else:
logging.error(f"TorBox: Max retries reached - {text}")
return None
if response.status != 200:
logging.error(f"TorBox get download link failed: {response.status} - {text}")
logging.error(f"TorBox: URL was: {url} with params: {params}")
return None
data = await response.json()
if data.get("success"):
download_url = data.get("data")
logging.info(f"TorBox: Download link obtained: {download_url}")
return download_url
else:
logging.error(f"TorBox download link failed: {data}")
return None
except Exception as e:
logging.error(f"TorBox get download link error (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1 + attempt)
continue
return None
async def get_stream_link(self, magnet_link, stream_type, season=None, episode=None):
"""
Obtient un lien de streaming depuis un magnet.
Args:
magnet_link: Lien magnet
stream_type: "movie" ou "series"
season: Numéro de saison (pour séries)
episode: Numéro d'épisode (pour séries)
Returns:
str ou None: Lien de streaming
"""
# 1. Ajouter le magnet
magnet_data = await self.add_magnet(magnet_link)
if not magnet_data:
logging.error("TorBox: Failed to add magnet")
return None
torrent_id = magnet_data.get("torrent_id")
torrent_hash = magnet_data.get("hash")
is_cached = magnet_data.get("is_cached", False)
if not torrent_id or not torrent_hash:
logging.error("TorBox: Missing torrent_id or hash")
return None
# 2. Récupérer les fichiers avec leurs IDs réels via mylist
logging.info(f"TorBox: Fetching torrent details for ID {torrent_id}")
torrent_details = await self.get_torrent_details(torrent_id)
if not torrent_details or "files" not in torrent_details:
logging.error("TorBox: Failed to get torrent details or no files")
return None
files = torrent_details["files"]
logging.info(f"TorBox: Found {len(files)} files in torrent")
# 3. Sélectionner le fichier approprié
# IMPORTANT : Utiliser le champ "id" du fichier, pas l'index dans le tableau
# Log tous les fichiers pour debug
logging.info(f"TorBox: All files in torrent:")
for f in files:
file_id_debug = f.get('id', 'N/A')
logging.info(f" [id={file_id_debug}] {f.get('name')} - {f.get('size', 0)} bytes - video: {self._is_video_file(f.get('name', ''))}")
if stream_type == "movie":
# Plus gros fichier vidéo
video_files = [
f for f in files
if self._is_video_file(f.get("name", ""))
]
if not video_files:
logging.error("TorBox: No video files found")
return None
selected_file = max(video_files, key=lambda x: x.get("size", 0))
# Utiliser le champ "id" du fichier
file_id = selected_file.get("id")
if file_id is None:
logging.error("TorBox: Selected file has no 'id' field")
return None
logging.info(f"TorBox: Selected file (id={file_id}): {selected_file.get('name')}")
elif stream_type == "series":
# Fichier correspondant à S{season}E{episode}
matching_files = []
for f in files:
filename = f.get("name", "")
is_video = self._is_video_file(filename)
matches_ep = self._matches_episode(filename, season, episode)
logging.debug(f" File {filename}: is_video={is_video}, matches_S{season:02d}E{episode:02d}={matches_ep}")
if is_video and matches_ep:
matching_files.append(f)
if not matching_files:
logging.error(f"TorBox: No video file matching S{season:02d}E{episode:02d}")
return None
logging.info(f"TorBox: Found {len(matching_files)} matching video file(s)")
# Prendre le plus gros si plusieurs matchent
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
# Utiliser le champ "id" du fichier
file_id = selected_file.get("id")
if file_id is None:
logging.error("TorBox: Selected file has no 'id' field")
return None
logging.info(f"TorBox: Selected episode file (id={file_id}): {selected_file.get('name')}")
else:
logging.error(f"TorBox: Unsupported stream type: {stream_type}")
return None
# 4. Obtenir le lien de téléchargement
# Utiliser le champ "id" du fichier comme file_id
logging.info(f"TorBox: Requesting download link with file_id={file_id} (torrent_id={torrent_id})")
download_link = await self.get_download_link(torrent_id, file_id)
if download_link:
logging.info(f"TorBox: Stream link obtained successfully: {download_link}")
return download_link
else:
logging.error(f"TorBox: Failed to get download link")
return None
def _is_video_file(self, filename):
"""Vérifie si un fichier est une vidéo."""
video_extensions = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts', '.vob'}
excluded_extensions = {'.nfo', '.txt', '.srt', '.sub', '.idx', '.jpg', '.png', '.gif', '.iso', '.pdf', '.epub', '.zip', '.rar'}
filename_lower = filename.lower()
# Exclure explicitement les fichiers non-vidéo
if any(filename_lower.endswith(ext) for ext in excluded_extensions):
return False
# Vérifier que c'est une vidéo
return any(filename_lower.endswith(ext) for ext in video_extensions)
def _matches_episode(self, filename, season, episode):
"""
Vérifie si un nom de fichier correspond à un épisode.
Patterns supportés:
- S01E01
- 1x01
- s01e01
- etc.
"""
if not season or not episode:
return False
patterns = [
rf"[Ss]{season:02d}[Ee]{episode:02d}", # S01E01
rf"[Ss]{season}[Ee]{episode}", # S1E1
rf"{season}x{episode:02d}", # 1x01
rf"{season}x{episode}", # 1x1
]
for pattern in patterns:
if re.search(pattern, filename):
return True
return False

125
services/torr9.py Normal file
View file

@ -0,0 +1,125 @@
import aiohttp
import logging
import urllib.parse
import xml.etree.ElementTree as ET
from utils import check_season_episode
class Torr9Service:
def __init__(self, passkey):
self.passkey = passkey
self.base_url = "https://api.torr9.net/api/v1/torznab"
async def search(self, params):
if not self.passkey:
return []
params['apikey'] = self.passkey
# Log request (masking passkey)
log_params = params.copy()
log_params['apikey'] = '***PASSKEY***'
logging.info(f"Torr9 Search: {self.base_url}?{urllib.parse.urlencode(log_params)}")
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(self.base_url, params=params, timeout=20) as response:
if response.status == 200:
text = await response.text()
return self._parse_xml(text)
else:
logging.warning(f"Torr9 Error {response.status}")
body = await response.text()
logging.warning(f"Torr9 Body: {body[:200]}")
except Exception as e:
logging.error(f"Torr9 Exception: {e}")
return []
def _parse_xml(self, xml_text):
"""Parse Torznab XML response"""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logging.error(f"Torr9 XML Parse Error: {e}")
return []
# Torznab namespace
ns = {'torznab': 'http://torznab.com/schemas/2015/feed'}
items = root.findall('.//item')
logging.info(f"Torr9 found {len(items)} results")
normalized = []
for item in items:
title = item.findtext('title', '')
guid = item.findtext('guid', '')
size_text = item.findtext('size', '0')
# Enclosure (download link)
enclosure = item.find('enclosure')
download_link = enclosure.get('url', '') if enclosure is not None else ''
# Torznab attributes
info_hash = None
seeders = 0
leechers = 0
for attr in item.findall('torznab:attr', ns):
name = attr.get('name')
value = attr.get('value')
if name == 'infohash':
info_hash = value.lower() if value else None
elif name == 'seeders':
seeders = int(value) if value else 0
elif name == 'peers':
leechers = int(value) if value else 0
# Fallback to guid as hash
if not info_hash:
info_hash = guid.lower() if guid else None
result = {
"name": title,
"size": int(size_text) if size_text else 0,
"tracker_name": "Torr9",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "torr9",
"seeders": seeders,
"leechers": leechers
}
normalized.append(result)
return normalized
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
params = {"t": "movie"}
if imdb_id:
if not str(imdb_id).startswith('tt'):
imdb_id = f"tt{imdb_id}"
params["imdbid"] = imdb_id
elif tmdb_id:
params["tmdbid"] = tmdb_id
else:
params["q"] = f"{title} {year}"
return await self.search(params)
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None):
params = {"t": "tvsearch"}
if imdb_id:
if not str(imdb_id).startswith('tt'):
imdb_id = f"tt{imdb_id}"
params["imdbid"] = imdb_id
elif tmdb_id:
params["tmdbid"] = tmdb_id
else:
params["q"] = title
# Torznab filters for season/episode
if season is not None:
params["season"] = season
if episode is not None:
params["episode"] = episode
return await self.search(params)

150
services/torznab.py Normal file
View file

@ -0,0 +1,150 @@
"""
Client Torznab générique : branchez Jackett ou Prowlarr et accédez à des
centaines de trackers publics et privés (l'authentification tracker est
gérée côté Jackett/Prowlarr).
Config : [{"name": "MonIndexeur", "url": "http://jackett:9117/api/v2.0/indexers/xxx/results/torznab", "apikey": "..."}]
"""
import asyncio
import logging
import re
import urllib.parse
import xml.etree.ElementTree as ET
import aiohttp
_HASH_RE = re.compile(r"btih:([a-fA-F0-9]{40})")
class TorznabService:
def __init__(self, indexers):
# indexers: liste de {"name", "url", "apikey"}
self.indexers = [
i for i in (indexers or [])
if (i.get("url") or "").strip()
]
async def _query(self, indexer, params):
url = indexer["url"].strip().rstrip("/")
if not url.endswith("/api") and "torznab" not in url and "/results" not in url:
url += "/api"
params = dict(params)
if indexer.get("apikey"):
params["apikey"] = indexer["apikey"]
log_params = {k: ("***" if k == "apikey" else v) for k, v in params.items()}
logging.info(f"Torznab [{indexer.get('name', '?')}]: ?{urllib.parse.urlencode(log_params)}")
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=20)
) as resp:
if resp.status != 200:
logging.warning(f"Torznab [{indexer.get('name')}] HTTP {resp.status}")
return []
text = await resp.text()
except Exception as e:
logging.error(f"Torznab [{indexer.get('name')}] error: {e}")
return []
return self._parse_xml(text, indexer.get("name") or "Torznab")
@staticmethod
def _parse_xml(xml_text, indexer_name):
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logging.error(f"Torznab [{indexer_name}] XML invalide: {e}")
return []
ns = {"torznab": "http://torznab.com/schemas/2015/feed"}
results = []
for item in root.findall(".//item"):
title = item.findtext("title", "")
size = int(item.findtext("size", "0") or 0)
enclosure = item.find("enclosure")
download_link = enclosure.get("url", "") if enclosure is not None else item.findtext("link", "")
info_hash = None
magnet = None
seeders = 0
leechers = 0
for attr in item.findall("torznab:attr", ns):
name, value = attr.get("name"), attr.get("value")
if name == "infohash" and value:
info_hash = value.lower()
elif name == "magneturl" and value:
magnet = value
elif name == "seeders" and value:
seeders = int(value)
elif name == "peers" and value:
leechers = int(value)
# Extraction du hash depuis le magnet si besoin
if not info_hash:
for candidate in (magnet, download_link, item.findtext("guid", "")):
if candidate:
m = _HASH_RE.search(candidate)
if m:
info_hash = m.group(1).lower()
break
if not info_hash:
guid = (item.findtext("guid", "") or "").strip().lower()
if re.fullmatch(r"[a-f0-9]{40}", guid):
info_hash = guid
if not info_hash:
continue # sans hash : pas de dédup ni de débridage possible
results.append({
"name": title,
"size": size,
"tracker_name": indexer_name,
"info_hash": info_hash,
"magnet": magnet,
"link": magnet or download_link,
"source": "torznab",
"seeders": seeders,
"leechers": leechers,
})
return results
async def _search_all(self, params_builder):
tasks = [self._query(indexer, params_builder(indexer)) for indexer in self.indexers]
results_list = await asyncio.gather(*tasks, return_exceptions=True)
merged = []
for results in results_list:
if isinstance(results, Exception):
logging.error(f"Torznab task error: {results}")
continue
merged.extend(results)
logging.info(f"Torznab: {len(merged)} résultats ({len(self.indexers)} indexeurs)")
return merged
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
def build(_indexer):
params = {"t": "movie"}
if imdb_id:
params["imdbid"] = imdb_id if str(imdb_id).startswith("tt") else f"tt{imdb_id}"
else:
params["t"] = "search"
params["q"] = f"{title} {year}".strip()
return params
return await self._search_all(build)
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
def build(_indexer):
params = {"t": "tvsearch"}
if imdb_id:
params["imdbid"] = imdb_id if str(imdb_id).startswith("tt") else f"tt{imdb_id}"
else:
params["q"] = title
if season is not None:
params["season"] = season
if episode is not None:
params["ep"] = episode
return params
return await self._search_all(build)

105
services/tr4ker.py Normal file
View file

@ -0,0 +1,105 @@
import aiohttp
import logging
import xml.etree.ElementTree as ET
from utils import check_season_episode
class Tr4kerService:
def __init__(self, apikey):
self.apikey = apikey
self.base_url = "https://tr4ker.net/torznab"
async def search(self, params):
if not self.apikey:
return []
params['apikey'] = self.apikey
log_q = params.get('tmdbid') or params.get('imdbid') or params.get('q', '')
logging.info(f"Tr4ker Search: {self.base_url}?t={params.get('t')}&{log_q}")
async with aiohttp.ClientSession(trust_env=True) as session:
try:
async with session.get(self.base_url, params=params, timeout=aiohttp.ClientTimeout(total=20)) as response:
if response.status == 200:
text = await response.text()
results = self._parse_xml(text)
logging.info(f"Tr4ker found {len(results)} results")
return results
else:
logging.warning(f"Tr4ker Error {response.status}")
except Exception as e:
logging.error(f"Tr4ker Exception: {e}")
return []
def _parse_xml(self, xml_text):
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logging.error(f"Tr4ker XML Parse Error: {e}")
return []
ns = {'torznab': 'http://torznab.com/schemas/2015/feed'}
items = root.findall('.//item')
results = []
for item in items:
title = item.findtext('title', '')
link = item.findtext('link', '')
enclosure = item.find('enclosure')
download_link = enclosure.get('url', '') if enclosure is not None else link
size = int(enclosure.get('length', 0)) if enclosure is not None else 0
info_hash = None
seeders = 0
leechers = 0
for attr in item.findall('torznab:attr', ns):
name = attr.get('name')
value = attr.get('value')
if name == 'infohash':
info_hash = value.lower() if value else None
elif name == 'seeders':
seeders = int(value) if value else 0
elif name == 'leechers':
leechers = int(value) if value else 0
elif name == 'size' and value:
size = int(value)
results.append({
"name": title,
"size": size,
"tracker_name": "Tr4ker",
"info_hash": info_hash,
"magnet": None,
"link": download_link,
"source": "tr4ker",
"seeders": seeders,
"leechers": leechers,
})
return results
async def search_movie(self, title, year, imdb_id=None, tmdb_id=None):
if tmdb_id:
return await self.search({"t": "movie", "tmdbid": tmdb_id})
return await self.search({"t": "search", "q": f"{title} {year}".strip()})
async def search_series(self, title, season, episode, imdb_id=None, tmdb_id=None):
if tmdb_id:
params = {"t": "tvsearch", "tmdbid": tmdb_id}
if season is not None:
params["season"] = season
if episode is not None:
params["episode"] = episode
results = await self.search(params)
else:
if season is not None and episode is not None:
q = f"{title} S{int(season):02d}E{int(episode):02d}"
elif season is not None:
q = f"{title} S{int(season):02d}"
else:
q = title
results = await self.search({"t": "search", "q": q})
if season is not None:
results = [r for r in results if check_season_episode(r.get('name', ''), season, episode)]
return results

163
services/unit3d.py Normal file
View file

@ -0,0 +1,163 @@
import aiohttp
import asyncio
import logging
import json
from urllib.parse import urlencode
class Unit3DService:
def __init__(self, trackers_config):
"""
trackers_config: liste de dicts {url, token, categories}
"""
self.trackers = trackers_config
async def search_tracker(self, session, tracker, query_params):
url = f"{tracker['url']}/api/torrents/filter"
params = {
"api_token": tracker['token'],
**query_params
}
# On ignore les catégories comme demandé
if 'categories' in params:
del params['categories']
# Construction de la query string standard
query_string = urlencode(params)
full_url = f"{url}?{query_string}"
# Masquage du token pour les logs
log_url = full_url.replace(tracker['token'], '***TOKEN***')
logging.info(f"[{tracker['url']}] Requesting: {log_url}")
try:
async with session.get(full_url, timeout=15) as response:
logging.info(f"[{tracker['url']}] Status: {response.status}")
if response.status == 200:
text_data = await response.text()
# Log plus court pour ne pas spammer si grosse réponse, mais suffisant pour voir le format
logging.info(f"[{tracker['url']}] Raw Response Start: {text_data[:200]} ...")
try:
data = json.loads(text_data)
except json.JSONDecodeError as e:
logging.error(f"[{tracker['url']}] JSON Decode Error: {e}")
return []
results = []
if isinstance(data, dict):
if 'data' in data and isinstance(data['data'], list):
results = data['data']
else:
# Cas où data serait directement la liste ou autre structure
# logging.warning(f"[{tracker['url']}] Structure 'data' list not found. Keys: {data.keys()}")
pass
elif isinstance(data, list):
results = data
logging.info(f"[{tracker['url']}] Found {len(results)} items for params {query_params}")
cleaned_results = []
for res in results:
item = res
if 'attributes' in res:
item = {**res, **res['attributes']}
item['tracker_name'] = tracker['url']
# Extraction du lien de téléchargement pour qBittorrent
# Format typique: {"download_link": "https://tracker.com/torrents/download/123?api_token=xxx"}
if 'download_link' in item:
item['link'] = item['download_link']
elif 'download_link' in res.get('attributes', {}):
item['link'] = res['attributes']['download_link']
cleaned_results.append(item)
return cleaned_results
else:
logging.warning(f"[{tracker['url']}] Error Status: {response.status}")
# text = await response.text()
# logging.warning(f"[{tracker['url']}] Error Body: {text[:200]}")
except Exception as e:
logging.error(f"[{tracker['url']}] Exception: {e}")
# Traceback complet inutile si c'est juste un timeout ou connection error fréquent
# import traceback
# logging.error(traceback.format_exc())
pass
return []
async def download_torrent(self, session, download_url):
"""Télécharge le fichier .torrent depuis l'URL fournie"""
# download_url contient souvent déjà l'api_token
try:
async with session.get(download_url) as resp:
if resp.status == 200:
return await resp.read()
logging.error(f"UNIT3D Download Error: {resp.status}")
except Exception as e:
logging.error(f"UNIT3D Download Exception: {e}")
return None
async def search_all(self, tmdb_id=None, imdb_id=None, type=None, season=None, episode=None):
tasks = []
# Préparation des paramètres
params_list = []
# 1. Recherche Standard (Saison + Episode si dispo)
base_params = {}
if type == 'series' and season is not None:
base_params['seasonNumber'] = season
if episode is not None:
base_params['episodeNumber'] = episode
params_list.append(base_params)
# 2. Recherche Pack Saison (Saison sans Episode)
# Si on a un épisode, on ajoute aussi une recherche pour la saison entière pour trouver les packs
if type == 'series' and season is not None and episode is not None:
pack_params = {'seasonNumber': season}
params_list.append(pack_params)
async with aiohttp.ClientSession(trust_env=True) as session:
for tracker in self.trackers:
for common_params in params_list:
# Recherche TMDB
if tmdb_id:
params_tmdb = {'tmdbId': tmdb_id, **common_params}
tasks.append(self.search_tracker(session, tracker, params_tmdb))
# Recherche IMDB
if imdb_id:
# Certains trackers UNIT3D attendent l'ID sans 'tt'
clean_imdb = imdb_id.replace('tt', '')
params_imdb = {'imdbId': clean_imdb, **common_params}
tasks.append(self.search_tracker(session, tracker, params_imdb))
logging.info(f"Launching {len(tasks)} search tasks across {len(self.trackers)} trackers")
# Exécution parallèle de toutes les requêtes
responses = await asyncio.gather(*tasks)
# Aplatir les résultats
all_results = []
for resp in responses:
all_results.extend(resp)
# Filtrage et déduplication
unique_results = {} # info_hash -> data
for res in all_results:
info_hash = res.get('info_hash') or res.get('attributes', {}).get('info_hash')
if info_hash:
if info_hash not in unique_results:
unique_results[info_hash] = res
else:
pass
# logging.debug("Item without info_hash ignored")
logging.info(f"Total unique torrents found after deduplication: {len(unique_results)}")
return list(unique_results.values())

196
services/ygg.py Normal file
View file

@ -0,0 +1,196 @@
import asyncio
import json
import logging
from urllib.parse import quote
import aiohttp
from utils import check_season_episode
RELAY_URL = "https://u2p.anhkagi.net/"
TORRENT_KIND = 2003
WS_TIMEOUT = 5
DEFAULT_TRACKERS = [
"https://tracker.yggleak.top/announce",
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.demonii.com:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.srv00.com:6969/announce",
"udp://tracker.dler.org:6969/announce",
"udp://leet-tracker.moe:1337/announce",
"udp://explodie.org:6969/announce",
]
def _fix_encoding(text: str) -> str:
try:
return text.encode('latin-1').decode('utf-8')
except (UnicodeDecodeError, UnicodeEncodeError):
return text
def _get_tag(tags: list, key: str):
for t in tags:
if t[0] == key:
return t[1] if len(t) > 1 else None
return None
def _get_l_prefix(tags: list, prefix: str):
for t in tags:
if t[0] == "l" and len(t) > 1 and t[1].startswith(prefix):
parts = t[1].split(":", 1)
return parts[1] if len(parts) > 1 else None
return None
def _parse_event(event: dict):
try:
tags = event.get("tags", [])
title = _get_tag(tags, "title")
if not title:
return None
title = _fix_encoding(title)
info_hash = _get_tag(tags, "x")
if not info_hash:
return None
info_hash = info_hash.lower()
size = int(_get_tag(tags, "size") or 0)
seeders = int(_get_l_prefix(tags, "u2p.seed:") or 1)
leechers = int(_get_l_prefix(tags, "u2p.leech:") or 0)
timestamp = int(_get_tag(tags, "published_at") or event.get("created_at", 0))
magnet = f"magnet:?xt=urn:btih:{info_hash}&dn={quote(title)}"
for tr in DEFAULT_TRACKERS:
magnet += f"&tr={quote(tr, safe='')}"
return {
"name": title,
"size": size,
"tracker_name": "YGG",
"info_hash": info_hash,
"magnet": magnet,
"link": magnet,
"source": "ygg",
"seeders": seeders,
"leechers": leechers,
"timestamp": timestamp,
}
except Exception as e:
logging.warning(f"YGG parse error: {e}")
return None
async def _query(filters: dict) -> list:
results = []
headers = {
"Origin": "https://www.ygg.re",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
try:
async with asyncio.timeout(WS_TIMEOUT):
async with aiohttp.ClientSession(headers=headers) as session:
async with session.ws_connect(RELAY_URL) as ws:
req = json.dumps(["REQ", "frenchio", filters])
await ws.send_str(req)
logging.info(f"YGG search: {filters.get('search', '?')}")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
if data[0] == "EOSE":
break
if data[0] == "EVENT":
parsed = _parse_event(data[2])
if parsed:
results.append(parsed)
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
break
except TimeoutError:
logging.warning(f"YGG relay timeout after {WS_TIMEOUT}s ({len(results)} results)")
except Exception as e:
logging.error(f"YGG relay error: {e}")
return results
def _merge(results_list: list) -> list:
seen = set()
merged = []
for results in results_list:
if isinstance(results, Exception) or not results:
continue
for r in results:
ih = r.get('info_hash')
if ih and ih not in seen:
merged.append(r)
seen.add(ih)
elif not ih:
merged.append(r)
merged.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
return merged
class YggService:
def __init__(self, passkey=None, cookie=None):
self.passkey = passkey
self.cookie = cookie
async def search_movie(self, title, year, original_title=None, imdb_id=None, tmdb_id=None):
queries = []
if title:
queries.append(f"{title} {year}".strip())
if original_title and original_title != title:
queries.append(f"{original_title} {year}".strip())
if not queries:
return []
tasks = [
_query({
"kinds": [TORRENT_KIND],
"search": q,
"limit": 50
})
for q in queries
]
results_list = await asyncio.gather(*tasks, return_exceptions=True)
return _merge(results_list)
async def search_series(self, title, season, episode, original_title=None, imdb_id=None, tmdb_id=None):
seen_t = set()
unique_titles = []
for t in [title] + ([original_title] if original_title and original_title != title else []):
if t and t not in seen_t:
unique_titles.append(t)
seen_t.add(t)
tasks = []
for t in unique_titles:
if season is not None and episode is not None:
tasks.append(_query({
"kinds": [TORRENT_KIND],
"search": f"{t} S{int(season):02d}E{int(episode):02d}",
"limit": 50
}))
if season is not None:
tasks.append(_query({
"kinds": [TORRENT_KIND],
"search": f"{t} S{int(season):02d}",
"limit": 50
}))
if not tasks:
return []
results_list = await asyncio.gather(*tasks, return_exceptions=True)
merged = _merge(results_list)
return [r for r in merged if check_season_episode(r.get('name', ''), season, episode)]

517
templates/configure.html Normal file
View file

@ -0,0 +1,517 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ranger — Configuration</title>
<style>
:root {
--bg: #0f1115; --card: #181b22; --card2: #1f232c; --border: #2a2f3a;
--text: #e6e8ec; --muted: #9aa2b1; --accent: #7c5cff; --accent2: #22c55e;
--danger: #ef4444; --radius: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 24px 16px 80px; }
header { text-align: center; margin-bottom: 28px; }
header h1 { font-size: 2.4rem; margin: 0; letter-spacing: -1px; }
header h1 .r { color: var(--accent); }
header p { color: var(--muted); margin: 6px 0 0; }
.badge { font-size: .72rem; color: var(--muted); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 18px 18px; margin-bottom: 18px;
}
.card h2 { font-size: 1.05rem; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; }
.card .hint { color: var(--muted); font-size: .85rem; margin: 0 0 14px; }
label { display: block; font-size: .85rem; color: var(--muted); margin: 12px 0 5px; }
input[type=text], input[type=password], input[type=number], select {
width: 100%; padding: 10px 12px; background: var(--card2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: .92rem;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 140px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@media (max-width: 560px) { .grid3 { grid-template-columns: 1fr 1fr; } }
.check {
display: flex; align-items: center; gap: 9px; padding: 9px 11px; background: var(--card2);
border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-size: .9rem;
}
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
.check.on { border-color: var(--accent); }
.subfield { margin-top: 8px; display: none; }
.subfield.show { display: block; }
.debrid-list, .indexer-list { display: flex; flex-direction: column; gap: 10px; }
.debrid-item, .indexer-item {
background: var(--card2); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
}
.debrid-item .head { display: flex; gap: 8px; align-items: center; }
.debrid-item .prio { color: var(--muted); font-size: .8rem; cursor: grab; user-select: none; }
.btn {
background: var(--accent); color: #fff; border: none; padding: 11px 16px; border-radius: 8px;
font-size: .92rem; font-weight: 600; cursor: pointer;
}
.btn.sm { padding: 7px 12px; font-size: .82rem; }
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
.actions { position: sticky; bottom: 0; background: linear-gradient(transparent, var(--bg) 30%);
padding-top: 20px; display: flex; gap: 10px; flex-direction: column; }
.install {
display: block; text-align: center; background: var(--accent2); color: #05210f; font-weight: 700;
padding: 14px; border-radius: 10px; text-decoration: none; font-size: 1rem;
}
.install.disabled { opacity: .4; pointer-events: none; }
.url-box {
background: var(--card2); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
font-size: .78rem; word-break: break-all; color: var(--muted); margin-top: 8px; display: none;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
padding: 7px 12px; border-radius: 20px; border: 1px solid var(--border); background: var(--card2);
cursor: pointer; font-size: .82rem; user-select: none;
}
.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.sortable { display: flex; flex-direction: column; gap: 6px; }
.sort-item {
display: flex; align-items: center; gap: 8px; background: var(--card2); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 11px; cursor: grab; font-size: .88rem;
}
.sort-item .grip { color: var(--muted); }
.small { font-size: .8rem; color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><span class="r">R</span>anger</h1>
<p>L'addon Stremio ultime — FR &amp; international · films / séries / anime</p>
<span class="badge">v__APP_VERSION__</span>
</header>
<!-- TMDB -->
<div class="card">
<h2>🎬 Clé TMDB</h2>
<p class="hint">Recommandé : titres FR, détection anime, épisodes absolus. Sans clé, fallback Cinemeta (titres anglais). <a href="https://www.themoviedb.org/settings/api" target="_blank" style="color:var(--accent)">Obtenir une clé</a></p>
<input type="text" id="tmdb_key" placeholder="Clé API TMDB (v3)">
</div>
<!-- Débrideurs -->
<div class="card">
<h2>⚡ Débrideurs <span class="small">(glisser pour l'ordre de priorité)</span></h2>
<p class="hint">Le premier débrideur où un torrent est en cache est utilisé en priorité.</p>
<div class="debrid-list" id="debridList"></div>
<div style="margin-top:10px" class="row">
<div>
<select id="debridSelect">
<option value="alldebrid">AllDebrid</option>
<option value="realdebrid">Real-Debrid</option>
<option value="torbox">TorBox</option>
<option value="debridlink">DebridLink</option>
</select>
</div>
<button class="btn sm" onclick="addDebrid()">+ Ajouter</button>
</div>
<label>Mode d'affichage multi-débrideurs</label>
<select id="debrid_mode">
<option value="first">Prioritaire uniquement (1 lien par torrent)</option>
<option value="all">Tous les débrideurs en cache (1 lien chacun)</option>
</select>
</div>
<!-- StremThru -->
<div class="card">
<h2>🛰️ StremThru <span class="small">(optionnel)</span></h2>
<p class="hint">Proxy d'API débrideur — contourne les blocages d'IP datacenter (VPS). Laissez vide si non concerné.</p>
<label>URL de l'instance StremThru</label>
<input type="text" id="stremthru_url" placeholder="https://stremthru.exemple.com">
<label>Authentification (user:pass, si protégée)</label>
<input type="text" id="stremthru_auth" placeholder="user:pass">
</div>
<!-- Trackers publics -->
<div class="card">
<h2>🌍 Trackers publics</h2>
<p class="hint">Activez d'un clic — aucune clé requise.</p>
<div class="grid3" id="publicTrackers"></div>
</div>
<!-- Trackers privés / semi -->
<div class="card">
<h2>🔑 Trackers privés / semi-privés</h2>
<p class="hint">Cochez et renseignez votre clé / passkey.</p>
<div id="keyedTrackers"></div>
<label style="margin-top:16px">ABN (identifiants)</label>
<div class="grid2">
<input type="text" id="abn_user" placeholder="Utilisateur ABN">
<input type="password" id="abn_pass" placeholder="Mot de passe ABN">
</div>
</div>
<!-- UNIT3D -->
<div class="card">
<h2>🌐 Trackers UNIT3D</h2>
<p class="hint">Ajoutez vos trackers UNIT3D (URL + token API).</p>
<div class="indexer-list" id="unit3dList"></div>
<button class="btn sm ghost" style="margin-top:10px" onclick="addUnit3d()">+ Ajouter un tracker UNIT3D</button>
</div>
<!-- Torznab -->
<div class="card">
<h2>🗂️ Indexeurs Torznab (Jackett / Prowlarr)</h2>
<p class="hint">Accédez à des centaines de trackers via votre instance Jackett/Prowlarr.</p>
<div class="indexer-list" id="torznabList"></div>
<button class="btn sm ghost" style="margin-top:10px" onclick="addTorznab()">+ Ajouter un indexeur Torznab</button>
</div>
<!-- Filtres -->
<div class="card">
<h2>🎚️ Filtres</h2>
<div class="row">
<div>
<label>Taille min (Go)</label>
<input type="number" id="min_size" min="0" step="0.5" value="0">
</div>
<div>
<label>Taille max (Go)</label>
<input type="number" id="max_size" min="0" step="0.5" value="0">
</div>
<div>
<label>Résultats max</label>
<input type="number" id="max_results" min="1" value="30">
</div>
<div>
<label>Max / résolution</label>
<input type="number" id="max_per_res" min="0" value="0">
</div>
</div>
<label>Résolutions autorisées <span class="small">(rien = toutes)</span></label>
<div class="chips" id="resChips"></div>
<label>Codecs autorisés</label>
<div class="chips" id="codecChips"></div>
<label>Langues autorisées</label>
<div class="chips" id="langChips"></div>
<div style="margin-top:14px" class="grid2">
<label class="check"><input type="checkbox" id="exclude_cam" checked> Exclure les CAM/TS</label>
<label class="check"><input type="checkbox" id="exclude_packs"> Exclure les packs de saison</label>
<label class="check"><input type="checkbox" id="cached_only"> Uniquement en cache débrideur</label>
<label class="check"><input type="checkbox" id="show_uncached" checked> Afficher les non-cachés (ajout au débrideur)</label>
<label class="check"><input type="checkbox" id="show_p2p"> Afficher les liens P2P (sans débrideur)</label>
</div>
</div>
<!-- Tri -->
<div class="card">
<h2>↕️ Ordre de tri <span class="small">(glisser pour réordonner)</span></h2>
<p class="hint">Les critères sont appliqués de haut en bas.</p>
<div class="sortable" id="sortList"></div>
<div style="margin-top:14px" class="grid2">
<div>
<label>Priorité des langues</label>
<div class="sortable" id="langOrder"></div>
</div>
<div>
<label>Priorité des trackers <span class="small">(source)</span></label>
<div class="sortable" id="provOrder"></div>
</div>
</div>
</div>
<div class="actions">
<div class="url-box" id="urlBox"></div>
<div class="row">
<button class="btn ghost" onclick="copyUrl()">📋 Copier l'URL du manifest</button>
</div>
<a class="install disabled" id="installBtn"> Installer dans Stremio</a>
</div>
</div>
<script>
const APP_VERSION = "__APP_VERSION__";
const PUBLIC_TRACKERS = [
{ id: "ygg", label: "🐝 YGG (leak)" },
{ id: "apibay", label: "🏴‍☠️ ThePirateBay" },
{ id: "eztv", label: "📺 EZTV" },
{ id: "nyaa", label: "🐈 Nyaa (anime)" },
];
const KEYED_TRACKERS = [
{ id: "c411", label: "📡 C411", ph: "Clé API C411" },
{ id: "torr9", label: "🔥 Torr9", ph: "Passkey Torr9" },
{ id: "tr4ker", label: "🎯 Tr4ker", ph: "Clé API Tr4ker" },
{ id: "nekobt", label: "🐾 NekoBT (anime)", ph: "Clé API NekoBT" },
];
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",
seeders: "👤 Seeders", tracker: "🌐 Tracker",
};
let debrids = []; // [{service, key}]
let unit3ds = []; // [{url, key}]
let torznabs = []; // [{name, url, apikey}]
let sortOrder = ["cached", "language", "resolution", "size_desc"];
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();
// ---- Rendu ----
function renderPublic() {
const el = document.getElementById("publicTrackers");
el.innerHTML = "";
PUBLIC_TRACKERS.forEach(t => {
const lbl = document.createElement("label");
lbl.className = "check";
lbl.innerHTML = `<input type="checkbox" data-tracker="${t.id}" checked> ${t.label}`;
lbl.querySelector("input").addEventListener("change", update);
el.appendChild(lbl);
});
}
function renderKeyed() {
const el = document.getElementById("keyedTrackers");
el.innerHTML = "";
KEYED_TRACKERS.forEach(t => {
const wrap = document.createElement("div");
wrap.style.marginBottom = "8px";
wrap.innerHTML = `
<label class="check"><input type="checkbox" data-ktracker="${t.id}"> ${t.label}</label>
<div class="subfield" data-sub="${t.id}"><input type="password" data-kkey="${t.id}" placeholder="${t.ph}"></div>`;
const cb = wrap.querySelector("input[type=checkbox]");
cb.addEventListener("change", () => {
wrap.querySelector(".subfield").classList.toggle("show", cb.checked);
update();
});
wrap.querySelector("input[type=password]").addEventListener("input", update);
el.appendChild(wrap);
});
}
function renderDebrids() {
const el = document.getElementById("debridList");
el.innerHTML = "";
debrids.forEach((d, i) => {
const item = document.createElement("div");
item.className = "debrid-item";
item.draggable = true;
item.dataset.idx = i;
item.innerHTML = `
<div class="head">
<span class="prio" title="Glisser pour réordonner">⠿ ${i + 1}.</span>
<strong style="text-transform:capitalize">${d.service}</strong>
<button class="btn sm danger" style="margin-left:auto" data-del="${i}"></button>
</div>
<input type="password" data-dkey="${i}" placeholder="Clé API ${d.service}" value="${d.key || ''}">`;
item.querySelector("[data-del]").addEventListener("click", () => { debrids.splice(i, 1); renderDebrids(); update(); });
item.querySelector("[data-dkey]").addEventListener("input", e => { debrids[i].key = e.target.value; update(); });
addDragReorder(item, el, debrids, renderDebrids);
el.appendChild(item);
});
}
function addDebrid() {
const s = document.getElementById("debridSelect").value;
if (debrids.some(d => d.service === s)) return;
debrids.push({ service: s, key: "" });
renderDebrids(); update();
}
function renderUnit3d() {
const el = document.getElementById("unit3dList");
el.innerHTML = "";
unit3ds.forEach((u, i) => {
const item = document.createElement("div");
item.className = "indexer-item";
item.innerHTML = `
<div class="grid2">
<input type="text" data-uurl="${i}" placeholder="https://tracker.tld" value="${u.url || ''}">
<input type="password" data-ukey="${i}" placeholder="Token API" value="${u.key || ''}">
</div>
<button class="btn sm danger" style="margin-top:8px" data-udel="${i}">✕ Retirer</button>`;
item.querySelector("[data-uurl]").addEventListener("input", e => { unit3ds[i].url = e.target.value; update(); });
item.querySelector("[data-ukey]").addEventListener("input", e => { unit3ds[i].key = e.target.value; update(); });
item.querySelector("[data-udel]").addEventListener("click", () => { unit3ds.splice(i, 1); renderUnit3d(); update(); });
el.appendChild(item);
});
}
function addUnit3d() { unit3ds.push({ url: "", key: "" }); renderUnit3d(); }
function renderTorznab() {
const el = document.getElementById("torznabList");
el.innerHTML = "";
torznabs.forEach((t, i) => {
const item = document.createElement("div");
item.className = "indexer-item";
item.innerHTML = `
<input type="text" data-tname="${i}" placeholder="Nom (ex: MonJackett)" value="${t.name || ''}" style="margin-bottom:8px">
<input type="text" data-turl="${i}" placeholder="URL Torznab (.../api ou /results/torznab)" value="${t.url || ''}" style="margin-bottom:8px">
<div class="grid2">
<input type="password" data-tkey="${i}" placeholder="API key" value="${t.apikey || ''}">
<button class="btn sm danger" data-tdel="${i}">✕ Retirer</button>
</div>`;
item.querySelector("[data-tname]").addEventListener("input", e => { torznabs[i].name = e.target.value; update(); });
item.querySelector("[data-turl]").addEventListener("input", e => { torznabs[i].url = e.target.value; update(); });
item.querySelector("[data-tkey]").addEventListener("input", e => { torznabs[i].apikey = e.target.value; update(); });
item.querySelector("[data-tdel]").addEventListener("click", () => { torznabs.splice(i, 1); renderTorznab(); update(); });
el.appendChild(item);
});
}
function addTorznab() { torznabs.push({ name: "", url: "", apikey: "" }); renderTorznab(); }
function renderChips(containerId, values, set) {
const el = document.getElementById(containerId);
el.innerHTML = "";
values.forEach(v => {
const c = document.createElement("span");
c.className = "chip";
c.textContent = v;
c.addEventListener("click", () => {
if (set.has(v)) { set.delete(v); c.classList.remove("on"); }
else { set.add(v); c.classList.add("on"); }
update();
});
el.appendChild(c);
});
}
function renderSortable(containerId, list, labelFn) {
const el = document.getElementById(containerId);
el.innerHTML = "";
list.forEach((item, i) => {
const div = document.createElement("div");
div.className = "sort-item";
div.draggable = true;
div.dataset.idx = i;
div.innerHTML = `<span class="grip"></span> ${labelFn(item)}`;
addDragReorder(div, el, list, () => renderSortable(containerId, list, labelFn));
el.appendChild(div);
});
}
// ---- Drag & drop réordonnancement ----
let dragSrc = null;
function addDragReorder(node, container, arr, rerender) {
node.addEventListener("dragstart", () => { dragSrc = node; node.style.opacity = ".4"; });
node.addEventListener("dragend", () => { node.style.opacity = "1"; });
node.addEventListener("dragover", e => e.preventDefault());
node.addEventListener("drop", e => {
e.preventDefault();
if (!dragSrc || dragSrc === node) return;
const from = +dragSrc.dataset.idx, to = +node.dataset.idx;
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
rerender(); update();
});
}
// ---- Construction de la config ----
function buildConfig() {
const trackers = [];
document.querySelectorAll("[data-tracker]").forEach(cb => { if (cb.checked) trackers.push(cb.dataset.tracker); });
const tracker_keys = {};
KEYED_TRACKERS.forEach(t => {
const cb = document.querySelector(`[data-ktracker="${t.id}"]`);
const key = document.querySelector(`[data-kkey="${t.id}"]`);
if (cb && cb.checked && key && key.value.trim()) {
trackers.push(t.id);
tracker_keys[t.id] = key.value.trim();
}
});
return {
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() })),
debrid_mode: document.getElementById("debrid_mode").value,
stremthru: {
url: document.getElementById("stremthru_url").value.trim(),
auth: document.getElementById("stremthru_auth").value.trim(),
},
trackers,
tracker_keys,
abn: {
username: document.getElementById("abn_user").value.trim(),
password: document.getElementById("abn_pass").value.trim(),
},
unit3d: unit3ds.filter(u => u.url && u.key).map(u => ({ url: u.url.trim(), key: u.key.trim() })),
torznab: torznabs.filter(t => t.url).map(t => ({ name: t.name.trim() || "Torznab", url: t.url.trim(), apikey: (t.apikey || "").trim() })),
filters: {
min_size_gb: parseFloat(document.getElementById("min_size").value) || 0,
max_size_gb: parseFloat(document.getElementById("max_size").value) || 0,
resolutions: [...selectedRes],
codecs: [...selectedCodec],
languages: [...selectedLang],
exclude_cam: document.getElementById("exclude_cam").checked,
exclude_season_packs: document.getElementById("exclude_packs").checked,
max_results: parseInt(document.getElementById("max_results").value) || 30,
max_per_resolution: parseInt(document.getElementById("max_per_res").value) || 0,
cached_only: document.getElementById("cached_only").checked,
show_uncached: document.getElementById("show_uncached").checked,
show_p2p: document.getElementById("show_p2p").checked,
},
sort: sortOrder,
language_order: langOrder,
resolution_order: RESOLUTIONS,
providers_order: provOrder,
};
}
function b64url(obj) {
const json = JSON.stringify(obj);
const b64 = btoa(unescape(encodeURIComponent(json)));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function update() {
const cfg = buildConfig();
const encoded = b64url(cfg);
const host = window.location.host;
const proto = window.location.protocol;
const manifestUrl = `${proto}//${host}/${encoded}/manifest.json`;
document.getElementById("urlBox").textContent = manifestUrl;
const btn = document.getElementById("installBtn");
const valid = cfg.debrids.length > 0 || cfg.filters.show_p2p;
btn.classList.toggle("disabled", !valid);
btn.href = valid ? `stremio://${host}/${encoded}/manifest.json` : "#";
window._manifestUrl = manifestUrl;
}
function copyUrl() {
const box = document.getElementById("urlBox");
box.style.display = "block";
navigator.clipboard.writeText(window._manifestUrl || "").then(() => {
const b = event.target; const t = b.textContent; b.textContent = "✅ Copié !";
setTimeout(() => b.textContent = t, 1500);
});
}
// ---- Init ----
renderPublic();
renderKeyed();
renderDebrids();
renderUnit3d();
renderTorznab();
renderChips("resChips", RESOLUTIONS, selectedRes);
renderChips("codecChips", CODECS, selectedCodec);
renderChips("langChips", LANGUAGES, selectedLang);
renderSortable("sortList", sortOrder, k => SORT_LABELS[k] || k);
renderSortable("langOrder", langOrder, k => k);
renderSortable("provOrder", provOrder, k => k);
["tmdb_key","stremthru_url","stremthru_auth","abn_user","abn_pass","debrid_mode",
"min_size","max_size","max_results","max_per_res","exclude_cam","exclude_packs",
"cached_only","show_uncached","show_p2p"].forEach(id => {
const el = document.getElementById(id);
el.addEventListener("input", update);
el.addEventListener("change", update);
});
update();
</script>
</body>
</html>

379
utils.py Normal file
View file

@ -0,0 +1,379 @@
import re
import unicodedata
def normalize_title(title):
if not title:
return ""
# Remove accents
title = unicodedata.normalize('NFD', title).encode('ascii', 'ignore').decode('utf-8')
# Remove punctuation and special characters, replace with space
title = re.sub(r'[^\w\s]', ' ', title)
# lowercase and normalize spaces
return " ".join(title.lower().split())
def check_title_match(torrent_name, title_fr, title_en, year=None, is_movie=False):
"""
Vérifie si le titre français ou anglais est présent dans le nom du torrent.
Amélioré pour détecter les faux positifs (ex: Narcos match Narcos Mexico).
"""
if not title_fr and not title_en:
return True
norm_torrent = normalize_title(torrent_name)
norm_fr = normalize_title(title_fr)
norm_en = normalize_title(title_en)
# Mots qui peuvent suivre un titre sans que ce soit un autre média
tech_tags = {
'1080p', '720p', '4k', '2160p', 'uhd', 'dvd', 'sd', 'bluray', 'brrip', 'bdrip',
'webrip', 'web', 'webdl', 'dvdrip', 'cam', 'ts', 'vff', 'vf', 'vfq', 'vf2',
'vostfr', 'multi', 'truefrench', 'french', 'eng', 'english', 'en', 'vo', 'fr',
'x264', 'x265', 'hevc', 'h264', 'h265', 'av1', 'hdr', 'dv', 'dolby', 'vision',
'10bit', 'light', 'repack', 'proper', 'internal', 'extended', 'uncut',
'subfrench', 'subforced', 'nf', 'netflix', 'amzn', 'amazon', 'dnp', 'dsnp', 'hmax',
'ac3', 'dts', 'dd5', 'dd2', 'aac', 'mkv', 'mp4', 'avi', 'tv', 'show', 'complete',
'integrale', 'integra', 'vol', 'volume', 'part', 'party', 'uncut', 'dual', 'hdtv'
}
def is_strict_match(target, torrent):
if not target: return False
# On cherche le titre exact avec bordures de mots
pattern = r'\b' + re.escape(target) + r'\b'
match = re.search(pattern, torrent)
if not match:
return False
# On vérifie ce qui suit immédiatement le titre
end_idx = match.end()
after_text = torrent[end_idx:].strip()
if not after_text:
return True # Titre exact à la fin
# On prend le prochain mot
next_word = after_text.split()[0]
# Si le prochain mot est une année, un épisode (s01, 1x01) ou un tag technique -> OK
if re.match(r'^(19|20)\d{2}$', next_word): # Année
return True
if re.match(r'^[sx]\d+$', next_word): # S01, x01
return True
if re.match(r'^s\d+e\d+$', next_word): # S01E01
return True
if next_word in tech_tags:
return True
# Si le prochain mot fait partie de l'AUTRE titre (ex: "Narcos" vs "Narcos Mexico")
# Si on cherche "Narcos" (FR) et que EN est "Narcos Mexico", et que le torrent a "Mexico" -> OK
# Mais ici on vérifie si next_word est dans norm_fr ou norm_en
if next_word in norm_fr.split() or next_word in norm_en.split():
return True
# Si c'est un mot inconnu (ex: "Mexico" alors que la cible est juste "Narcos") -> Faux positif probable
return False
title_match = is_strict_match(norm_fr, norm_torrent) or is_strict_match(norm_en, norm_torrent)
if not title_match:
return False
if is_movie and year:
try:
y = int(year)
# Pour les films, l'année est cruciale pour éviter les remakes/suites
if str(y) not in norm_torrent and str(y-1) not in norm_torrent and str(y+1) not in norm_torrent:
return False
except ValueError:
if str(year) not in norm_torrent:
return False
return True
def format_size(size_bytes):
"""Formate une taille en octets vers une chaine lisible (Go, Mo)"""
try:
size = float(size_bytes)
except (ValueError, TypeError):
return "0 B"
if size >= 1024**3:
return f"{size / (1024**3):.2f} Go"
elif size >= 1024**2:
return f"{size / (1024**2):.2f} Mo"
else:
return f"{size / 1024:.2f} Ko"
def is_video_file(filename):
"""
Vérifie si le fichier/torrent semble être une vidéo.
On exclut les extensions non-vidéo connues (.iso, .pdf, .epub, .zip, etc.).
Pour les torrents, le nom n'a souvent pas d'extension, on l'accepte par défaut.
"""
if not filename:
return False
filename_lower = filename.lower()
# Liste des extensions non-vidéo à exclure absolument
bad_extensions = (
'.iso', '.pdf', '.epub', '.txt', '.nfo', '.jpg', '.jpeg', '.png',
'.rar', '.zip', '.7z', '.tar', '.gz', '.exe', '.doc', '.docx'
)
if filename_lower.endswith(bad_extensions):
return False
# Liste des extensions vidéo connues (pour acceptation immédiate)
video_extensions = (
'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm',
'.m4v', '.mpg', '.mpeg', '.ts', '.m2ts', '.vob', '.divx'
)
if filename_lower.endswith(video_extensions):
return True
# Si le nom contient un point suivi de 2-4 caractères à la fin,
# c'est probablement une extension inconnue (et potentiellement non-vidéo)
# Mais attention, beaucoup de torrents ont des points dans le nom (ex: Movie.Name.1080p)
# On ne filtre PAS si ça ressemble à un titre de torrent standard (sans extension de fichier à la fin)
# Si le nom se termine par une extension (3-4 caractères après le dernier point)
# qui n'est pas dans notre liste de vidéos, on pourrait être suspicieux.
# Mais pour l'instant, on va rester permissif : on ne bloque que les "bad_extensions".
return True
def parse_torrent_name(name):
"""Analyse le nom du torrent pour extraire qualité, langue, codec et type de release"""
if not name:
return {"name": "", "quality": "", "codec": "", "language": "", "release_type": ""}
name_upper = name.upper()
# Qualité
quality = ""
if any(q in name_upper for q in ["2160P", "4K", "UHD"]):
quality = "4K"
elif "1080P" in name_upper:
quality = "1080p"
elif "720P" in name_upper:
quality = "720p"
elif any(q in name_upper for q in ["480P", "SD", "DVD"]):
quality = "SD"
# Codec
codec = ""
if any(c in name_upper for c in ["X265", "HEVC", "H265"]):
codec = "x265"
elif any(c in name_upper for c in ["X264", "AVC", "H264"]):
codec = "x264"
elif "AV1" in name_upper:
codec = "AV1"
# HDR / Bitrate
extras = []
if "HDR" in name_upper: extras.append("HDR")
if any(dv in name_upper for dv in ["DV", "DOLBY VISION"]): extras.append("DV")
if "10BIT" in name_upper: extras.append("10bit")
# Release Type
release_type = ""
if "WEBRIP" in name_upper:
release_type = "WebRIP"
elif "WEB-DL" in name_upper or "WEBDL" in name_upper:
release_type = "WEB-DL"
elif re.search(r'\bWEB\b', name_upper):
release_type = "WEB-DL"
elif any(br in name_upper for br in ["BRRIP", "BDRIP", "BLURAY", "BDLIGHT"]):
release_type = "BDRip"
elif "DVDRIP" in name_upper:
release_type = "DVDRip"
elif re.search(r'\bCAM\b', name_upper) or re.search(r'\bHDTS\b|\bHD-TS\b|\bTS\b', name_upper):
release_type = "CAM"
# Langues
languages = []
if "MULTI" in name_upper:
languages.append("Multi")
if "VOSTFR" in name_upper:
languages.append("VOSTFR")
if "TRUEFRENCH" in name_upper or "VFF" in name_upper:
languages.append("VFF")
elif "VF2" in name_upper:
languages.append("VF2")
elif "VFQ" in name_upper:
languages.append("VFQ")
elif "FRENCH" in name_upper or "VF" in name_upper:
languages.append("VF")
# Si rien de détecté mais original_title != title
if not languages and ("EN" in name_upper or "VO" in name_upper or "ENG" in name_upper):
languages.append("VO")
# Formatage de l'affichage classique (title)
display_langs = []
for lang in languages:
if lang == "Multi": display_langs.append("🇫🇷+🇺🇸 MULTI")
elif lang == "VFF": display_langs.append("🇫🇷 VFF")
elif lang == "VF": display_langs.append("🇫🇷 VF")
elif lang == "VFQ": display_langs.append("🇨🇦 VFQ")
elif lang == "VOSTFR": display_langs.append("🇫🇷🇯🇵 VOSTFR")
elif lang == "VO": display_langs.append("🇺🇸 VO")
title_parts = []
if quality: title_parts.append(f"📺 {quality}")
if release_type: title_parts.append(f"📦 {release_type}")
if codec: title_parts.append(f"🎞️ {codec}")
if extras: title_parts.append(f"{' '.join(extras)}")
if display_langs: title_parts.append(f"{' '.join(display_langs)}")
return {
"name": " | ".join(title_parts),
"quality": quality,
"codec": codec,
"language": ", ".join(languages) if languages else "Français",
"release_type": release_type
}
STOPWORDS_TITLE = {'le', 'la', 'les', 'de', 'des', 'du', 'the', 'of', 'no', 'and', 'et', 'wa', 'to', 'ni'}
def check_title_tokens(name, *titles):
"""
Vérification de titre souple pour les nommages fansub (Nyaa/NekoBT) :
tous les mots significatifs d'un des titres doivent apparaître entiers
dans le nom du torrent. Évite les hors-sujet du type "Monster" qui
matche "Pocket Monsters" via un pack/range d'épisodes.
"""
name_words = set(normalize_title(name).split())
for title in titles:
if not title:
continue
tokens = [w for w in normalize_title(title).split()
if len(w) >= 2 and w not in STOPWORDS_TITLE]
if tokens and all(tok in name_words for tok in tokens):
return True
return False
def check_special_episode(name, target_episode, exclude_packs=False):
"""
Vérifie si le torrent correspond à un épisode spécial/OVA (saison 0)
au nommage fansub : "Titre OVA 06 VOSTFR", "OAV 1+2", "S01 + OAV",
ou le classique "S00E01".
"""
if check_season_episode(name, 0, target_episode, exclude_packs=exclude_packs):
return True
name_upper = name.upper()
if not re.search(r'\b(?:OVA|OAV|OAD|SPECIALS?|SP)\b', name_upper):
return False
# Numéros accolés au tag : "OVA 06", "OAV 1+2", "Special 3"
nums = re.findall(r'\b(?:OVA|OAV|OAD|SPECIALS?|SP)\b[ ._#-]*0*(\d{1,3})(?:[ ._+~-]+0*(\d{1,3}))?', name_upper)
if not nums:
# Tag OVA sans numéro : pack d'OVA ou bundle ("S01 + OAV")
return not exclude_packs
for start, end in nums:
try:
s = int(start)
e = int(end) if end else s
if s > e:
s, e = e, s
if s != e and exclude_packs:
continue
if s <= target_episode <= e:
return True
except ValueError:
continue
return False
def check_absolute_episode(name, absolute_episode, exclude_packs=False):
"""
Vérifie si le torrent correspond à l'épisode en numérotation absolue,
utilisée par les fansubs anime (ex: "One Piece S01E1122", "One Piece - 1122 VOSTFR",
ou un pack "One Piece 1100-1150").
"""
if absolute_episode is None:
return False
name_upper = name.upper()
# SxxEyyyy : numérotation absolue déguisée en saison 1 (ex: S01E1122, avec ranges S01E1100-1150)
se_pattern = re.compile(r'(?:S|SAISON|SEASON)[ ._-]?0?1[ ._-]?E(\d{1,4})(?:[ ._-]*(?:E|-|~)[ ._-]*(\d{1,4}))?', re.IGNORECASE)
for e_start, e_end in se_pattern.findall(name_upper):
try:
start = int(e_start)
end = int(e_end) if e_end else start
if end < start:
continue
if start < end and exclude_packs:
continue
if start <= absolute_episode <= end:
return True
except ValueError:
continue
# Ranges nus (packs) : "1100-1150"
if not exclude_packs:
range_pattern = re.compile(r'(?<![0-9])(\d{2,4})[ ._]?[-~][ ._]?(\d{2,4})(?![0-9])')
for r_start, r_end in range_pattern.findall(name_upper):
try:
start, end = int(r_start), int(r_end)
if start < end and start <= absolute_episode <= end:
return True
except ValueError:
continue
# Numéro nu : "- 1122", "E1122", "EP1122" (en excluant 1080P, X264, H.264...)
bare_pattern = re.compile(rf'(?<![0-9])(?<!X)(?<!H\.)0*{absolute_episode}(?![0-9])(?!P\b)')
if bare_pattern.search(name_upper):
return True
return False
def check_season_episode(name, target_season, target_episode, exclude_packs=False):
"""
Vérifie si le torrent correspond à la saison/épisode demandé.
Retourne True si c'est bon (match exact ou pack saison).
Retourne False si c'est un autre épisode/saison.
"""
if target_season is None:
return True
name_upper = name.upper()
# Extraction SxxExx
# Regex améliorée pour capturer les ranges d'épisodes (ex: S05E02-E03 ou S05E02E03)
se_pattern = re.compile(r'(?:S|SAISON|SEASON)[ ._-]?(\d{1,2})(?:[ ._-]?E(\d{1,2}))(?:(?:[ ._-]*(?:E|-|~)[ ._-]*)(\d{1,2}))?', re.IGNORECASE)
matches = se_pattern.findall(name_upper)
# Si aucun pattern Sxx trouvé, on essaie 1x01
if not matches:
x_pattern = re.compile(r'(\d{1,2})x(\d{1,2})', re.IGNORECASE)
matches = [(m[0], m[1], None) for m in x_pattern.findall(name_upper)]
# Si toujours rien, on cherche juste le pattern Saison sans épisode (Pack Saison)
if not matches:
s_only_pattern = re.compile(r'(?:S|SAISON|SEASON)[ ._-]?(\d{1,2})', re.IGNORECASE)
matches = [(m, None, None) for m in s_only_pattern.findall(name_upper)]
if not matches:
return False # Pour une série, si on ne trouve aucune info de saison/épisode, on rejette
for s, e_start, e_end in matches:
try:
season = int(s)
if season != target_season:
continue
# Si pas d'épisode dans le nom (Pack Saison) -> OK sauf si on exclut les packs
if e_start is None:
if exclude_packs:
return False
return True
start = int(e_start)
end = int(e_end) if e_end else start
# Vérification de l'épisode (dans le range)
if start <= target_episode <= end:
return True
except ValueError:
continue
return False