diff --git a/docs/lecciones_aprendidas_vigilancia.md b/docs/lecciones_aprendidas_vigilancia.md index 732e739..71381c3 100644 --- a/docs/lecciones_aprendidas_vigilancia.md +++ b/docs/lecciones_aprendidas_vigilancia.md @@ -118,9 +118,17 @@ 42. **Reiniciar autossh de la Pi**: `systemctl restart autossh-tunel` puede fallar con `remote port forwarding failed for listen port 2222` porque queda un proceso autossh viejo con los puertos. Fix: `pkill -f autossh` + `pkill -f "ssh.*-R"` y luego restart. Al matar el túnel se corta la propia conexión SSH (hay que reconectar). -43. **Comando `pelis` en pi_bot**: Lista novedades con buena calidad en castellano del catálogo Torrentio/RealDebrid con el comando `descargar ` ya montado para copiar/pegar. Filtros: castellano = regex `Esp|Castellano|Span|dual|lat`; calidad mala excluida = `CAMRip|TS.|HDTS|Telesync|KinoRip|Screener|HDCAM`; año >= 2024. Se trocea en mensajes <=3500 chars (Telegram corta a ~4096). Adultos van en mensaje efímero que se borra a los 5 min (`threading.Timer(300, ...)` + `deleteMessage`) y NO se loguean. Módulo autónomo: `vigilancia/pelis.py` (lee `os.environ` primero, luego `.env`). +43. **Comando `pelis` en pi_bot**: Lista novedades con buena calidad en castellano del catálogo Torrentio/RealDebrid con el comando `descargar ` ya montado para copiar/pegar. Filtros: castellano = regex `Esp|Castellano|Span|Spanish|dual|lat|ES|audio`; calidad mala excluida = `CAMRip|TS.|HDTS|Telesync|KinoRip|Screener|HDCAM|r5`; **sin límite de año** (se quitó el `ano_min=2024` — el usuario pidió no limitar). Se trocea en mensajes <=3500 chars (Telegram corta a ~4096). Adultos van en mensaje efímero que se borra a los 5 min (`threading.Timer(300, ...)` + `deleteMessage`) y NO se loguean. Módulo autónomo: `vigilancia/pelis.py` (lee `os.environ` primero, luego `.env`). 44. **El token RD y las credenciales NAS están en el `.env` de la Pi** (`/home/pi/minipc/vigilancia/.env`): `TORRENTIO_RD_TOKEN`, `NAS_HOST=192.168.50.31`, `NAS_PORT=5000`, `NAS_USER`, `NAS_PASS`. Hubo un bug previo donde `NAS_HTTPS=0TORRENTIO_RD_TOKEN=...` quedaron pegados en una línea — ojo al editar el `.env` por SSH. +45. **Comando `series` en pi_bot**: Como `pelis` pero solo series del catálogo, con el comando `descargar ` listo. La detección de castellano en series no sirve igual que en películas (sus nombres no traen marcadores Esp/Castellano). Criterio: la config ya es `language=spanish`, así que se aceptan las series con marcador positivo **o inglés** (`INGLES`: Eng, English, Inglés, EN) o neutro, y se excluyen las que tienen marcador claro de otro idioma (`OTRO_IDIOMA`: Ita\b, MIRCrew, RO\s?EN, South Wind, Silver Zero, Francais, Deutsch, Português). `seleccionar_series()` + `generar_series()` en `vigilancia/pelis.py`. + +46. **Persistencia en la Pi**: Los servicios `pi-bot`, `tv-api` y `autossh-tunel` están `enabled` (arrancan solos al boot). El código desplegado vive en `/home/pi/minipc/vigilancia/` (dueño `pi`), no en /tmp. Verificar siempre `systemctl is-enabled` tras desplegar. + +47. **Comando `serie <nombre>` en pi_bot**: Busca una serie por nombre en el catálogo y lista TODAS sus temporadas/capítulos con el enlace `descargar ` debajo de cada uno. Funciones nuevas en `pelis.py`: `SERIE_EP_RE`/`CAP_RE` (patrones `S01E05` y `Cap.X`), `parsear_episodio()`, `serie_base()` (título base antes del marcador), `_limpiar_titulo_ep()` (quita extensión, calidad, idiomas, `\dx\d\d`, corchetes y tokens sucios), `agrupar_por_serie()`, `formatear_episodio()`, `buscar_serie()`. `generar_series()` ahora agrupa por serie con sus capítulos debajo (2 mensajes ~3750 chars total). Los `EXTRA_SUCIO` se ampliaron con webdl, x264/x265, aac, resoluciones, spanish, cast/dual/lat, es/en, audio/sub/subs, web/dl. + +48. **Despliegue de ficheros a la Pi fiable**: NO usar `echo $b64 | ssh VPS "sshpass ssh pi 'base64 -d | bash'"` encadenado con comillas anidadas (rompe el base64 con `invalid input`). Método robusto: (1) mandar el fichero como base64 por stdin: `Write-Output $b64 | ssh VPS "sshpass ssh pi 'cat > /tmp/x.b64'"`; (2) mandar un script pequeño (también por stdin base64) que haga `base64 -d /tmp/x.b64 > destino && python3 -m py_compile && systemctl restart`. El `base64: invalid input` que sale al final es ruido residual del pipe, no error real. + diff --git a/vigilancia/pelis.py b/vigilancia/pelis.py index e5d348b..58bfc36 100644 --- a/vigilancia/pelis.py +++ b/vigilancia/pelis.py @@ -47,13 +47,43 @@ SERIE_PALABRAS = {"star trek", "stargate", "heidi", "euphoria", "from", "the pit 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"] + "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)", re.IGNORECASE) -# Idiomas que indican castellano/espanol/dual -CASTELLANO = re.compile(r"(Esp|Castellano|Span|dual|lat)", re.IGNORECASE) + 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): @@ -125,20 +155,15 @@ def fetch_catalogo(): return items -def seleccionar(items, ano_min=2024): - """Novedades con buena calidad en castellano (excluye adulto y series sueltas).""" +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 - try: - y = int(it["year"]) if it["year"] else 0 - except ValueError: - y = 0 - if y < ano_min: - continue if it["tipo"] == "serie" and it.get("enlace") and "S01E0" in it["name"]: continue out.append(it) @@ -146,6 +171,118 @@ def seleccionar(items, ano_min=2024): 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 = [], [] @@ -180,9 +317,8 @@ def trocear(lista, max_len=CHUNK_MAX): return mensajes -def trocear_efimero(texto, items, max_len=CHUNK_MAX): - """Trocea un texto con lineas 'titulo + descargar ' sin partir lineas.""" - lineas = texto.split("\n") +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: @@ -196,11 +332,16 @@ def trocear_efimero(texto, items, max_len=CHUNK_MAX): return mensajes -def generar(fecha=None, ano_min=2024): +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, ano_min=ano_min) + sel = seleccionar(normales) mensajes = trocear(sel) if not mensajes: mensajes = ["No hay novedades con buena calidad en castellano."] diff --git a/vigilancia/pi_bot.py b/vigilancia/pi_bot.py index ff9ff87..92d69ae 100644 --- a/vigilancia/pi_bot.py +++ b/vigilancia/pi_bot.py @@ -180,6 +180,25 @@ def procesar(texto, chat_id): else "❌ No se pudo añadir al NAS", chat_id) elif t in ("pelis", "/pelis"): procesar_pelis(chat_id) + elif t in ("series", "/series"): + procesar_series(chat_id) + elif t.startswith("serie ") or t.startswith("/serie "): + nombre = texto.split(" ", 1)[1].strip() + try: + import pelis + except Exception as e: + log(f"serie import error: {e}") + enviar("❌ No se pudo cargar el modulo pelis", chat_id) + return + enviar("🔎 Buscando la serie en el catálogo...", chat_id) + try: + mensajes, encontrada = pelis.buscar_serie(nombre) + except Exception as e: + log(f"serie buscar error: {e}") + enviar(f"❌ Error consultando el catálogo: {e}", chat_id) + return + for m in mensajes: + enviar(m, chat_id) elif t in ("ayuda", "/ayuda", "help", "/help"): enviar("Comandos:\n" "estado — estado de la Pi\n" @@ -189,6 +208,8 @@ def procesar(texto, chat_id): "youtube <URL> — reproduce YouTube y apaga al terminar\n" "descargar <enlace> — añade a Download Station del NAS\n" "pelis — novedades en castellano con comando listo\n" + "series — series en castellano con comando listo\n" + "serie <nombre> — temporadas/capitulos de una serie con enlaces\n" "panorama — fotos de las cámaras\n" "limpieza — limpia procesos atascados de la Pi\n" "reboot — reinicia la Pi (necesita confirmar con /confirmar-reboot)\n" @@ -212,6 +233,44 @@ def procesar(texto, chat_id): sh("echo 'raspberry' | sudo -S reboot") +def procesar_series(chat_id): + """Comando 'series': series con buena calidad en castellano del catalogo, + con el comando 'descargar ' listo. Adultos efimeros (5 min).""" + try: + import pelis + except Exception as e: + log(f"series import error: {e}") + enviar("❌ No se pudo cargar el modulo pelis", chat_id) + return + try: + enviar("📺 Consultando series del catálogo...", chat_id) + mensajes, adultos = pelis.generar_series() + except Exception as e: + log(f"series generar error: {e}") + enviar(f"❌ Error consultando el catálogo: {e}", chat_id) + return + + for m in mensajes: + enviar(m, chat_id) + + if adultos: + lineas = [f"🔞 Contenido adulto ({len(adultos)} novedades) — se borrará en 5 min:"] + for it in adultos[:30]: + y = f" ({it['year']})" if it["year"] else "" + enlace = it.get("enlace") or "" + linea = f"{it['clean']}{y}" + if enlace: + linea += f"\ndescargar {enlace}" + lineas.append(linea) + if len(adultos) > 30: + lineas.append(f"... y {len(adultos) - 30} más") + texto = "\n".join(lineas) + for parte in pelis.trocear_efimero(texto, adultos): + mid = enviar(parte, chat_id) + if mid: + threading.Timer(300, borrar, args=(chat_id, mid)).start() + + def procesar_pelis(chat_id): """Comando 'pelis': novedades con buena calidad en castellano, con el comando 'descargar ' listo para copiar. Los adultos se envian en