mirror of
https://github.com/vatax3/ranger.git
synced 2026-07-26 16:32:07 +00:00
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:
commit
709265ccb8
36 changed files with 5991 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
119
README.md
Normal 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
0
core/__init__.py
Normal file
165
core/cache.py
Normal file
165
core/cache.py
Normal 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
125
core/config.py
Normal 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
122
core/debrid.py
Normal 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
110
core/formatting.py
Normal 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
165
core/metadata.py
Normal 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
141
core/parsing.py
Normal 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
190
core/ranking.py
Normal 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
170
core/search.py
Normal 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
23
docker-compose.yml
Normal 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
338
main.py
Normal 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é) où c'est caché.
|
||||||
|
- debrid_mode 'all' : une entrée par débrideur où 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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
aiohttp==3.9.5
|
||||||
|
aiofiles==23.2.1
|
||||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
435
services/abn.py
Normal file
435
services/abn.py
Normal 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
433
services/alldebrid.py
Normal 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
104
services/apibay.py
Normal 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
118
services/c411.py
Normal 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
224
services/debridlink.py
Normal 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
79
services/eztv.py
Normal 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
154
services/nekobt.py
Normal 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
148
services/nyaa.py
Normal 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
222
services/realdebrid.py
Normal 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
268
services/stremthru.py
Normal 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
35
services/tmdb.py
Normal 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
416
services/torbox.py
Normal 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
125
services/torr9.py
Normal 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
150
services/torznab.py
Normal 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
105
services/tr4ker.py
Normal 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
163
services/unit3d.py
Normal 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
196
services/ygg.py
Normal 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
517
templates/configure.html
Normal 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 & 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
379
utils.py
Normal 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
|
||||||
|
|
||||||
Loading…
Reference in a new issue