From 46af244e567ae9372ac6714e813f13731ac0858b Mon Sep 17 00:00:00 2001 From: juanjo <130799031+juanminguezsanz2023@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:40:47 +0200 Subject: [PATCH] 04-ago: [videoclub] comando pelis (novedades castellano + comando listo + adultos efimeros 5min) + unidad NAS montada en sobremesa + lecciones --- docs/lecciones_aprendidas_vigilancia.md | 16 ++ vigilancia/montar_nas.ps1 | 49 ++++++ vigilancia/pelis.py | 216 ++++++++++++++++++++++++ vigilancia/pi_bot.py | 60 ++++++- 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 vigilancia/montar_nas.ps1 create mode 100644 vigilancia/pelis.py diff --git a/docs/lecciones_aprendidas_vigilancia.md b/docs/lecciones_aprendidas_vigilancia.md index efccb40..732e739 100644 --- a/docs/lecciones_aprendidas_vigilancia.md +++ b/docs/lecciones_aprendidas_vigilancia.md @@ -106,5 +106,21 @@ 37. **Cada cámara Tapo tiene credenciales RTSP propias**: No comparten usuario/pass necesariamente. El usuario RTSP puede ser el correo de la cuenta (`hermes211%40gmail.com`, con %40) o un usuario de cámara (jjminguez). Probarlas una a una con `ffmpeg`. +### Videoclub / NAS Synology (03-04 agosto) + +38. **DSM 7 exige `_sid` como query parameter** en las APIs de Download Station (GET): `task.cgi?api=...&method=...&_sid=`. Enviar la cookie `sid=` sola devuelve error **105** ("sin permiso") y FileStation da **403**. El login con `format=cookie` devuelve el sid correctamente, pero hay que pasarlo como `_sid` en la URL de cada llamada. + +39. **El error 105 de Download Station es de sesión/permiso, no del usuario**: Con `_sid` en query funciona aunque el usuario `jjminguez` no sea admin. `list` de tareas devuelve 13 tareas (Lilo y Stitch, BLUELOCK, Heidi, etc.), todas del usuario `jjminguez`. + +40. **Despliegue de archivos a la Pi sin git**: `scp` directo al túnel inverso falla (`scp: Connection closed`). Fiable: copiar al VPS (`scp` local→VPS funciona), luego base64 expandido en el VPS: `echo $(base64 -w0 /tmp/f.py) | base64 -d > /tmp/f.py` (los `$()` inline se expanden en el VPS antes de llegar a la Pi) y `sudo cp` al destino. Verificar con `md5sum`. + +41. **Unidad NAS montada en sobremesa via túnel SSH encadenado**: La NAS (192.168.50.31) está en otra red que el sobremesa (192.168.1.239). Se montó `Z:` → `\\127.0.0.1\video` con: (a) la Pi reenvía SMB del NAS al VPS por autossh: `-R 1445:192.168.50.31:445`; (b) túnel local en el PC: `ssh -L 445:127.0.0.1:1445 root@185.187.169.109`; (c) `net use Z: \\127.0.0.1\video`. **Windows SMB usa SIEMPRE el puerto 445** — si el túnel escucha en otro puerto, `net use` da error 67 ("nombre de red no encontrado"). Script: `vigilancia/montar_nas.ps1` + acceso directo en Startup (la tarea programada exige admin). + +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`). + +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. + diff --git a/vigilancia/montar_nas.ps1 b/vigilancia/montar_nas.ps1 new file mode 100644 index 0000000..804cd4b --- /dev/null +++ b/vigilancia/montar_nas.ps1 @@ -0,0 +1,49 @@ +# montar_nas.ps1 - Monta la unidad Z: (share \video de la NAS Synology 192.168.50.31) +# via tunel SSH encadenado: PC -> VPS(185.187.169.109) -> Pi(tunel inverso 1445) -> NAS +# +# Requisitos: +# - Clave SSH del VPS en C:\Users\juanm\Documents\GitHub\contabo +# - Servicio autossh-tunel en la Pi con -R 1445:192.168.50.31:445 +# - Credencial SMB guardada con: cmdkey /add:127.0.0.1 /user:jjminguez /pass:"" +# +# Uso: powershell -File montar_nas.ps1 + +$ErrorActionPreference = "SilentlyContinue" + +function Test-Tunel { + $c = Get-NetTCPConnection -LocalPort 445 -State Listen -ErrorAction SilentlyContinue + return ($null -ne $c) +} + +function Test-Unidad { + $d = Get-PSDrive -Name Z -ErrorAction SilentlyContinue + return ($null -ne $d) +} + +# 1) Levantar tunel local (445 -> VPS:1445 -> Pi -> NAS:445) +if (-not (Test-Tunel)) { + Write-Host "[NAS] Levantando tunel 445 -> VPS:1445..." + Start-Process -FilePath "ssh" -ArgumentList ` + "-i", "C:\Users\juanm\Documents\GitHub\contabo", ` + "-N", "-L", "445:127.0.0.1:1445", ` + "root@185.187.169.109", ` + "-o", "ExitOnForwardFailure=yes", ` + "-o", "ServerAliveInterval=30", ` + "-o", "ServerAliveCountMax=3" ` + -WindowStyle Hidden | Out-Null + Start-Sleep -Seconds 5 +} + +# 2) Montar unidad Z: apuntando al share video (via tunel local) +if (-not (Test-Unidad)) { + Write-Host "[NAS] Montando Z: -> \\127.0.0.1\video ..." + net use Z: \\127.0.0.1\video /persistent:no | Out-Null +} + +# 3) Verificar +if (Test-Unidad) { + $n = (Get-ChildItem "Z:\" -ErrorAction SilentlyContinue | Measure-Object).Count + Write-Host "[NAS] Unidad Z: montada OK ($n elementos)" +} else { + Write-Host "[NAS] ERROR: no se pudo montar Z:" +} diff --git a/vigilancia/pelis.py b/vigilancia/pelis.py new file mode 100644 index 0000000..e5d348b --- /dev/null +++ b/vigilancia/pelis.py @@ -0,0 +1,216 @@ +""" +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"] + +# 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) + + +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, ano_min=2024): + """Novedades con buena calidad en castellano (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) + out.sort(key=lambda x: (int(x["year"] or 0), x["clean"] or ""), reverse=True) + return out + + +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_efimero(texto, items, max_len=CHUNK_MAX): + """Trocea un texto con lineas 'titulo + descargar ' sin partir lineas.""" + lineas = texto.split("\n") + 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 generar(fecha=None, ano_min=2024): + """Devuelve (mensajes_normales, adultos_list). Uso desde pi_bot.""" + items = fetch_catalogo() + normales, adultos = separar_adultos(items) + sel = seleccionar(normales, ano_min=ano_min) + 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)") diff --git a/vigilancia/pi_bot.py b/vigilancia/pi_bot.py index a6f3302..ff9ff87 100644 --- a/vigilancia/pi_bot.py +++ b/vigilancia/pi_bot.py @@ -74,13 +74,28 @@ def estado(): def enviar(texto, chat_id): try: - requests.post( + r = requests.post( f"https://api.telegram.org/bot{TOKEN}/sendMessage", data={"chat_id": chat_id, "text": texto, "parse_mode": "HTML"}, timeout=10, ) + d = r.json() + if d.get("ok"): + return d["result"].get("message_id") except Exception as e: print("Error enviando:", e) + return None + + +def borrar(chat_id, message_id): + try: + requests.post( + f"https://api.telegram.org/bot{TOKEN}/deleteMessage", + data={"chat_id": chat_id, "message_id": message_id}, + timeout=10, + ) + except Exception as e: + print("Error borrando:", e) def mandar_tv(mensaje, simple): @@ -163,6 +178,8 @@ def procesar(texto, chat_id): ok = nas_dl.add_torrent(uri) enviar("📥 Añadido a Download Station del NAS ✅" if ok else "❌ No se pudo añadir al NAS", chat_id) + elif t in ("pelis", "/pelis"): + procesar_pelis(chat_id) elif t in ("ayuda", "/ayuda", "help", "/help"): enviar("Comandos:\n" "estado — estado de la Pi\n" @@ -171,6 +188,7 @@ def procesar(texto, chat_id): "alarma <mensaje> — secuencia completa (sirena x3)\n" "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" "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" @@ -194,6 +212,46 @@ def procesar(texto, chat_id): sh("echo 'raspberry' | sudo -S reboot") +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 + un mensaje efimero que se borra a los 5 minutos y NO se loguea.""" + try: + import pelis + except Exception as e: + log(f"pelis import error: {e}") + enviar("❌ No se pudo cargar el modulo pelis", chat_id) + return + try: + enviar("🎬 Consultando novedades del catálogo...", chat_id) + mensajes, adultos = pelis.generar() + except Exception as e: + log(f"pelis generar error: {e}") + enviar(f"❌ Error consultando el catálogo: {e}", chat_id) + return + + for m in mensajes: + enviar(m, chat_id) + + # Adultos: efimeros (5 min), sin loguear, nunca persisten + 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 monitor_temperatura(): while True: time.sleep(300)