344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""
|
||
videoclub_buscador.py — Buscador interactivo de peliculas y series.
|
||
|
||
Reemplaza el listado diario pasivo por un buscador on-demand:
|
||
/pelicula <nombre> → lista numerada de peliculas
|
||
/serie <nombre> → lista series con temporadas/capitulos numerados
|
||
/descargar <numero> → descarga a NAS via Download Station
|
||
/novedades → novedades del catalogo
|
||
|
||
Usa la API de Torrentio (Stremio addon) + RealDebrid para enlaces directos.
|
||
"""
|
||
import json
|
||
import re
|
||
import sys
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
BASE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(BASE))
|
||
|
||
import config
|
||
|
||
CATALOGO_URL = ("https://torrentio.strem.fun/"
|
||
"{config}/catalog/other/torrentio-realdebrid.json")
|
||
|
||
# Patrones de clasificacion
|
||
YEAR_RE = re.compile(r"\((19\d\d|20\d\d)\)")
|
||
YEAR_BARE_RE = re.compile(r"\.(19\d\d|20\d\d)\.")
|
||
SERIE_EP_RE = re.compile(r"\bS(\d{1,2})E(\d{1,3})\b", re.IGNORECASE)
|
||
CAP_RE = re.compile(r"(?:Cap[\.\s]*(\d+))", re.IGNORECASE)
|
||
ADULTO_RE = re.compile(
|
||
r"(\.XXX\.|XXX\.|private\.|brazzers|onlytarts|spankbang|xhamster|"
|
||
r"porntrex|missav|sexandsubmission|evilangel|monstersofcock|itsanal|thelifeerotic)",
|
||
re.IGNORECASE)
|
||
SERIE_PALABRAS = {
|
||
"star trek", "stargate", "heidi", "euphoria", "from", "the pitt",
|
||
"supergirl", "monarch", "paradise", "desaparecida", "vanished",
|
||
"strange new worlds", "star trek strange", "the studio",
|
||
"one punch man", "conan", "detective conan"
|
||
}
|
||
EXTRA_SUCIO = [
|
||
"wolfmax4k.com", "pctfenix", "pctfenix.com", "yts", "yts.mx",
|
||
"rarbg", "verpeliculasonline", "viruse", "proyecto", "kikorip",
|
||
"camrip", "telesync", "hdtv", "bluray", "remux", "webrip", "bdrip",
|
||
"repack", "proper", "xtrem", "webdl", "web-dl", "web", "dl",
|
||
"x264", "x265", "h264", "h265", "aac", "1080p", "720p", "4k",
|
||
"2160p", "spanish", "castellano", "dual", "lat", "es", "en",
|
||
"audio", "sub", "subs"
|
||
]
|
||
CALIDAD_MALA = re.compile(
|
||
r"(CAMRip|TS\.|HDTS|Telesync|KinoRip|Screener|HDCAM|HC\b|r5|360p|240p)",
|
||
re.IGNORECASE)
|
||
CASTELLANO = re.compile(
|
||
r"(Esp|Castellano|Span|Spanish|dual|lat|\bES\b|audio)", re.IGNORECASE)
|
||
INGLES = re.compile(r"(Eng|English|Ingl[eé]s|\bEN\b)", re.IGNORECASE)
|
||
OTRO_IDIOMA = re.compile(
|
||
r"(Ita\b|MIRCrew|RO\s?EN|South Wind|Silver Zero|Francais|Deutsch|Portugu[eê]s)",
|
||
re.IGNORECASE)
|
||
|
||
|
||
def clean_filename(name):
|
||
"""Limpia un nombre de fichero y devuelve (titulo_limpio, year, tipo)."""
|
||
name = re.sub(r"\.(mkv|mp4|avi|mov|webm|m4v)$", "", name.strip(),
|
||
flags=re.IGNORECASE)
|
||
year = None
|
||
m = YEAR_RE.search(name)
|
||
if m:
|
||
year = m.group(1)
|
||
name = name.replace(m.group(0), " ")
|
||
else:
|
||
m = YEAR_BARE_RE.search(name)
|
||
if m:
|
||
year = m.group(1)
|
||
name = name.replace(m.group(0), " ")
|
||
|
||
es_serie = bool(SERIE_EP_RE.search(name) or CAP_RE.search(name)) or \
|
||
any(p in name.lower() for p in SERIE_PALABRAS)
|
||
es_adulto = bool(ADULTO_RE.search(name))
|
||
|
||
name = re.sub(r"\[[^\]]*\]", " ", name)
|
||
name = re.sub(r"[\(\)\[\]]", " ", name)
|
||
name = name.replace("_", " ").replace(".", " ")
|
||
name = re.sub(r"\s+", " ", name).strip()
|
||
tokens = [t for t in name.split(" ") if t.lower() not in EXTRA_SUCIO]
|
||
name = " ".join(tokens).strip(" -–")
|
||
name = re.sub(r"\s+", " ", name).strip()
|
||
|
||
tipo = "adulto" if es_adulto else ("serie" if es_serie else "pelicula")
|
||
return name, year, tipo
|
||
|
||
|
||
def parsear_episodio(name):
|
||
"""Extrae (temporada, capitulo) de un nombre, o (0, 0)."""
|
||
m = SERIE_EP_RE.search(name)
|
||
if m:
|
||
return int(m.group(1)), int(m.group(2))
|
||
m = CAP_RE.search(name)
|
||
if m:
|
||
return 1, int(m.group(1))
|
||
return 0, 0
|
||
|
||
|
||
def serie_base(name):
|
||
"""Nombre base de la serie (antes del marcador SxxEyy, Cap.X, o anio)."""
|
||
m = SERIE_EP_RE.search(name) or CAP_RE.search(name)
|
||
if m:
|
||
base = name[:m.start()]
|
||
else:
|
||
base = name
|
||
# Quitar el anio si quedo pegado al final del nombre base
|
||
ym = YEAR_RE.search(base)
|
||
if ym:
|
||
base = base[:ym.start()] + base[ym.end():]
|
||
else:
|
||
ym2 = YEAR_BARE_RE.search(base)
|
||
if ym2:
|
||
base = base[:ym2.start()] + base[ym2.end():]
|
||
return _limpiar_titulo_ep(base)
|
||
|
||
|
||
def _limpiar_titulo_ep(texto):
|
||
"""Limpia un titulo de episodio."""
|
||
texto = re.sub(r"\.(mkv|mp4|avi|mov|webm|m4v)$", "", texto,
|
||
flags=re.IGNORECASE)
|
||
texto = re.sub(r"\[[^\]]*\]", " ", texto)
|
||
texto = re.sub(r"\b\d+x\d+\b", " ", texto, flags=re.IGNORECASE)
|
||
texto = texto.replace("_", " ").replace(".", " ").replace("-", " ")
|
||
texto = re.sub(r"[\[\]\(\)]", " ", texto)
|
||
tokens = [t for t in texto.split(" ") if t.lower() not in EXTRA_SUCIO]
|
||
texto = " ".join(tokens).strip(" -–")
|
||
return re.sub(r"\s+", " ", texto).strip()
|
||
|
||
|
||
def fetch_catalogo():
|
||
"""Descarga el catalogo de Torrentio + enlaces directos RD."""
|
||
if not config.TORRENTIO_RD_TOKEN:
|
||
return []
|
||
url = CATALOGO_URL.format(config=config.TORRENTIO_CONFIG_WITH_TOKEN)
|
||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||
with urllib.request.urlopen(req, timeout=40) as r:
|
||
data = json.loads(r.read().decode("utf-8"))
|
||
|
||
enlaces = {}
|
||
try:
|
||
req2 = urllib.request.Request(
|
||
"https://api.real-debrid.com/rest/1.0/torrents",
|
||
headers={"Authorization": f"Bearer {config.TORRENTIO_RD_TOKEN}"})
|
||
with urllib.request.urlopen(req2, timeout=40) as r2:
|
||
for t in json.loads(r2.read().decode("utf-8")):
|
||
links = t.get("links") or []
|
||
if links:
|
||
enlaces[t["id"]] = links[0]
|
||
except Exception as e:
|
||
print(f"[videoclub] No se pudieron obtener enlaces RD: {e}")
|
||
|
||
items = []
|
||
for m in data.get("metas", []):
|
||
name = m.get("name", "")
|
||
clean, year, tipo = clean_filename(name)
|
||
rd_id = m.get("id", "").split(":", 1)[-1]
|
||
items.append({
|
||
"id": m.get("id"),
|
||
"name": name,
|
||
"clean": clean,
|
||
"year": year,
|
||
"tipo": tipo,
|
||
"enlace": enlaces.get(rd_id),
|
||
"buena_calidad": not bool(CALIDAD_MALA.search(name)),
|
||
"castellano": bool(CASTELLANO.search(name)),
|
||
})
|
||
return items
|
||
|
||
|
||
def separar_adultos(items):
|
||
"""Separa items adultos del resto."""
|
||
normales, adultos = [], []
|
||
for it in items:
|
||
(adultos if it["tipo"] == "adulto" else normales).append(it)
|
||
return normales, adultos
|
||
|
||
|
||
def buscar_peliculas(termino, items=None):
|
||
"""Busca peliculas por nombre. Devuelve lista ordenada de resultados."""
|
||
term = (termino or "").strip().lower()
|
||
if not term:
|
||
return []
|
||
if items is None:
|
||
items = fetch_catalogo()
|
||
normales, _ = separar_adultos(items)
|
||
resultados = []
|
||
for it in normales:
|
||
if it["tipo"] != "pelicula":
|
||
continue
|
||
if not it["buena_calidad"]:
|
||
continue
|
||
if not (it.get("castellano") or bool(INGLES.search(it.get("name", "")))):
|
||
continue
|
||
clean = (it.get("clean") or "").lower()
|
||
name = (it.get("name") or "").lower()
|
||
if term in clean or term in name:
|
||
resultados.append(it)
|
||
resultados.sort(key=lambda x: (int(x["year"] or 0), x["clean"] or ""),
|
||
reverse=True)
|
||
return resultados
|
||
|
||
|
||
def buscar_series(termino, items=None):
|
||
"""Busca series por nombre. Devuelve dict agrupado por serie base."""
|
||
term = (termino or "").strip().lower()
|
||
if not term:
|
||
return {}
|
||
if items is None:
|
||
items = fetch_catalogo()
|
||
normales, _ = separar_adultos(items)
|
||
grupos = {}
|
||
for it in normales:
|
||
if it["tipo"] != "serie":
|
||
continue
|
||
if not it["buena_calidad"]:
|
||
continue
|
||
if OTRO_IDIOMA.search(it.get("name", "")):
|
||
continue
|
||
base = serie_base(it["name"]) or it["clean"] or ""
|
||
if term in base.lower() or term in (it["name"] or "").lower():
|
||
grupos.setdefault(base, []).append(it)
|
||
for base in grupos:
|
||
grupos[base].sort(key=lambda x: parsear_episodio(x["name"]))
|
||
return grupos
|
||
|
||
|
||
def formatear_peliculas(resultados):
|
||
"""Formatea resultados de peliculas como lista numerada."""
|
||
if not resultados:
|
||
return ["No se encontraron peliculas con ese termino."]
|
||
lineas = [f"🎬 <b>Resultados ({len(resultados)}):</b>", ""]
|
||
for i, it in enumerate(resultados, 1):
|
||
y = f" ({it['year']})" if it["year"] else ""
|
||
calidad = "4K" if "4k" in (it.get("name") or "").lower() or \
|
||
"2160p" in (it.get("name") or "").lower() else "HD"
|
||
idioma = "cast." if it.get("castellano") else "ing."
|
||
enlace = it.get("enlace") or ""
|
||
lineas.append(f"<b>{i}.</b> {it['clean']}{y} — {calidad}, {idioma}")
|
||
if enlace:
|
||
lineas.append(f" <code>descargar {enlace}</code>")
|
||
lineas.append("")
|
||
lineas.append("Responde con el <b>numero</b> para descargar.")
|
||
return [lineas]
|
||
|
||
|
||
def formatear_series(grupos):
|
||
"""Formatea resultados de series como lista numerada por temporadas."""
|
||
if not grupos:
|
||
return ["No se encontraron series con ese termino."]
|
||
lineas = []
|
||
for base in sorted(grupos, key=lambda x: x.lower()):
|
||
eps = grupos[base]
|
||
temp_max = max(parsear_episodio(e["name"])[0] for e in eps)
|
||
lineas.append(f"📺 <b>{base}</b> — {temp_max} temporadas")
|
||
lineas.append("")
|
||
|
||
temp_actual = 0
|
||
num_global = 1
|
||
for it in eps:
|
||
temp, cap = parsear_episodio(it["name"])
|
||
if temp != temp_actual:
|
||
temp_actual = temp
|
||
if temp > 0:
|
||
lineas.append(f"<b>Temporada {temp}:</b>")
|
||
resto = _limpiar_titulo_ep(it["name"][SERIE_EP_RE.search(it["name"]).end():]) \
|
||
if SERIE_EP_RE.search(it["name"]) else it["clean"]
|
||
enlace = it.get("enlace") or ""
|
||
if temp > 0:
|
||
etiqueta = f"S{temp:02d}E{cap:02d}"
|
||
else:
|
||
etiqueta = it["clean"]
|
||
lineas.append(f" <b>{num_global}.</b> {etiqueta} — {resto}")
|
||
if enlace:
|
||
lineas.append(f" <code>descargar {enlace}</code>")
|
||
num_global += 1
|
||
lineas.append("")
|
||
|
||
lineas.append("Responde con el <b>numero</b> de episodio para descargar.")
|
||
return [lineas]
|
||
|
||
|
||
def enlace_realdebrid(rd_id):
|
||
"""Obtiene el enlace directo de descarga de un torrent RD."""
|
||
import urllib.parse
|
||
rd_id = rd_id.split(":", 1)[-1]
|
||
tok = config.TORRENTIO_RD_TOKEN
|
||
url = f"https://api.real-debrid.com/rest/1.0/torrents/info/{urllib.parse.quote(rd_id)}"
|
||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {tok}"})
|
||
with urllib.request.urlopen(req, timeout=30) as r:
|
||
info = json.loads(r.read().decode("utf-8"))
|
||
links = info.get("links") or []
|
||
return links[0] if links else None
|
||
|
||
|
||
def generar_novedades(items=None):
|
||
"""Devuelve listado de novedades recientes."""
|
||
if items is None:
|
||
items = fetch_catalogo()
|
||
normales, _ = separar_adultos(items)
|
||
buenos = [it for it in normales if it["buena_calidad"]]
|
||
buenos.sort(key=lambda x: (int(x["year"] or 0), x["clean"] or ""),
|
||
reverse=True)
|
||
return buenos[:20]
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys as _sys
|
||
import os as _os
|
||
try:
|
||
_os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||
_sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
if len(_sys.argv) < 2:
|
||
print("Uso:")
|
||
print(" python videoclub_buscador.py pelicula <nombre>")
|
||
print(" python videoclub_buscador.py serie <nombre>")
|
||
print(" python videoclub_buscador.py novedades")
|
||
_sys.exit(1)
|
||
|
||
cmd = _sys.argv[1]
|
||
arg = " ".join(_sys.argv[2:]) if len(_sys.argv) > 2 else ""
|
||
|
||
if cmd == "pelicula":
|
||
res = buscar_peliculas(arg)
|
||
for line in formatear_peliculas(res):
|
||
print(line)
|
||
elif cmd == "serie":
|
||
res = buscar_series(arg)
|
||
for line in formatear_series(res):
|
||
print(line)
|
||
elif cmd == "novedades":
|
||
res = generar_novedades()
|
||
for i, it in enumerate(res, 1):
|
||
y = f" ({it['year']})" if it["year"] else ""
|
||
print(f"{i}. {it['clean']}{y}")
|
||
else:
|
||
print(f"Comando desconocido: {cmd}")
|