mirror of
https://github.com/vatax3/ranger.git
synced 2026-07-26 16:32:07 +00:00
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>
379 lines
14 KiB
Python
379 lines
14 KiB
Python
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
|
|
|