358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
||
pelis.py — Listado de novedades con buena calidad en castellano del catalogo
|
||
RealDebrid/Torrentio, con el comando 'descargar <url>' ya montado para copiar
|
||
y pegar en Telegram. Lo usa el comando 'pelis' de pi_bot.py.
|
||
|
||
- Filtra: NO adulto, buena calidad, castellano.
|
||
- Trocea la salida en mensajes <= CHUNK_MAX para que Telegram no corte.
|
||
- Los items adultos se devuelven por separado (efimeros, nunca se guardan).
|
||
|
||
Autonomo (sin dependencias): usa solo urllib y la logica de clasificacion
|
||
duplicada de videoclub/videoclub.py para no depender del repo de negocio.
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
BASE = Path(__file__).resolve().parent
|
||
CHUNK_MAX = 3500 # limite de Telegram (~4096), con margen
|
||
|
||
# Reutiliza el .env del propio directorio (carga simple, sin python-dotenv)
|
||
ENV = {}
|
||
try:
|
||
for line in open(BASE / ".env"):
|
||
line = line.strip()
|
||
if line and not line.startswith("#") and "=" in line:
|
||
k, v = line.split("=", 1)
|
||
ENV[k.strip()] = v.strip()
|
||
except Exception:
|
||
pass
|
||
|
||
TORRENTIO_RD_TOKEN = os.getenv("TORRENTIO_RD_TOKEN") or ENV.get("TORRENTIO_RD_TOKEN", "")
|
||
TORRENTIO_CONFIG = os.getenv("TORRENTIO_CONFIG") or ENV.get("TORRENTIO_CONFIG", "language=spanish")
|
||
CATALOGO = ("https://torrentio.strem.fun/"
|
||
f"{TORRENTIO_CONFIG}|realdebrid={TORRENTIO_RD_TOKEN}/"
|
||
"catalog/other/torrentio-realdebrid.json")
|
||
|
||
YEAR_RE = re.compile(r"\((19\d\d|20\d\d)\)")
|
||
YEAR_BARE_RE = re.compile(r"\.(19\d\d|20\d\d)\.")
|
||
SERIE_RE = re.compile(r"(S\d{1,2}E\d{1,3}|Cap[\.\s]|cap[\.\s]|Temporada|temp\.|\bS0?1E\d|\dx\d\d)", re.IGNORECASE)
|
||
ADULTO_RE = re.compile(r"(\.XXX\.|XXX\.|private\.|brazzers|onlytarts|spankbang|xhamster|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 -> se excluye de "buena calidad"
|
||
CALIDAD_MALA = re.compile(
|
||
r"(CAMRip|TS\.|HDTS|Telesync|KinoRip|Screener|HDCAM|HC\b|r5|telesync|MD-?AAC|360p|240p)", re.IGNORECASE)
|
||
# Idiomas que indican castellano/espanol/dual (audio o subs)
|
||
CASTELLANO = re.compile(r"(Esp|Castellano|Span|Spanish|dual|lat|\bES\b|audio)", re.IGNORECASE)
|
||
# Marcadores de ingles (audio o subs) que tambien cuentan como "bien"
|
||
INGLES = re.compile(r"(Eng|English|Ingl[eé]s|\bEN\b)", re.IGNORECASE)
|
||
# Marcadores claros de OTRO idioma sin espanol ni ingles (se excluyen)
|
||
OTRO_IDIOMA = re.compile(
|
||
r"(Ita\b|MIRCrew|RO\s?EN|South Wind|Silver Zero|Francais|Deutsch|Portugu[eê]s)", re.IGNORECASE)
|
||
|
||
# Capitulos/temporadas: "S01E05", "Cap 3", etc.
|
||
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)
|
||
|
||
|
||
def parsear_episodio(name):
|
||
"""Extrae (temporada, capitulo) de un nombre de serie, 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 (lo que va antes del marcador SxxEyy o Cap.X)."""
|
||
m = SERIE_EP_RE.search(name) or CAP_RE.search(name)
|
||
base = name[:m.start()] if m else name
|
||
base = _limpiar_titulo_ep(base)
|
||
return base
|
||
|
||
|
||
def clean_filename(name):
|
||
"""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_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 fetch_catalogo():
|
||
"""Descarga el catalogo + enlaces directos RD. Devuelve lista de items."""
|
||
if not TORRENTIO_RD_TOKEN:
|
||
return []
|
||
url = CATALOGO.format()
|
||
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 {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"[pelis] 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 seleccionar(items):
|
||
"""Peliculas con buena calidad en castellano, sin limite de anio
|
||
(excluye adulto y series sueltas)."""
|
||
out = []
|
||
for it in items:
|
||
if it["tipo"] == "adulto":
|
||
continue
|
||
if not it["buena_calidad"] or not it["castellano"]:
|
||
continue
|
||
if it["tipo"] == "serie" and it.get("enlace") and "S01E0" in it["name"]:
|
||
continue
|
||
out.append(it)
|
||
out.sort(key=lambda x: (int(x["year"] or 0), x["clean"] or ""), reverse=True)
|
||
return out
|
||
|
||
|
||
def es_serie_idioma(it):
|
||
"""Una serie cuenta si tiene audio/subs en castellano o ingles
|
||
(marcador positivo o neutro, la config ya es language=spanish)
|
||
y NO tiene marcador claro de otro idioma."""
|
||
name = (it.get("name") or "").lower()
|
||
if OTRO_IDIOMA.search(name):
|
||
return False
|
||
return it.get("castellano") or bool(INGLES.search(name)) or True
|
||
|
||
|
||
def seleccionar_series(items):
|
||
"""Series con buena calidad en castellano/ingles (excluye adulto y peliculas)."""
|
||
out = []
|
||
for it in items:
|
||
if it["tipo"] != "serie":
|
||
continue
|
||
if not it["buena_calidad"]:
|
||
continue
|
||
if not es_serie_idioma(it):
|
||
continue
|
||
out.append(it)
|
||
out.sort(key=lambda x: (x["clean"] or "").lower())
|
||
return out
|
||
|
||
|
||
def agrupar_por_serie(items):
|
||
"""Agrupa los items de serie por nombre base y ordena por temporada/capitulo."""
|
||
grupos = {}
|
||
for it in items:
|
||
base = serie_base(it["name"]) or it["clean"] or "?"
|
||
grupos.setdefault(base, []).append(it)
|
||
for base in grupos:
|
||
grupos[base].sort(key=lambda x: parsear_episodio(x["name"]))
|
||
return grupos
|
||
|
||
|
||
def _limpiar_titulo_ep(texto):
|
||
"""Limpia un titulo de episodio: quita extension, calidad, idiomas, ruido."""
|
||
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 formatear_episodio(it):
|
||
"""Linea 'SxxExx titulo' con el comando descargar debajo."""
|
||
temp, cap = parsear_episodio(it["name"])
|
||
m = SERIE_EP_RE.search(it["name"])
|
||
if temp:
|
||
etiqueta = f"S{temp:02d}E{cap:02d}"
|
||
if m:
|
||
resto = _limpiar_titulo_ep(it["name"][m.end():])
|
||
else:
|
||
resto = _limpiar_titulo_ep(it["name"])
|
||
linea = f" {etiqueta} — {resto}" if resto else f" {etiqueta}"
|
||
else:
|
||
linea = f" {it['clean']}"
|
||
enlace = it.get("enlace") or ""
|
||
if enlace:
|
||
linea += f"\n <code>descargar {enlace}</code>"
|
||
return linea
|
||
|
||
|
||
def generar_series():
|
||
"""Devuelve (mensajes_series, adultos_list). Series agrupadas por serie,
|
||
cada una con sus temporadas/capitulos y el comando 'descargar' listo."""
|
||
items = fetch_catalogo()
|
||
normales, adultos = separar_adultos(items)
|
||
sel = seleccionar_series(normales)
|
||
grupos = agrupar_por_serie(sel)
|
||
lineas = []
|
||
for base in sorted(grupos, key=lambda x: x.lower()):
|
||
lineas.append(f"📺 <b>{base}</b>")
|
||
for it in grupos[base]:
|
||
lineas.append(formatear_episodio(it))
|
||
mensajes = trocear_texto(lineas)
|
||
if not mensajes:
|
||
mensajes = ["No hay series con buena calidad (castellano/ingles)."]
|
||
return mensajes, adultos
|
||
|
||
|
||
def buscar_serie(nombre, items=None):
|
||
"""Devuelve (mensajes, encontrada). Busca una serie por nombre y lista
|
||
sus temporadas/capitulos con el comando 'descargar' listo."""
|
||
term = (nombre or "").strip().lower()
|
||
if not term:
|
||
return ["Uso: <b>serie <nombre></b>"], False
|
||
if items is None:
|
||
items = fetch_catalogo()
|
||
normales, _ = separar_adultos(items)
|
||
coincidencias = {}
|
||
for it in normales:
|
||
if it["tipo"] != "serie":
|
||
continue
|
||
base = serie_base(it["name"]) or it["clean"] or ""
|
||
if term in base.lower() or term in (it["name"] or "").lower():
|
||
coincidencias.setdefault(base, []).append(it)
|
||
if not coincidencias:
|
||
return [f"No encuentro ninguna serie con <b>{nombre}</b>."], False
|
||
lineas = []
|
||
for base in sorted(coincidencias, key=lambda x: x.lower()):
|
||
lista = sorted(coincidencias[base], key=lambda x: parsear_episodio(x["name"]))
|
||
lineas.append(f"📺 <b>{base}</b>")
|
||
for it in lista:
|
||
lineas.append(formatear_episodio(it))
|
||
return trocear_texto(lineas), True
|
||
|
||
|
||
def separar_adultos(items):
|
||
"""Separa los items adultos del resto."""
|
||
normales, adultos = [], []
|
||
for it in items:
|
||
(adultos if it["tipo"] == "adulto" else normales).append(it)
|
||
return normales, adultos
|
||
|
||
|
||
def formatear_linea(it):
|
||
"""Línea lista para copiar/pegar: titulo + 'descargar <url>'."""
|
||
y = f" ({it['year']})" if it["year"] else ""
|
||
enlace = it.get("enlace") or ""
|
||
linea = f"{it['clean']}{y}"
|
||
if enlace:
|
||
linea += f"\n<code>descargar {enlace}</code>"
|
||
return linea
|
||
|
||
|
||
def trocear(lista, max_len=CHUNK_MAX):
|
||
"""Convierte items en mensajes <= max_len. Cada item no se parte."""
|
||
mensajes, actual = [], []
|
||
long = 0
|
||
for it in lista:
|
||
linea = formatear_linea(it)
|
||
if actual and long + len(linea) + 1 > max_len:
|
||
mensajes.append("\n".join(actual))
|
||
actual, long = [], 0
|
||
actual.append(linea)
|
||
long += len(linea) + 1
|
||
if actual:
|
||
mensajes.append("\n".join(actual))
|
||
return mensajes
|
||
|
||
|
||
def trocear_texto(lineas, max_len=CHUNK_MAX):
|
||
"""Convierte una lista de lineas en mensajes <= max_len sin partir lineas."""
|
||
mensajes, actual = [], []
|
||
long = 0
|
||
for linea in lineas:
|
||
if actual and long + len(linea) + 1 > max_len:
|
||
mensajes.append("\n".join(actual))
|
||
actual, long = [], 0
|
||
actual.append(linea)
|
||
long += len(linea) + 1
|
||
if actual:
|
||
mensajes.append("\n".join(actual))
|
||
return mensajes
|
||
|
||
|
||
def trocear_efimero(texto, items, max_len=CHUNK_MAX):
|
||
"""Trocea un texto con lineas 'titulo + descargar <url>' sin partir lineas."""
|
||
return trocear_texto(texto.split("\n"), max_len)
|
||
|
||
|
||
def generar(fecha=None):
|
||
"""Devuelve (mensajes_normales, adultos_list). Uso desde pi_bot."""
|
||
items = fetch_catalogo()
|
||
normales, adultos = separar_adultos(items)
|
||
sel = seleccionar(normales)
|
||
mensajes = trocear(sel)
|
||
if not mensajes:
|
||
mensajes = ["No hay novedades con buena calidad en castellano."]
|
||
return mensajes, adultos
|
||
|
||
|
||
if __name__ == "__main__":
|
||
msgs, adult = generar()
|
||
for i, m in enumerate(msgs, 1):
|
||
print(f"--- Mensaje {i}/{len(msgs)} ({len(m)} chars) ---")
|
||
print(m)
|
||
print()
|
||
print(f"ADULTOS: {len(adult)} (efimeros, no se listan aqui)")
|