diff --git a/COMANDOS_RAPIDOS.sh b/COMANDOS_RAPIDOS.sh index 8325102..73d2a15 100755 --- a/COMANDOS_RAPIDOS.sh +++ b/COMANDOS_RAPIDOS.sh @@ -26,7 +26,6 @@ cat << 'EOF' docker-compose up -d 3️⃣ Acceder: - Panel: http://localhost:8501 API: http://localhost:8080/docs @@ -43,9 +42,6 @@ cat << 'EOF' 📍 Iniciar SOLO API: ./docker-start-api.sh -📍 Iniciar SOLO Streamlit: - ./docker-start-streamlit.sh - 📍 Detener TODO: ./docker-stop-all.sh # O: @@ -68,11 +64,6 @@ cat << 'EOF' # O: ./docker-logs-separate.sh api -📍 Ver Logs Streamlit: - docker logs -f streamlit_panel - # O: - ./docker-logs-separate.sh streamlit - 📍 Ver Logs AMBOS: docker-compose logs -f # O: @@ -80,12 +71,11 @@ cat << 'EOF' 📍 Ver últimas 100 líneas: docker logs --tail 100 tubescript_api - docker logs --tail 100 streamlit_panel 📍 Ver recursos (CPU/RAM): docker stats # O solo TubeScript: - docker stats tubescript_api streamlit_panel + docker stats tubescript_api ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -94,7 +84,6 @@ cat << 'EOF' 📍 Actualizar yt-dlp: docker exec tubescript_api pip install --upgrade yt-dlp - docker exec streamlit_panel pip install --upgrade yt-dlp 📍 Reconstruir Contenedores: docker-compose down @@ -123,39 +112,30 @@ cat << 'EOF' 📍 Entrar al contenedor (shell): docker exec -it tubescript_api /bin/bash - docker exec -it streamlit_panel /bin/bash 📍 Verificar versión yt-dlp: docker exec tubescript_api yt-dlp --version - docker exec streamlit_panel yt-dlp --version 📍 Probar endpoint manualmente: curl http://localhost:8080/stream/G01-33V6I2g 📍 Ver error completo: docker logs tubescript_api 2>&1 | tail -50 - docker logs streamlit_panel 2>&1 | tail -50 📍 Reiniciar un servicio: docker restart tubescript_api - docker restart streamlit_panel 📍 Ver qué usa un puerto: lsof -i :8080 # API - lsof -i :8501 # Streamlit 📍 Matar proceso en un puerto (macOS/Linux): kill -9 $(lsof -ti:8080) - kill -9 $(lsof -ti:8501) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🌐 ACCESO Y URLs ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📍 Panel Web: - http://localhost:8501 - 📍 API FastAPI: http://localhost:8080 @@ -166,11 +146,9 @@ cat << 'EOF' http://localhost:8080/redoc 📍 Abrir en navegador (macOS): - open http://localhost:8501 open http://localhost:8080/docs 📍 Abrir en navegador (Linux): - xdg-open http://localhost:8501 xdg-open http://localhost:8080/docs @@ -225,7 +203,6 @@ cat << 'EOF' 📍 Ver información de contenedores: docker inspect tubescript_api - docker inspect streamlit_panel 📍 Ver red de Docker: docker network inspect tubescript-network @@ -241,7 +218,6 @@ cat << 'EOF' 📍 Ver procesos dentro del contenedor: docker top tubescript_api - docker top streamlit_panel ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -294,7 +270,6 @@ cat << 'EOF' alias ts-stop='docker-compose down' alias ts-logs='docker-compose logs -f' alias ts-restart='docker-compose restart' - alias ts-panel='open http://localhost:8501' alias ts-api='open http://localhost:8080/docs' 📍 Ver logs con colores (si tienes grc): @@ -302,7 +277,6 @@ cat << 'EOF' 📍 Buscar en logs: docker logs tubescript_api 2>&1 | grep "ERROR" - docker logs streamlit_panel 2>&1 | grep "stream" 📍 Seguir solo errores: docker logs -f tubescript_api 2>&1 | grep -i error @@ -320,8 +294,6 @@ cat << 'EOF' 📍 Abrir documentación: QUICKSTART_COMPLETO.md # Inicio rápido - PANEL_STREAMLIT_GUIA.md # Guía del panel - DOCKER_COMANDOS_SEPARADOS_COMPLETO.md # Comandos Docker API_EXAMPLES.md # Ejemplos de API RESUMEN_IMPLEMENTACION.md # Resumen completo diff --git a/DOCKER_COMANDOS_SEPARADOS.md b/DOCKER_COMANDOS_SEPARADOS.md index 23f2544..517666f 100644 --- a/DOCKER_COMANDOS_SEPARADOS.md +++ b/DOCKER_COMANDOS_SEPARADOS.md @@ -1,3 +1,8 @@ +# Nota importante: El frontend Streamlit ha sido eliminado + +> Se ha eliminado el panel Streamlit en esta rama/proyecto. Las instrucciones a continuación permanecen para referencia histórica, pero el flujo operativo actual es usar únicamente la API (FastAPI) y los comandos de Docker relacionados con el servicio `api`. + + # 🐳 Comandos Docker - Ejecución por Separado ## 📋 Índice diff --git a/DOCKER_COMANDOS_SEPARADOS_COMPLETO.md b/DOCKER_COMANDOS_SEPARADOS_COMPLETO.md index 2feb551..6b91e54 100644 --- a/DOCKER_COMANDOS_SEPARADOS_COMPLETO.md +++ b/DOCKER_COMANDOS_SEPARADOS_COMPLETO.md @@ -90,29 +90,6 @@ docker stop tubescript_api --- -### 2️⃣ Iniciar solo Streamlit - -```bash -./docker-start-streamlit.sh -``` - -**Características:** -- Puerto: `8501` (host) → `8501` (contenedor) -- Panel web: http://localhost:8501 -- Se conecta automáticamente al API usando `API_URL` del archivo `.env` - -**Ver logs:** -```bash -docker logs -f streamlit_panel -``` - -**Detener:** -```bash -docker stop streamlit_panel -``` - ---- - ### 3️⃣ Ver Logs Usa el script de logs para ver la salida de los servicios: @@ -434,3 +411,7 @@ docker-compose down # Detener ambos --- **TubeScript API Pro © 2026** + +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado — las secciones que mencionan la ejecución del panel permanecen como referencia histórica. Para uso actual, emplea solamente la API. diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..bc730af --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,27 @@ +# Dockerfile para la API (FastAPI) con yt-dlp y ffmpeg +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 + +# Instalar ffmpeg y herramientas necesarias +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copiar requirements y instalar dependencias +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt \ + && pip install --no-cache-dir yt-dlp + +# Copiar el resto del código +COPY . /app + +EXPOSE 8000 + +# Comando por defecto para ejecutar la API +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.streamlit b/Dockerfile.streamlit new file mode 100644 index 0000000..09ffef8 --- /dev/null +++ b/Dockerfile.streamlit @@ -0,0 +1,2 @@ +# Dockerfile.streamlit removed - Streamlit UI has been removed from this project. +# If you need to restore the Streamlit frontend, revert this file from version control. diff --git a/GUIA_RAPIDA_DOCKER_FFMPEG.md b/GUIA_RAPIDA_DOCKER_FFMPEG.md index ebc0773..0987bd5 100644 --- a/GUIA_RAPIDA_DOCKER_FFMPEG.md +++ b/GUIA_RAPIDA_DOCKER_FFMPEG.md @@ -1,3 +1,10 @@ +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado. Las instrucciones que mencionan Streamlit se mantienen como referencia, pero el flujo actual usa únicamente la API. + + +# Guía Rápida Docker + FFmpeg + ╔══════════════════════════════════════════════════════════════════════╗ ║ ║ ║ 🚀 GUÍA RÁPIDA: Docker + Endpoint + FFmpeg ║ @@ -66,31 +73,6 @@ docker stop api && docker rm api --- -### Solo Streamlit (Frontend) - -```bash -# Construir -docker build -t tubescript-streamlit . - -# Ejecutar (conectado a API en host) -docker run -d --name panel -p 8501:8501 \ - -e API_URL=http://host.docker.internal:8080 \ - -v $(pwd)/stream_config.json:/app/stream_config.json \ - tubescript-streamlit \ - streamlit run streamlit_app.py \ - --server.port=8501 \ - --server.address=0.0.0.0 \ - --server.headless=true - -# Ver logs -docker logs -f panel - -# Detener -docker stop panel && docker rm panel -``` - ---- - ### Ambos con docker-compose ```bash diff --git a/QUICKSTART.md b/QUICKSTART.md index e227ede..733902a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,3 +1,8 @@ +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado en esta rama; usa la API (FastAPI) para las operaciones. Las instrucciones siguientes han sido adaptadas para el modo API. + + # 🚀 Guía de Inicio Rápido - TubeScript Panel Web ## 📋 Prerequisitos diff --git a/README.md b/README.md index 48e0a99..e32e87a 100644 --- a/README.md +++ b/README.md @@ -366,3 +366,6 @@ Para preguntas o soporte, abre un issue en el repositorio. **⚠️ Advertencia Legal**: Asegúrate de tener los derechos necesarios para retransmitir el contenido. Este software es solo para uso educativo y personal. El uso indebido puede violar los términos de servicio de las plataformas. +# Nota: Streamlit eliminado + +> El panel Streamlit ha sido eliminado en esta rama. El proyecto ahora se centra en la API (FastAPI). Las referencias previas a Streamlit se mantienen solo para histórico. diff --git a/README_DOCKER.md b/README_DOCKER.md new file mode 100644 index 0000000..e107fc7 --- /dev/null +++ b/README_DOCKER.md @@ -0,0 +1,35 @@ +Guía rápida para construir y ejecutar el contenedor del API (FastAPI) por separado + +API (FastAPI) - imagen: tubescript-api:local + +Construir: + +```bash +cd /Users/cesarmendivil/Documents/Nextream/TubeScript-API +docker build -t tubescript-api:local -f Dockerfile.api . +``` + +Ejecutar (exponer puerto 8000): + +```bash +# Monta cookies.txt y pasa la ruta como variable de entorno (opcional) +docker run --rm -p 8000:8000 \ + -v "$(pwd)/cookies.txt:/app/cookies.txt" \ + -e API_COOKIES_PATH="/app/cookies.txt" \ + --name tubescript-api \ + tubescript-api:local +``` + +Usando docker-compose local (solo API): + +```bash +# Levantar el servicio API (usa docker-compose.local.yml) +API_COOKIES_PATH=/app/cookies.txt docker-compose -f docker-compose.local.yml up --build -d + +# Parar y remover: +docker-compose -f docker-compose.local.yml down +``` + +Notas: +- Asegúrate de tener `cookies.txt` en la raíz (o sube con el endpoint /upload_cookies) si necesitas evitar 429/403 por restricciones. +- El `Dockerfile.api` instala `yt-dlp` y `ffmpeg` para que la API pueda extraer m3u8 y manejar subtítulos. diff --git a/START.md b/START.md index b3c41d7..6b13bc2 100644 --- a/START.md +++ b/START.md @@ -1,3 +1,8 @@ +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado en esta rama; por favor use la API (main.py) para todas las operaciones y pruebas. + + # 🎯 INSTRUCCIONES DE INICIO ## ⚡ Inicio Rápido (3 pasos) diff --git a/START_HERE.md b/START_HERE.md index 51423b9..563df48 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -1,3 +1,9 @@ +# Nota: Streamlit eliminado + +> El frontend Streamlit fue eliminado. Usa `main.py` o Docker para ejecutar la API. + +# START HERE + # 🎉 IMPLEMENTACIÓN COMPLETADA - TubeScript API Pro ## ✅ Estado: COMPLETADO Y LISTO PARA USAR diff --git a/demo.sh b/demo.sh index 41bfd3f..5f5f893 100755 --- a/demo.sh +++ b/demo.sh @@ -29,14 +29,6 @@ echo " ✅ Transmisión simultánea a múltiples plataformas" echo " ✅ Contador de tiempo de actividad" echo "" echo "────────────────────────────────────────────────────────────" -echo "🚀 Para Iniciar el Panel Web:" -echo "────────────────────────────────────────────────────────────" -echo "" -echo " streamlit run streamlit_app.py" -echo "" -echo " El panel se abrirá en: http://localhost:8501" -echo "" -echo "────────────────────────────────────────────────────────────" echo "📚 Documentación:" echo "────────────────────────────────────────────────────────────" echo " • README.md - Documentación completa" @@ -64,3 +56,8 @@ echo "════════════════════════ echo " 🎊 ¡Sistema Listo para Usar!" echo "════════════════════════════════════════════════════════════" echo "" +echo "Demo: usar la API (streamlit eliminado)" +echo "" +echo "Accede a la API en: http://localhost:8080/docs" +echo "" +echo "Ejemplo: curl http://localhost:8080/stream/G01-33V6I2g" diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..d2fc118 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile.api + image: tubescript-api:local + container_name: tubescript-api + ports: + - "8000:8000" + environment: + - TZ=UTC + - API_BASE_URL=${API_BASE_URL:-http://localhost:8000} + volumes: + - ./cookies.txt:/app/cookies.txt + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/docs" ] + interval: 30s + timeout: 10s + retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 7a830bc..f28d935 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,41 +26,6 @@ services: retries: 3 start_period: 40s - # Servicio Streamlit - Frontend Panel Web - streamlit-panel: - build: - context: . - dockerfile: Dockerfile - container_name: streamlit_panel - command: streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0 --server.headless=true --browser.gatherUsageStats=false - ports: - - "8501:8501" - volumes: - - ./cookies.txt:/app/cookies.txt:ro # Solo lectura - - ./stream_config.json:/app/stream_config.json - - ./streams_state.json:/app/streams_state.json - - ./data:/app/data # Directorio para datos persistentes - environment: - - PYTHONUNBUFFERED=1 - - API_URL=${API_URL:-http://tubescript-api:8000} # URL de la API, configurable desde .env - restart: unless-stopped - depends_on: - tubescript-api: - condition: service_healthy - networks: - - tubescript-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8501"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - networks: tubescript-network: - driver: bridge - -volumes: - data: - driver: local - + name: tubescript-network diff --git a/docker-compose.yml.bak b/docker-compose.yml.bak new file mode 100644 index 0000000..8efa132 --- /dev/null +++ b/docker-compose.yml.bak @@ -0,0 +1,2 @@ +# Backup del docker-compose original +# Si necesitas restaurarlo, renombra este archivo a docker-compose.yml diff --git a/docker-logs-separate.sh b/docker-logs-separate.sh index 64fd27f..672f55c 100755 --- a/docker-logs-separate.sh +++ b/docker-logs-separate.sh @@ -7,11 +7,10 @@ SERVICE=$1 if [ -z "$SERVICE" ]; then - echo "Uso: ./docker-logs.sh [api|streamlit|both]" + echo "Uso: ./docker-logs.sh [api|both]" echo "" echo "Opciones:" echo " api - Ver logs de FastAPI" - echo " streamlit - Ver logs de Streamlit" echo " both - Ver logs de ambos servicios" exit 1 fi @@ -22,11 +21,6 @@ case "$SERVICE" in echo "" docker logs -f tubescript_api ;; - streamlit) - echo "📋 Logs de Streamlit (Ctrl+C para salir):" - echo "" - docker logs -f streamlit_panel - ;; both) echo "📋 Logs de ambos servicios (Ctrl+C para salir):" echo "" @@ -34,7 +28,7 @@ case "$SERVICE" in ;; *) echo "❌ Opción inválida: $SERVICE" - echo "Usa: api, streamlit o both" + echo "Usa: api o both" exit 1 ;; esac diff --git a/docker-manager.sh b/docker-manager.sh index 7e731be..948abb7 100755 --- a/docker-manager.sh +++ b/docker-manager.sh @@ -24,7 +24,7 @@ show_menu() { echo -e "${GREEN}SERVICIOS:${NC}" echo " 1) 🚀 Iniciar TODOS los servicios (Docker Compose)" echo " 2) 🔧 Iniciar solo FastAPI" - echo " 3) 🖥️ Iniciar solo Streamlit" + # echo " 3) 🖥️ Iniciar solo Streamlit" # Deshabilitado echo "" echo -e "${YELLOW}CONTROL:${NC}" echo " 4) 🛑 Detener todos los servicios" @@ -33,7 +33,7 @@ show_menu() { echo "" echo -e "${BLUE}MONITOREO:${NC}" echo " 7) 📋 Ver logs de FastAPI" - echo " 8) 📋 Ver logs de Streamlit" + # echo " 8) 📋 Ver logs de Streamlit" # Deshabilitado echo " 9) 📋 Ver logs de ambos servicios" echo " 10) 📊 Ver estado de servicios" echo "" @@ -122,12 +122,12 @@ while true; do ./docker-start-api.sh wait_for_key ;; - 3) - echo "" - echo -e "${GREEN}🖥️ Iniciando solo Streamlit...${NC}" - ./docker-start-streamlit.sh - wait_for_key - ;; + # 3) Deshabilitado + # echo "" + # echo -e "${GREEN}🖥️ Iniciando solo Streamlit...${NC}" + # ./docker-start-streamlit.sh + # wait_for_key + # ;; 4) echo "" echo -e "${YELLOW}🛑 Deteniendo todos los servicios...${NC}" @@ -167,13 +167,13 @@ while true; do docker logs -f tubescript_api 2>/dev/null || echo -e "${RED}❌ FastAPI no está corriendo${NC}" wait_for_key ;; - 8) - echo "" - echo -e "${BLUE}📋 Logs de Streamlit (Ctrl+C para salir):${NC}" - echo "" - docker logs -f streamlit_panel 2>/dev/null || echo -e "${RED}❌ Streamlit no está corriendo${NC}" - wait_for_key - ;; + # 8) Deshabilitado + # echo "" + # echo -e "${BLUE}📋 Logs de Streamlit (Ctrl+C para salir):${NC}" + # echo "" + # docker logs -f streamlit_panel 2>/dev/null || echo -e "${RED}❌ Streamlit no está corriendo${NC}" + # wait_for_key + # ;; 9) echo "" echo -e "${BLUE}📋 Logs de ambos servicios (Ctrl+C para salir):${NC}" diff --git a/main.py b/main.py index ad7d42b..e513ab1 100644 --- a/main.py +++ b/main.py @@ -3,11 +3,17 @@ import json import subprocess import requests import time -from fastapi import FastAPI, HTTPException, Query +import re +import tempfile +import glob +from fastapi import FastAPI, HTTPException, UploadFile, File from typing import List, Dict app = FastAPI(title="TubeScript API Pro - JSON Cleaner") +# Ruta de cookies configurable vía variable de entorno: API_COOKIES_PATH +DEFAULT_COOKIES_PATH = os.getenv('API_COOKIES_PATH', './cookies.txt') + def clean_youtube_json(raw_json: Dict) -> List[Dict]: """ Transforma el formato complejo 'json3' de YouTube a un formato @@ -92,11 +98,133 @@ def parse_subtitle_format(content: str, format_type: str) -> List[Dict]: print(f"Error parsing subtitle format {format_type}: {e}") return [] -def get_transcript_data(video_id: str, lang: str): +def extract_video_id(video_id_or_url: str) -> str: + """ + Normaliza la entrada y extrae el video_id si se recibe una URL completa. + Acepta: https://www.youtube.com/watch?v=ID, youtu.be/ID, o el propio ID. + """ + if not video_id_or_url: + return "" + s = video_id_or_url.strip() + # Si ya parece un id (11-20 caracteres alfanuméricos y -, _), retornarlo + if re.match(r'^[A-Za-z0-9_-]{8,20}$', s): + return s + + # Intentar extraer de URL completa + # watch?v= + m = re.search(r'[?&]v=([A-Za-z0-9_-]{8,20})', s) + if m: + return m.group(1) + # youtu.be/ + m = re.search(r'youtu\.be/([A-Za-z0-9_-]{8,20})', s) + if m: + return m.group(1) + # /v/ or /embed/ + m = re.search(r'(?:/v/|/embed/)([A-Za-z0-9_-]{8,20})', s) + if m: + return m.group(1) + + # Si no se detecta, devolver la entrada original (fallará después si es inválida) + return s + + +def get_transcript_data(video_id: str, lang: str = "es"): + video_id = extract_video_id(video_id) + if not video_id: + return None, "video_id inválido o vacío" + url = f"https://www.youtube.com/watch?v={video_id}" - cookies_path = "cookies.txt" - - # Comando ultra-simplificado - SOLO metadatos, sin opciones adicionales + # Leer la ruta de cookies desde la variable de entorno al invocar (permite override en runtime) + cookies_path = os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) + + def load_cookies_from_file(path: str) -> dict: + """Parsea un cookies.txt en formato Netscape a un dict usable por requests.""" + cookies = {} + try: + if not os.path.exists(path): + return cookies + with open(path, 'r', encoding='utf-8', errors='ignore') as fh: + for line in fh: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('\t') + # formato Netscape: domain, flag, path, secure, expiration, name, value + if len(parts) >= 7: + name = parts[5].strip() + value = parts[6].strip() + if name: + cookies[name] = value + else: + # fallback: intento simple name=value + if '=' in line: + k, v = line.split('=', 1) + cookies[k.strip()] = v.strip() + except Exception: + return {} + return cookies + + cookies_for_requests = load_cookies_from_file(cookies_path) + + # Intento rápido y fiable: usar yt-dlp para descargar subtítulos (auto o manual) al tmpdir + try: + with tempfile.TemporaryDirectory() as tmpdl: + # probar variantes de idioma (ej. es y es-419) para cubrir casos regionales + sub_langs = [lang] + if len(lang) == 2: + sub_langs.append(f"{lang}-419") + + ytdlp_cmd = [ + "yt-dlp", + url, + "--skip-download", + "--write-auto-sub", + "--write-sub", + "--sub-format", "vtt/json3/srv3/best", + "-o", os.path.join(tmpdl, "%(id)s.%(ext)s"), + "--no-warnings", + ] + + # agregar sub-lang si hay variantes + if sub_langs: + ytdlp_cmd.extend(["--sub-lang", ",".join(sub_langs)]) + + if os.path.exists(cookies_path): + ytdlp_cmd.extend(["--cookies", cookies_path]) + + try: + result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=120) + # Si yt-dlp falló por rate limiting, devolver mensaje claro + stderr = (result.stderr or "").lower() + if result.returncode != 0 and ('http error 429' in stderr or 'too many requests' in stderr): + return None, "YouTube está limitando las peticiones al descargar subtítulos (HTTP 429). Agrega un cookies.txt válido exportado desde tu navegador y monta en el contenedor, o espera unos minutos." + if result.returncode != 0 and ('http error 403' in stderr or 'forbidden' in stderr): + return None, "Acceso denegado al descargar subtítulos (HTTP 403). El video puede tener restricciones. Usa cookies.txt con una cuenta autorizada." + except subprocess.TimeoutExpired: + pass + + # revisar archivos creados + files = glob.glob(os.path.join(tmpdl, f"{video_id}.*")) + if files: + combined = [] + for fpath in files: + try: + with open(fpath, 'r', encoding='utf-8') as fh: + combined.append(fh.read()) + except Exception: + continue + if combined: + vtt_combined = "\n".join(combined) + parsed = parse_subtitle_format(vtt_combined, 'vtt') + if parsed: + return parsed, None + except FileNotFoundError: + # yt-dlp no instalado, seguiremos con los métodos previos + pass + except Exception: + pass + + # 1) Intento principal: obtener metadata con yt-dlp command = [ "yt-dlp", "--skip-download", @@ -105,136 +233,240 @@ def get_transcript_data(video_id: str, lang: str): url ] - # Agregar cookies solo si el archivo existe if os.path.exists(cookies_path): command.extend(["--cookies", cookies_path]) try: - # 1. Obtener metadatos con yt-dlp result = subprocess.run(command, capture_output=True, text=True, timeout=60) if result.returncode != 0: - error_msg = result.stderr if result.stderr else "Error desconocido" - return None, f"Error de yt-dlp al obtener metadatos: {error_msg[:300]}" + error_msg = result.stderr if result.stderr else "Error desconocido from yt-dlp" + # Si yt-dlp reporta algo, enviar mensaje útil + # No abortar inmediatamente: intentaremos fallback descargando subs con yt-dlp + video_metadata = None + else: + if not result.stdout.strip(): + video_metadata = None + else: + try: + video_metadata = json.loads(result.stdout) + except Exception: + video_metadata = None + except subprocess.TimeoutExpired: + video_metadata = None + except FileNotFoundError: + return None, "yt-dlp no está instalado en el contenedor/entorno. Instala yt-dlp y vuelve a intentar." + except Exception as e: + video_metadata = None - if not result.stdout.strip(): - return None, "No se obtuvieron datos del video. Verifica que el video_id sea correcto." + requested_subs = {} + if video_metadata: + requested_subs = video_metadata.get('requested_subtitles', {}) or {} - video_metadata = json.loads(result.stdout) - - # 2. Buscar subtítulos de forma muy flexible - requested_subs = video_metadata.get('requested_subtitles', {}) - - # Si no hay requested_subtitles, buscar en cualquier fuente disponible + # Buscar en automatic_captions y subtitles si requested_subs está vacío if not requested_subs: - # Intentar con automatic_captions primero - automatic_captions = video_metadata.get('automatic_captions', {}) - if automatic_captions: - # Buscar idiomas que contengan el código solicitado - for lang_key in automatic_captions.keys(): - if lang in lang_key or lang_key.startswith(lang): - # Tomar el PRIMER formato disponible - if automatic_captions[lang_key]: - requested_subs = {lang_key: automatic_captions[lang_key][0]} - break - - # Si no, intentar con subtitles manuales - if not requested_subs: - subtitles = video_metadata.get('subtitles', {}) - if subtitles: - for lang_key in subtitles.keys(): - if lang in lang_key or lang_key.startswith(lang): - if subtitles[lang_key]: - requested_subs = {lang_key: subtitles[lang_key][0]} - break + automatic_captions = video_metadata.get('automatic_captions', {}) or {} + for lang_key, formats in automatic_captions.items(): + if lang in lang_key or lang_key.startswith(lang): + if formats: + requested_subs = {lang_key: formats[0]} + break if not requested_subs: - return None, f"No se encontraron subtítulos para el idioma '{lang}'. El video puede no tener subtítulos disponibles." + subtitles = video_metadata.get('subtitles', {}) or {} + for lang_key, formats in subtitles.items(): + if lang in lang_key or lang_key.startswith(lang): + if formats: + requested_subs = {lang_key: formats[0]} + break - # Obtenemos la URL del primer idioma que coincida + # Si requested_subs está disponible, intentar descargar vía requests la URL proporcionada + if requested_subs: lang_key = next(iter(requested_subs)) sub_url = requested_subs[lang_key].get('url') - if not sub_url: - return None, "No se pudo obtener la URL de los subtítulos." + if sub_url: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8', + 'Referer': 'https://www.youtube.com/', + } - # 3. Descargar el JSON crudo de los servidores de YouTube con headers - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8', - 'Referer': 'https://www.youtube.com/', - } - - # Intentar descargar con retry en caso de rate limiting - max_retries = 3 - response = None - for attempt in range(max_retries): - try: - response = requests.get(sub_url, headers=headers, timeout=30) - - if response.status_code == 200: - break - elif response.status_code == 429: - # Rate limiting - esperar y reintentar - if attempt < max_retries - 1: - import time - time.sleep(2 * (attempt + 1)) # Espera incremental: 2s, 4s, 6s - continue + max_retries = 3 + response = None + for attempt in range(max_retries): + try: + response = requests.get(sub_url, headers=headers, timeout=30, cookies=cookies_for_requests) + if response.status_code == 200: + break + elif response.status_code == 429: + if attempt < max_retries - 1: + time.sleep(2 * (attempt + 1)) + continue + else: + return None, "YouTube está limitando las peticiones (HTTP 429). Agrega cookies.txt o espera unos minutos." + elif response.status_code == 403: + return None, "Acceso denegado (HTTP 403). El video puede tener restricciones de edad o región. Intenta con cookies.txt." + elif response.status_code == 404: + # No encontramos la URL esperada; intentar fallback + response = None + break else: - return None, "YouTube está limitando las peticiones (HTTP 429). Por favor espera unos minutos e intenta nuevamente, o agrega cookies.txt válidas." - elif response.status_code == 403: - return None, f"Acceso denegado (HTTP 403). El video puede tener restricciones geográficas o de edad. Intenta agregar cookies.txt." - elif response.status_code == 404: - return None, f"Subtítulos no encontrados (HTTP 404). El video puede no tener subtítulos disponibles." + return None, f"Error al descargar subtítulos desde YouTube (HTTP {response.status_code})." + except requests.exceptions.Timeout: + if attempt < max_retries - 1: + continue + return None, "Timeout al descargar subtítulos. Intenta nuevamente." + except requests.exceptions.RequestException as e: + return None, f"Error de conexión al descargar subtítulos: {str(e)[:100]}" + + if response and response.status_code == 200: + subtitle_format = requested_subs[lang_key].get('ext', 'json3') + try: + # Si la respuesta parece ser una playlist M3U8 o contiene enlaces a timedtext, + # extraer las URLs y concatenar su contenido (VTT) antes de parsear. + text_body = response.text if isinstance(response.text, str) else None + + if text_body and ('#EXTM3U' in text_body or 'timedtext' in text_body or text_body.strip().lower().startswith('#extm3u')): + # Extraer URLs (líneas que empiecen con http) + urls = re.findall(r'^(https?://\S+)', text_body, flags=re.M) + + # Intento 1: descargar cada URL con requests (usa cookies montadas si aplican) + combined = [] + for idx, u in enumerate(urls): + try: + r2 = requests.get(u, headers=headers, timeout=20, cookies=cookies_for_requests) + if r2.status_code == 200 and r2.text: + combined.append(r2.text) + continue + except Exception: + # fallthrough al fallback + pass + + # Intento 2 (fallback): usar yt-dlp para descargar ese timedtext/url a un archivo temporal + try: + with tempfile.TemporaryDirectory() as tdir: + out_template = os.path.join(tdir, f"timedtext_{idx}.%(ext)s") + ytdlp_cmd = [ + "yt-dlp", + u, + "-o", out_template, + "--no-warnings", + ] + if os.path.exists(cookies_path): + ytdlp_cmd.extend(["--cookies", cookies_path]) + + try: + res2 = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=60) + stderr2 = (res2.stderr or "").lower() + if res2.returncode != 0 and ('http error 429' in stderr2 or 'too many requests' in stderr2): + # rate limit cuando intentamos descargar timedtext + return None, "YouTube está limitando las peticiones al descargar subtítulos (HTTP 429). Agrega cookies.txt válido o intenta más tarde." + if res2.returncode != 0 and ('http error 403' in stderr2 or 'forbidden' in stderr2): + return None, "Acceso denegado al descargar subtítulos (HTTP 403). Intenta con cookies.txt o una cuenta con permisos." + except Exception: + pass + + # leer cualquier archivo creado en el tempdir + for fpath in glob.glob(os.path.join(tdir, "timedtext_*.*")): + try: + with open(fpath, 'r', encoding='utf-8') as fh: + txt = fh.read() + if txt: + combined.append(txt) + except Exception: + continue + except Exception: + continue + + if combined: + vtt_combined = "\n".join(combined) + formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt') + if formatted_transcript: + return formatted_transcript, None + + try: + subtitle_data = response.json() + formatted_transcript = parse_subtitle_format(subtitle_data, subtitle_format) + except json.JSONDecodeError: + formatted_transcript = parse_subtitle_format(response.text, subtitle_format) + except Exception as e: + return None, f"Error al procesar los subtítulos: {str(e)[:200]}" + + if not formatted_transcript: + return None, "Los subtítulos están vacíos o no se pudieron procesar." + + return formatted_transcript, None + + # Fallback: intentarlo descargando subtítulos con yt-dlp a un directorio temporal + # (esto cubre casos en que la metadata no incluye requested_subtitles) + try: + with tempfile.TemporaryDirectory() as tmpdir: + # Intentar con auto-sub primero, luego con sub (manual) + ytdlp_variants = [ + ("--write-auto-sub", "auto"), + ("--write-sub", "manual") + ] + + downloaded = None + for flag, label in ytdlp_variants: + cmd = [ + "yt-dlp", + url, + "--skip-download", + flag, + "--sub-lang", lang, + "--sub-format", "json3/vtt/srv3/best", + "-o", os.path.join(tmpdir, "%(id)s.%(ext)s"), + "--no-warnings", + ] + if os.path.exists(cookies_path): + cmd.extend(["--cookies", cookies_path]) + + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except subprocess.TimeoutExpired: + r = None + + # Revisar si se creó algún archivo en tmpdir + files = glob.glob(os.path.join(tmpdir, f"{video_id}.*")) + if files: + # Tomar el primero válido + downloaded = files[0] + break + + if downloaded: + ext = os.path.splitext(downloaded)[1].lstrip('.') + try: + with open(downloaded, 'r', encoding='utf-8') as fh: + content = fh.read() + except Exception as e: + return None, f"Error leyendo archivo de subtítulos descargado: {str(e)[:200]}" + + # Intentar parsear según extensión conocida + fmt = 'json3' if ext in ('json', 'json3') else ('vtt' if ext == 'vtt' else 'srv3') + formatted_transcript = parse_subtitle_format(content, fmt) + if formatted_transcript: + return formatted_transcript, None else: - return None, f"Error al descargar subtítulos desde YouTube (HTTP {response.status_code}). El video puede tener restricciones." - except requests.exceptions.Timeout: - if attempt < max_retries - 1: - continue - return None, "Timeout al descargar subtítulos. Intenta nuevamente." - except requests.exceptions.RequestException as e: - return None, f"Error de conexión al descargar subtítulos: {str(e)[:100]}" - - if not response or response.status_code != 200: - return None, f"No se pudieron obtener los subtítulos después de {max_retries} intentos." - - # 4. Detectar el formato de subtítulo - subtitle_format = requested_subs[lang_key].get('ext', 'json3') - - # 5. Limpiar y formatear según el tipo - try: - # Intentar parsear como JSON primero - try: - subtitle_data = response.json() - formatted_transcript = parse_subtitle_format(subtitle_data, subtitle_format) - except json.JSONDecodeError: - # Si no es JSON, tratar como texto (VTT) - formatted_transcript = parse_subtitle_format(response.text, subtitle_format) - except Exception as e: - return None, f"Error al procesar los subtítulos: {str(e)[:100]}" - - if not formatted_transcript: - return None, "Los subtítulos están vacíos o no se pudieron procesar." - - return formatted_transcript, None - - except subprocess.TimeoutExpired: - return None, "Timeout al intentar obtener los subtítulos. Intenta nuevamente." - except subprocess.CalledProcessError as e: - return None, f"YouTube bloqueó la petición: {e.stderr[:200]}" - except json.JSONDecodeError: - return None, "Error al procesar los datos de YouTube. El formato de respuesta no es válido." + return None, "Se descargaron subtítulos pero no se pudieron procesar." + except FileNotFoundError: + return None, "yt-dlp no está instalado. Instala yt-dlp en el contenedor/entorno y vuelve a intentar." except Exception as e: - return None, f"Error inesperado: {str(e)[:200]}" + # No hacer crash, retornar mensaje general + return None, f"Error al intentar descargar subtítulos con yt-dlp: {str(e)[:200]}" + + return None, "No se encontraron subtítulos para este video (o el video no tiene subtítulos disponibles). Intenta con otro video en vivo o agrega cookies.txt si hay restricciones." def get_stream_url(video_id: str): """ Obtiene la URL de transmisión m3u8 del video usando yt-dlp con cookies y estrategias de fallback """ url = f"https://www.youtube.com/watch?v={video_id}" - cookies_path = "cookies.txt" - + # Leer la ruta de cookies desde la variable de entorno (si no está, usar valor por defecto) + cookies_path = os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) + # Lista de formatos a intentar en orden de prioridad format_strategies = [ ("best[ext=m3u8]", "Mejor calidad m3u8"), @@ -308,10 +540,17 @@ def transcript_endpoint(video_id: str, lang: str = "es"): if error: raise HTTPException(status_code=400, detail=error) + # Concatenar texto de segmentos para mostrar como texto plano además de los segmentos + try: + combined_text = "\n".join([seg.get('text', '') for seg in data if seg.get('text')]) + except Exception: + combined_text = "" + return { "video_id": video_id, "count": len(data), - "segments": data + "segments": data, + "text": combined_text } @app.get("/stream/{video_id}") @@ -355,6 +594,21 @@ def stream_endpoint(video_id: str): } } +@app.post('/upload_cookies') +async def upload_cookies(file: UploadFile = File(...)): + """Endpoint para subir cookies.txt y guardarlo en el servidor en /app/cookies.txt""" + try: + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail='Archivo vacío') + target = 'cookies.txt' + # Guardar con permisos de escritura + with open(target, 'wb') as fh: + fh.write(content) + return {"detail": "cookies.txt guardado correctamente", "path": os.path.abspath(target)} + except Exception as e: + raise HTTPException(status_code=500, detail=f'Error al guardar cookies: {str(e)[:200]}') + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt index 2742163..57a6de2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ fastapi -uvicorn -# No necesitamos la otra librería, yt-dlp hará todo el trabajo pesado +uvicorn[standard] requests yt-dlp -streamlit -streamlit-autorefresh +python-multipart +pydantic +# Nota: streamlit y paquetes relacionados fueron removidos porque el frontend fue eliminado diff --git a/streamlit_app.py b/streamlit_app.py index 2477ab3..a2b29f9 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -1,1131 +1,6 @@ -import streamlit as st -import json -import os -import subprocess -import threading -import time -import requests -from datetime import datetime -from streamlit_autorefresh import st_autorefresh +# Streamlit frontend removido +# El panel Streamlit fue eliminado intencionalmente del repositorio. +# Conservamos este archivo como placeholder para historial. Si necesitas +# recuperarlo, revierte este cambio desde el control de versiones. -# Configuración de la página -st.set_page_config( - page_title="TubeScript - Panel de Control", - page_icon="📺", - layout="wide", - initial_sidebar_state="expanded" -) - -# Archivo para guardar configuraciones -CONFIG_FILE = "stream_config.json" -STREAMS_STATE_FILE = "streams_state.json" -PROCESS_STATE_FILE = "process_state.json" - -# Diccionario de procesos activos -if 'active_processes' not in st.session_state: - st.session_state.active_processes = {} - -if 'search_results' not in st.session_state: - st.session_state.search_results = [] - -# ==================== FUNCIONES DE CONFIGURACIÓN ==================== - -def save_process_state(): - """Guardar estado de procesos activos en archivo para persistencia""" - state = {} - for key, info in st.session_state.active_processes.items(): - state[key] = { - 'pid': info.get('pid'), - 'platform': info.get('platform'), - 'start_time': info.get('start_time'), - 'status': info.get('status'), - 'rtmp_url': info.get('rtmp_url'), - 'enabled': info.get('enabled', True) - } - - with open(PROCESS_STATE_FILE, 'w') as f: - json.dump(state, f, indent=2) - -def load_process_state(): - """Cargar estado de procesos desde archivo""" - if os.path.exists(PROCESS_STATE_FILE): - try: - with open(PROCESS_STATE_FILE, 'r') as f: - return json.load(f) - except: - return {} - return {} - -def check_process_alive(pid): - """Verificar si un proceso está corriendo usando su PID""" - try: - # En Unix/Linux/Mac, enviar señal 0 para verificar si existe - os.kill(pid, 0) - return True - except OSError: - return False - except Exception: - return False - -def load_config(): - """Cargar configuración desde archivo JSON""" - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, 'r') as f: - return json.load(f) - return { - "platforms": { - "YouTube": {"rtmp_url": "", "stream_key": "", "enabled": False}, - "Facebook": {"rtmp_url": "", "stream_key": "", "enabled": False}, - "Twitch": {"rtmp_url": "", "stream_key": "", "enabled": False}, - "X (Twitter)": {"rtmp_url": "", "stream_key": "", "enabled": False}, - "Instagram": {"rtmp_url": "", "stream_key": "", "enabled": False}, - "TikTok": {"rtmp_url": "", "stream_key": "", "enabled": False}, - } - } - -def save_config(config): - """Guardar configuración en archivo JSON""" - with open(CONFIG_FILE, 'w') as f: - json.dump(config, f, indent=2) - -def load_streams_state(): - """Cargar estado de las transmisiones""" - if os.path.exists(STREAMS_STATE_FILE): - with open(STREAMS_STATE_FILE, 'r') as f: - return json.load(f) - return {} - -def save_streams_state(state): - """Guardar estado de las transmisiones""" - with open(STREAMS_STATE_FILE, 'w') as f: - json.dump(state, f, indent=2) - -# ==================== FUNCIONES DE YOUTUBE ==================== - -def search_youtube_live(query): - """Buscar videos en vivo de YouTube usando yt-dlp""" - try: - command = [ - "yt-dlp", - "--flat-playlist", - "--dump-json", - f"ytsearch10:{query} live" - ] - - result = subprocess.run(command, capture_output=True, text=True, timeout=30) - - if result.returncode != 0: - return [] - - videos = [] - for line in result.stdout.strip().split('\n'): - if line: - try: - video_data = json.loads(line) - if video_data.get('is_live'): - videos.append({ - 'id': video_data.get('id'), - 'title': video_data.get('title'), - 'channel': video_data.get('channel'), - 'url': f"https://www.youtube.com/watch?v={video_data.get('id')}" - }) - except: - continue - - return videos - except Exception as e: - st.error(f"Error al buscar videos: {str(e)}") - return [] - -def get_video_info(video_url): - """Obtener información del video de YouTube""" - try: - command = [ - "yt-dlp", - "--dump-json", - "--skip-download", - video_url - ] - - result = subprocess.run(command, capture_output=True, text=True, timeout=30) - - if result.returncode == 0: - video_data = json.loads(result.stdout) - return { - 'id': video_data.get('id'), - 'title': video_data.get('title'), - 'channel': video_data.get('channel'), - 'is_live': video_data.get('is_live', False), - 'thumbnail': video_data.get('thumbnail'), - 'url': video_url - } - except Exception as e: - st.error(f"Error al obtener información del video: {str(e)}") - return None - -def get_stream_url(video_url): - """Obtener la URL del stream m3u8 de YouTube para transmisión con estrategia de fallback""" - cookies_path = "cookies.txt" - - # Lista de formatos a intentar en orden de prioridad - format_strategies = [ - ("best", "Mejor calidad disponible"), - ("best[ext=mp4]", "Mejor calidad MP4"), - ("bestvideo+bestaudio", "Mejor video y audio separados"), - ("worst", "Menor calidad (más compatible)"), - ] - - for format_spec, description in format_strategies: - try: - st.info(f"🔄 Intentando: {description}...") - - command = [ - "yt-dlp", - "-g", # Obtener solo la URL - "-f", format_spec, - "--no-warnings", - "--no-check-certificate", - "--extractor-args", "youtube:player_client=android", # Usar cliente Android (más compatible) - ] - - if os.path.exists(cookies_path): - command.extend(["--cookies", cookies_path]) - - command.append(video_url) - - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=60, - env={**os.environ, "PYTHONIOENCODING": "utf-8"} - ) - - if result.returncode == 0 and result.stdout.strip(): - # Obtener todas las URLs - urls = [url.strip() for url in result.stdout.strip().split('\n') if url.strip()] - - if urls: - # Buscar la mejor URL - stream_url = None - - # Prioridad 1: URLs con m3u8 - for url in urls: - if 'm3u8' in url.lower(): - stream_url = url - break - - # Prioridad 2: URLs de googlevideo - if not stream_url: - for url in urls: - if 'googlevideo.com' in url: - stream_url = url - break - - # Prioridad 3: Cualquier URL HTTP válida - if not stream_url: - for url in urls: - if url.startswith('http'): - stream_url = url - break - - if stream_url: - st.success(f"✅ URL obtenida con: {description}") - return stream_url - - # Si llegamos aquí, este formato falló - # Mostrar error completo para debugging - if result.stderr: - error_detail = result.stderr.strip() - # Mostrar solo las primeras líneas para no saturar la UI - error_lines = error_detail.split('\n')[:10] - error_preview = '\n'.join(error_lines) - - st.warning(f"⚠️ Formato {description} falló:") - with st.expander("Ver error detallado", expanded=False): - st.code(error_preview, language=None) - if len(error_detail.split('\n')) > 10: - st.caption("... (error truncado, ver logs completos)") - - except subprocess.TimeoutExpired: - st.warning(f"⏱️ Timeout con formato: {description}") - continue - except Exception as e: - st.error(f"❌ Error con formato {description}: {str(e)}") - continue - - # Si todos los formatos fallaron - st.error("❌ No se pudo obtener la URL del stream con ningún formato") - - with st.expander("🔍 Ver detalles del error", expanded=False): - st.warning("⚠️ Posibles causas:") - st.markdown(""" - 1. **El video no está EN VIVO** 🔴 - - Verifica que el video tenga el indicador rojo "EN VIVO" - - Los videos pregrabados no funcionan - - 2. **Video con restricciones** - - Restricciones geográficas - - Restricciones de edad - - Video privado o no listado - - 3. **Problema con yt-dlp** - - yt-dlp puede estar desactualizado o corrupto - - YouTube cambió su API - - 4. **Problema de red** - - Conexión lenta o inestable - - Firewall bloqueando la conexión - """) - - st.info("💡 Soluciones:") - st.markdown(""" - 1. **Actualizar yt-dlp en el contenedor:** - ```bash - docker exec streamlit_panel pip install --force-reinstall yt-dlp - ``` - - 2. **Intenta con un canal de noticias 24/7:** - - CNN, BBC News, DW News (siempre en vivo) - - 3. **Agrega cookies de YouTube:** - - Exporta cookies con extensión de navegador - - Guarda como `cookies.txt` en el directorio raíz - - 4. **Reconstruir contenedor:** - ```bash - docker-compose down - docker-compose build --no-cache - docker-compose up -d - ``` - """) - - return None - -# ==================== FUNCIONES DE STREAMING ==================== - -def start_ffmpeg_stream(source_url, rtmp_url, stream_key, platform_name): - """Iniciar transmisión con FFmpeg usando la URL m3u8 directamente y guardar PID""" - full_rtmp = f"{rtmp_url}/{stream_key}" if stream_key else rtmp_url - - # Comando FFmpeg optimizado para streaming desde m3u8 - command = [ - "ffmpeg", - "-re", # Leer input a velocidad nativa (importante para streaming en vivo) - "-i", source_url, # URL m3u8 de YouTube - "-c", "copy", # Copiar codec sin recodificar (video y audio) - "-f", "flv", # Formato FLV para RTMP/RTMPS - "-loglevel", "error", # Solo mostrar errores - full_rtmp # URL RTMP de destino - ] - - try: - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # Obtener PID del proceso - pid = process.pid - - # Guardar el proceso en el diccionario con toda la información - process_key = f"{platform_name}" - st.session_state.active_processes[process_key] = { - 'process': process, - 'pid': pid, - 'platform': platform_name, - 'start_time': datetime.now().isoformat(), - 'status': 'running', - 'command': ' '.join(command), - 'rtmp_url': rtmp_url, - 'enabled': True - } - - # Guardar estado en archivo JSON para persistencia - save_process_state() - - return True, f"Transmisión iniciada (PID: {pid})" - except Exception as e: - return False, f"Error al iniciar transmisión: {str(e)}" - -def stop_stream(platform_name): - """Detener transmisión de una plataforma usando su PID""" - process_key = f"{platform_name}" - - if process_key in st.session_state.active_processes: - process_info = st.session_state.active_processes[process_key] - process = process_info['process'] - pid = process_info.get('pid') - - try: - # Intentar terminar gracefully - process.terminate() - process.wait(timeout=5) - except: - # Forzar si no responde - try: - process.kill() - except: - # Si el proceso ya no existe, intentar con PID - if pid and check_process_alive(pid): - try: - os.kill(pid, 15) # SIGTERM - except: - pass - - # Eliminar del diccionario - del st.session_state.active_processes[process_key] - - # Actualizar estado persistido - save_process_state() - - return True, "Transmisión detenida" - - return False, "No hay transmisión activa" - -def check_stream_health(platform_name): - """Verificar el estado de salud de una transmisión usando PID""" - process_key = f"{platform_name}" - - if process_key not in st.session_state.active_processes: - return "stopped", "Detenido" - - process_info = st.session_state.active_processes[process_key] - process = process_info.get('process') - pid = process_info.get('pid') - - # Verificar usando PID primero - if pid and not check_process_alive(pid): - # El proceso no está vivo según PID - st.session_state.active_processes[process_key]['status'] = 'error' - return "error", f"Error: Proceso detenido (PID: {pid})" - - # Verificar si el proceso sigue corriendo - if process and process.poll() is None: - # Proceso activo - return "running", f"Transmitiendo (PID: {pid})" - else: - # Proceso terminó - st.session_state.active_processes[process_key]['status'] = 'error' - return "error", f"Error: Proceso terminado (PID: {pid})" - -# ==================== INTERFAZ DE USUARIO ==================== - -def render_sidebar(): - """Renderizar barra lateral con configuración mejorada""" - with st.sidebar: - st.title("⚙️ Configuración") - - config = load_config() - - # URLs RTMP por defecto para cada plataforma - default_rtmp_urls = { - "YouTube": "rtmp://a.rtmp.youtube.com/live2", - "Facebook": "rtmps://live-api-s.facebook.com:443/rtmp/", - "Twitch": "rtmp://live.twitch.tv/app", - "X (Twitter)": "rtmps://fa.contribute.live-video.net/app", - "Instagram": "rtmps://live-upload.instagram.com:443/rtmp/", - "TikTok": "rtmp://push.live.tiktok.com/live/" - } - - st.subheader("Plataformas de Streaming") - - # Contador de plataformas configuradas - configured_count = sum( - 1 for p in config["platforms"].values() - if p.get("stream_key") and p.get("rtmp_url") - ) - st.caption(f"✅ {configured_count} de {len(config['platforms'])} configuradas") - - st.write("") # Espaciador - - for platform_name, platform_config in config["platforms"].items(): - with st.expander(f"🎥 {platform_name}", expanded=False): - - # Switch para habilitar/deshabilitar la plataforma - is_enabled = platform_config.get("enabled", False) - enabled = st.toggle( - "Habilitar esta plataforma", - value=is_enabled, - key=f"enabled_{platform_name}", - help="Activa para poder usar esta plataforma en transmisiones" - ) - - st.write("") # Espaciador - - # Stream Key (campo principal) - st.markdown("**🔑 Stream Key** (Requerido)") - stream_key = st.text_input( - "Ingresa tu Stream Key", - value=platform_config.get("stream_key", ""), - type="password", - key=f"key_{platform_name}", - placeholder="Pega aquí tu Stream Key...", - help=f"Obtén tu Stream Key desde el panel de {platform_name}", - label_visibility="collapsed" - ) - - st.write("") # Espaciador - - # RTMP URL con valor por defecto - st.markdown("**🌐 RTMP URL** (Opcional)") - - # Usar URL por defecto si no hay ninguna configurada - current_rtmp = platform_config.get("rtmp_url", "") - if not current_rtmp and platform_name in default_rtmp_urls: - current_rtmp = default_rtmp_urls[platform_name] - - # Checkbox para usar URL personalizada - use_custom_rtmp = st.checkbox( - "Usar URL RTMP personalizada", - value=bool(current_rtmp and current_rtmp != default_rtmp_urls.get(platform_name)), - key=f"custom_rtmp_{platform_name}", - help="Marca si necesitas usar una URL RTMP diferente a la por defecto" - ) - - if use_custom_rtmp: - rtmp_url = st.text_input( - "URL RTMP personalizada", - value=current_rtmp, - key=f"rtmp_{platform_name}", - placeholder=f"Ejemplo: {default_rtmp_urls.get(platform_name, 'rtmp://...')}", - help="Ingresa la URL RTMP completa", - label_visibility="collapsed" - ) - else: - # Usar URL por defecto - rtmp_url = default_rtmp_urls.get(platform_name, "") - st.info(f"📍 Usando URL por defecto:\n`{rtmp_url}`") - - st.write("") # Espaciador - - # Indicador de estado - is_configured = bool(stream_key and rtmp_url) - if is_configured and enabled: - st.success("✅ Plataforma lista para usar") - elif is_configured and not enabled: - st.warning("⚠️ Configurada pero deshabilitada") - elif not stream_key: - st.error("❌ Falta Stream Key") - elif not rtmp_url: - st.error("❌ Falta RTMP URL") - - # Guardar configuración - config["platforms"][platform_name]["rtmp_url"] = rtmp_url - config["platforms"][platform_name]["stream_key"] = stream_key - config["platforms"][platform_name]["enabled"] = enabled - - st.write("") # Espaciador - - # Botón de guardar - if st.button("💾 Guardar Configuración", use_container_width=True, type="primary"): - save_config(config) - st.success("✅ Configuración guardada correctamente") - time.sleep(1) - st.rerun() - - st.divider() - - # Guía rápida - with st.expander("❓ ¿Cómo obtener mi Stream Key?", expanded=False): - st.markdown(""" - ### YouTube - 1. Ve a [YouTube Studio](https://studio.youtube.com) - 2. Click en "Emisión en Vivo" → "Stream" - 3. Copia la "Clave de transmisión" - - ### Facebook - 1. Ve a [Creator Studio](https://business.facebook.com/creatorstudio) - 2. Click en "Emisión en vivo" - 3. Copia la "Clave de stream" - - ### Twitch - 1. Ve a [Dashboard](https://dashboard.twitch.tv/settings/stream) - 2. En "Configuración del canal" - 3. Copia la "Clave de transmisión principal" - - ### X (Twitter) - 1. Ve a [Media Studio](https://studio.twitter.com) - 2. Click en "Crear" → "En vivo" - 3. Copia la "Stream Key" - """) - - # Información de URLs por defecto - with st.expander("📋 URLs RTMP por Defecto", expanded=False): - for platform, url in default_rtmp_urls.items(): - st.code(f"{platform}:\n{url}", language=None) - -def render_search_panel(): - """Renderizar panel de búsqueda""" - st.header("🔍 Buscar Video en Vivo") - - col1, col2 = st.columns([3, 1]) - - with col1: - search_query = st.text_input( - "Buscar transmisión en vivo de YouTube", - placeholder="Ej: noticias, deportes, gaming...", - key="search_input" - ) - - with col2: - st.write("") # Espaciador - search_button = st.button("🔍 Buscar", use_container_width=True) - - # Opción para URL directa - video_url = st.text_input( - "O ingresa la URL directa del video", - placeholder="https://www.youtube.com/watch?v=...", - key="url_input" - ) - - if search_button and search_query: - with st.spinner("Buscando videos en vivo..."): - results = search_youtube_live(search_query) - st.session_state.search_results = results - - # Mostrar resultados de búsqueda - if st.session_state.search_results: - st.subheader("📺 Resultados") - - for video in st.session_state.search_results: - col1, col2 = st.columns([4, 1]) - - with col1: - st.markdown(f"**{video['title']}**") - st.caption(f"Canal: {video['channel']}") - - with col2: - if st.button("Seleccionar", key=f"select_{video['id']}"): - st.session_state.selected_video_url = video['url'] - # Guardar info básica del video - st.session_state.selected_video_info = { - 'title': video['title'], - 'channel': video['channel'], - 'url': video['url'], - 'id': video['id'], - 'is_live': True - } - st.rerun() - - # Video seleccionado por URL directa - if video_url: - st.session_state.selected_video_url = video_url - # Resetear stream_url para forzar nueva obtención - if 'stream_url' in st.session_state: - del st.session_state.stream_url - - if 'selected_video_url' in st.session_state and st.session_state.selected_video_url: - st.divider() - st.subheader("✅ Video Seleccionado") - - video_info = get_video_info(st.session_state.selected_video_url) - - if video_info: - # Guardar info completa del video en session_state - st.session_state.selected_video_info = video_info - - col1, col2 = st.columns([1, 3]) - - with col1: - if video_info.get('thumbnail'): - st.image(video_info['thumbnail'], use_container_width=True) - - with col2: - st.markdown(f"**{video_info['title']}**") - st.caption(f"Canal: {video_info['channel']}") - - if video_info['is_live']: - st.success("🔴 EN VIVO") - else: - st.warning("⚠️ Este video no está en vivo") - - st.code(video_info['url'], language=None) - -def render_streaming_control(): - """Renderizar panel de control de transmisión""" - st.header("🎛️ Control de Transmisión") - - if 'selected_video_url' not in st.session_state: - st.info("ℹ️ Selecciona un video primero para comenzar a transmitir") - return - - config = load_config() - - # Obtener información del video para preview - if 'selected_video_info' in st.session_state: - video_info = st.session_state.selected_video_info - - # Mostrar preview del video - st.subheader("📺 Video Seleccionado") - - col1, col2 = st.columns([1, 2]) - - with col1: - if video_info.get('thumbnail'): - st.image(video_info['thumbnail'], use_container_width=True) - - with col2: - st.markdown(f"**{video_info.get('title', 'Sin título')}**") - st.caption(f"Canal: {video_info.get('channel', 'Desconocido')}") - - if video_info.get('is_live'): - st.success("🔴 EN VIVO") - else: - st.warning("⚠️ Este video no está en vivo") - - st.caption(f"🔗 {st.session_state.selected_video_url}") - - st.divider() - - # Obtener URL del stream - if 'stream_url' not in st.session_state: - with st.spinner("Obteniendo URL del stream m3u8..."): - stream_url = get_stream_url(st.session_state.selected_video_url) - if stream_url: - st.session_state.stream_url = stream_url - else: - st.error("❌ No se pudo obtener la URL del stream") - return - - st.success("✅ Stream listo para transmitir") - - # Mostrar la URL m3u8 obtenida - with st.expander("🔗 Ver URL m3u8 del Stream", expanded=False): - st.code(st.session_state.stream_url, language=None) - st.caption("Esta es la URL HLS/m3u8 que se usará para la transmisión") - - # Mostrar ejemplo del comando FFmpeg que se usará - st.markdown("**Ejemplo de comando FFmpeg:**") - example_rtmp = "rtmps://live-api-s.facebook.com:443/rtmp/TU-STREAM-KEY" - example_cmd = f'ffmpeg -re -i "{st.session_state.stream_url}" -c copy -f flv {example_rtmp}' - st.code(example_cmd, language="bash") - - st.divider() - - # Filtrar solo plataformas configuradas Y habilitadas - configured_platforms = { - name: conf for name, conf in config["platforms"].items() - if conf["rtmp_url"] and conf["stream_key"] and conf.get("enabled", False) - } - - if not configured_platforms: - st.warning("⚠️ No hay plataformas habilitadas y configuradas") - st.info("💡 Ve a la barra lateral y:") - st.markdown(""" - 1. Activa el switch "Habilitar esta plataforma" - 2. Ingresa tu Stream Key - 3. (Opcional) Personaliza la RTMP URL - 4. Guarda la configuración - """) - return - - # Mostrar controles para plataformas configuradas - st.subheader(f"🎯 Plataformas Configuradas ({len(configured_platforms)})") - - # Botón para iniciar todas las transmisiones - col1, col2, col3 = st.columns([2, 2, 2]) - - with col1: - # Contar cuántas están transmitiendo - transmitting_count = sum( - 1 for name in configured_platforms.keys() - if check_stream_health(name)[0] == "running" - ) - - if transmitting_count == 0: - if st.button("▶️ Iniciar Todas las Transmisiones", type="primary", use_container_width=True): - for platform_name in configured_platforms.keys(): - start_ffmpeg_stream( - st.session_state.stream_url, - configured_platforms[platform_name]["rtmp_url"], - configured_platforms[platform_name]["stream_key"], - platform_name - ) - st.success(f"✅ Iniciando transmisión en {len(configured_platforms)} plataformas...") - st.rerun() - - with col2: - if transmitting_count > 0: - if st.button("⏹️ Detener Todas las Transmisiones", type="secondary", use_container_width=True): - for platform_name in configured_platforms.keys(): - stop_stream(platform_name) - st.info("🛑 Deteniendo todas las transmisiones...") - st.rerun() - - with col3: - st.metric("Transmitiendo", f"{transmitting_count}/{len(configured_platforms)}") - - st.divider() - - # LISTA DE REDES PREPARADAS Y LISTAS - st.subheader("📋 Redes Habilitadas y Listas para Transmitir") - - # Crear tabla de resumen - ready_platforms = [] - for platform_name, platform_config in configured_platforms.items(): - status, _ = check_stream_health(platform_name) - process_info = st.session_state.active_processes.get(platform_name, {}) - pid = process_info.get('pid', '-') - - # Determinar si está lista y habilitada - is_ready = platform_config["rtmp_url"] and platform_config["stream_key"] - is_enabled = platform_config.get("enabled", False) - - ready_platforms.append({ - "Red Social": platform_name, - "Estado": "🟢 Activo" if status == "running" else "⚪ Listo" if status == "stopped" else "🔴 Error", - "PID": pid if pid != '-' else '-', - "Habilitada": "✅ Sí" if is_enabled else "❌ No", - "Configurada": "✅ Sí" if is_ready else "❌ No" - }) - - # Mostrar tabla - if ready_platforms: - st.dataframe( - ready_platforms, - use_container_width=True, - hide_index=True - ) - - # Resumen rápido - col_summary1, col_summary2, col_summary3, col_summary4 = st.columns(4) - - total = len(ready_platforms) - running = sum(1 for p in ready_platforms if "🟢" in p["Estado"]) - ready = sum(1 for p in ready_platforms if "✅" in p["Configurada"]) - errors = sum(1 for p in ready_platforms if "🔴" in p["Estado"]) - - with col_summary1: - st.metric("Total Plataformas", total) - - with col_summary2: - st.metric("Transmitiendo", running, delta=None if running == 0 else "Activas") - - with col_summary3: - st.metric("Listas", ready) - - with col_summary4: - st.metric("Errores", errors, delta="Revisar" if errors > 0 else None, delta_color="inverse") - - st.divider() - - # Mostrar tarjetas de plataformas en columnas - st.subheader("🎛️ Control Individual por Plataforma") - cols = st.columns(2) - - for idx, (platform_name, platform_config) in enumerate(configured_platforms.items()): - with cols[idx % 2]: - render_platform_card(platform_name, platform_config) - -def render_platform_card(platform_name, platform_config): - """Renderizar tarjeta de control con switch para cada plataforma""" - - # Verificar si la plataforma está habilitada en la configuración - is_platform_enabled = platform_config.get("enabled", False) - - with st.container(border=True): - # Verificar estado - status, status_msg = check_stream_health(platform_name) - - # Obtener información del proceso si existe - process_info = st.session_state.active_processes.get(platform_name, {}) - pid = process_info.get('pid', 'N/A') - start_time_str = process_info.get('start_time') - - # Determinar color del semáforo y estilo - if status == "running": - status_emoji = "🟢" - status_text = "TRANSMITIENDO" - is_switch_on = True - elif status == "error": - status_emoji = "🔴" - status_text = "ERROR" - is_switch_on = False - else: - status_emoji = "⚪" - status_text = "LISTO" - is_switch_on = False - - # Encabezado con icono de plataforma - col1, col2, col3 = st.columns([2, 1, 1]) - - with col1: - # Mostrar si está deshabilitada - if not is_platform_enabled: - st.markdown(f"### 🎥 {platform_name} ⚠️") - else: - st.markdown(f"### 🎥 {platform_name}") - - with col2: - st.markdown(f"

{status_emoji}

", unsafe_allow_html=True) - - with col3: - st.markdown(f"

PID: {pid}

", unsafe_allow_html=True) - - # Estado - st.markdown(f"**Estado:** {status_text}") - st.caption(status_msg) - - # Advertencia si está deshabilitada - if not is_platform_enabled: - st.warning("⚠️ Plataforma deshabilitada en configuración") - st.info("💡 Ve a la barra lateral y activa el switch de esta plataforma") - return # No mostrar el switch de transmisión si está deshabilitada - - # Switch principal para habilitar/deshabilitar transmisión - st.write("") # Espaciador - - switch_label = f"🔴 Transmitir a {platform_name}" - - # El valor del switch depende del estado actual - switch_value = st.toggle( - switch_label, - value=is_switch_on, - key=f"switch_{platform_name}", - help=f"Activar/Desactivar transmisión a {platform_name}" - ) - - # Detectar cambio en el switch - if switch_value != is_switch_on: - if switch_value: - # Usuario activó el switch - INICIAR transmisión - if 'stream_url' in st.session_state: - with st.spinner(f"Iniciando transmisión en {platform_name}..."): - success, message = start_ffmpeg_stream( - st.session_state.stream_url, - platform_config["rtmp_url"], - platform_config["stream_key"], - platform_name - ) - if success: - st.success(f"✅ {message}") - else: - st.error(f"❌ {message}") - time.sleep(0.5) # Pequeña pausa para que el usuario vea el mensaje - st.rerun() - else: - # Usuario desactivó el switch - DETENER transmisión - with st.spinner(f"Deteniendo transmisión en {platform_name}..."): - success, message = stop_stream(platform_name) - if success: - st.info(f"🛑 {message}") - else: - st.error(f"❌ {message}") - time.sleep(0.5) - st.rerun() - - # Información adicional cuando está transmitiendo - if status == "running" and start_time_str: - try: - start_time = datetime.fromisoformat(start_time_str) - duration = datetime.now() - start_time - hours, remainder = divmod(duration.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - col_time1, col_time2 = st.columns(2) - - with col_time1: - st.metric("⏱️ Tiempo Activo", f"{hours:02d}:{minutes:02d}:{seconds:02d}") - - with col_time2: - # Verificar si el proceso está vivo - is_alive = check_process_alive(pid) if isinstance(pid, int) else False - health_status = "✅ Activo" if is_alive else "❌ Inactivo" - st.metric("🔍 Proceso", health_status) - except: - pass - - # Información de configuración (colapsable) - with st.expander("ℹ️ Detalles de Configuración", expanded=False): - st.text(f"RTMP URL: {platform_config['rtmp_url']}") - st.text(f"Stream Key: {'*' * 20}...{platform_config['stream_key'][-4:]}") - st.text(f"Habilitada: {'✅ Sí' if is_platform_enabled else '❌ No'}") - - if status == "running": - st.text(f"PID: {pid}") - if 'command' in process_info: - st.text("Comando FFmpeg:") - st.code(process_info['command'], language='bash') - -def render_monitor(): - """Renderizar panel de monitoreo con información de PIDs""" - st.header("📊 Monitor de Estado y PIDs") - - if not st.session_state.active_processes: - st.info("ℹ️ No hay transmisiones activas") - st.markdown(""" - ### Cómo iniciar transmisiones: - 1. Ve a la pestaña **🔍 Búsqueda** - 2. Selecciona un video en vivo - 3. Ve a la pestaña **🎛️ Control** - 4. Activa los switches de las plataformas que desees - """) - return - - # Auto-refresh cada 5 segundos - st_autorefresh(interval=5000, key="monitor_refresh") - - # Resumen general - st.subheader("📈 Resumen General") - - col1, col2, col3, col4 = st.columns(4) - - total_processes = len(st.session_state.active_processes) - running_count = 0 - error_count = 0 - - for process_key in st.session_state.active_processes: - status, _ = check_stream_health(process_key) - if status == "running": - running_count += 1 - elif status == "error": - error_count += 1 - - with col1: - st.metric("Total Transmisiones", total_processes) - - with col2: - st.metric("🟢 Activas", running_count) - - with col3: - st.metric("🔴 Con Errores", error_count) - - with col4: - stopped_count = total_processes - running_count - error_count - st.metric("⚪ Detenidas", stopped_count) - - st.divider() - - # Detalles por plataforma - st.subheader("🔍 Detalle por Plataforma") - - for process_key, process_info in st.session_state.active_processes.items(): - platform = process_info['platform'] - pid = process_info.get('pid', 'N/A') - start_time = datetime.fromisoformat(process_info['start_time']) - duration = datetime.now() - start_time - - # Verificar estado - status, status_msg = check_stream_health(platform) - - # Verificar si el PID está vivo - is_process_alive = check_process_alive(pid) if isinstance(pid, int) else False - - # Color del contenedor según estado - if status == "running": - status_emoji = "🟢" - status_color = "green" - elif status == "error": - status_emoji = "🔴" - status_color = "red" - else: - status_emoji = "⚪" - status_color = "gray" - - with st.container(border=True): - col_name, col_status, col_action = st.columns([3, 2, 1]) - - with col_name: - st.markdown(f"### {status_emoji} {platform}") - - with col_status: - st.markdown(f"**PID:** {pid}") - st.caption(status_msg) - - with col_action: - if st.button("⏹️ Detener", key=f"monitor_stop_{platform}"): - stop_stream(platform) - st.rerun() - - # Métricas - col_time, col_pid_status, col_uptime = st.columns(3) - - with col_time: - hours, remainder = divmod(duration.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - st.metric("⏱️ Tiempo Activo", f"{hours:02d}:{minutes:02d}:{seconds:02d}") - - with col_pid_status: - process_status = "✅ Vivo" if is_process_alive else "❌ Muerto" - st.metric("🔍 Estado del Proceso", process_status) - - with col_uptime: - start_time_formatted = start_time.strftime("%H:%M:%S") - st.metric("🕐 Inicio", start_time_formatted) - - # Información adicional expandible - with st.expander("ℹ️ Información Técnica", expanded=False): - st.code(f"PID: {pid}", language=None) - st.code(f"Plataforma: {platform}", language=None) - st.code(f"RTMP: {process_info.get('rtmp_url', 'N/A')}", language=None) - - if 'command' in process_info: - st.markdown("**Comando FFmpeg:**") - st.code(process_info['command'], language='bash') - - # Verificación en tiempo real del proceso - if isinstance(pid, int): - st.markdown("**Verificación del Proceso:**") - try: - # Intentar obtener información del proceso - os.kill(pid, 0) - st.success(f"✅ El proceso {pid} está corriendo") - except OSError: - st.error(f"❌ El proceso {pid} no está corriendo") - except Exception as e: - st.warning(f"⚠️ No se pudo verificar: {str(e)}") - - status, status_msg = check_stream_health(platform) - - col1, col2, col3, col4 = st.columns([2, 1, 2, 1]) - - with col1: - st.markdown(f"**{platform}**") - - with col2: - if status == "running": - st.success("🟢 ACTIVO") - else: - st.error("🔴 ERROR") - - with col3: - hours, remainder = divmod(duration.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - st.text(f"⏱️ {hours:02d}:{minutes:02d}:{seconds:02d}") - - with col4: - if st.button("⏹️", key=f"monitor_stop_{platform}"): - stop_stream(platform) - st.rerun() - - st.divider() - -# ==================== APLICACIÓN PRINCIPAL ==================== - -def main(): - st.title("📺 TubeScript - Panel de Control de Retransmisión") - - # Renderizar sidebar - render_sidebar() - - # Tabs principales - tab1, tab2, tab3 = st.tabs(["🔍 Búsqueda", "🎛️ Control", "📊 Monitor"]) - - with tab1: - render_search_panel() - - with tab2: - render_streaming_control() - - with tab3: - render_monitor() - - # Footer - st.divider() - st.caption("TubeScript API Pro © 2026 - Panel de Control de Retransmisión Multi-Plataforma") - -if __name__ == "__main__": - main() +print("Streamlit frontend eliminado. Use la API (main.py) en modo consola o Docker.")