""" pelis.py — Listado de novedades con buena calidad en castellano del catalogo RealDebrid/Torrentio, con el comando 'descargar ' 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 descargar {enlace}" 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"📺 {base}") 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: serie <nombre>"], 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 {nombre}."], 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"📺 {base}") 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 '.""" y = f" ({it['year']})" if it["year"] else "" enlace = it.get("enlace") or "" linea = f"{it['clean']}{y}" if enlace: linea += f"\ndescargar {enlace}" 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 ' 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)")