diff --git a/Dockerfile.api b/Dockerfile.api index bc730af..7cc5a5f 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -3,24 +3,43 @@ FROM python:3.11-slim ENV PYTHONUNBUFFERED=1 -# Instalar ffmpeg y herramientas necesarias +# Instalar ffmpeg, Node.js (LTS via NodeSource) y herramientas necesarias +# Node.js + yt-dlp-utils son requeridos para resolver el n-challenge y signature de YouTube RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg \ curl \ ca-certificates \ - && rm -rf /var/lib/apt/lists/* + gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g yt-dlp-utils 2>/dev/null || true WORKDIR /app -# Copiar requirements y instalar dependencias +# Copiar requirements y instalar dependencias Python COPY requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir -r /app/requirements.txt \ - && pip install --no-cache-dir yt-dlp +RUN pip install --no-cache-dir -r /app/requirements.txt +# Instalar yt-dlp desde la última versión del binario oficial (no pip) para tener siempre la más reciente +RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && chmod a+rx /usr/local/bin/yt-dlp + +# ARG para invalidar caché del COPY al hacer rebuild con --build-arg CACHEBUST=$(date +%s) +ARG CACHEBUST=1 # Copiar el resto del código COPY . /app +# Crear carpeta data con permisos abiertos para que cualquier UID pueda leer/escribir +RUN mkdir -p /app/data && chmod 777 /app/data + +# Crear usuario appuser (UID 1000) y darle acceso a /app +RUN groupadd -g 1000 appgroup && useradd -u 1000 -g appgroup -s /bin/sh appuser \ + && chown -R appuser:appgroup /app + +USER appuser + EXPOSE 8000 # Comando por defecto para ejecutar la API diff --git a/README.md b/README.md index e32e87a..a241af4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ chmod +x docker-start.sh # 3. Abrir en navegador # Panel Web: http://localhost:8501 -# API: http://localhost:8080 +# API: http://localhost:8282 ``` 📚 Ver [DOCKER_README.md](DOCKER_README.md) para más información. @@ -48,8 +48,8 @@ chmod +x docker-start.sh Esto iniciará: - **Panel Web Streamlit**: http://localhost:8501 -- **API FastAPI**: http://localhost:8080 -- **Documentación API**: http://localhost:8080/docs +- **API FastAPI**: http://localhost:8282 +- **Documentación API**: http://localhost:8282/docs 📚 Documentación completa: [DOCKER_GUIDE.md](DOCKER_GUIDE.md) @@ -214,18 +214,25 @@ docker-compose down Esto iniciará: - **Panel Streamlit**: http://localhost:8501 (Frontend) -- **API FastAPI**: http://localhost:8080 (Backend) -- **Docs API**: http://localhost:8080/docs (Swagger UI) +- **API FastAPI**: http://localhost:8282 (Backend) +- **Docs API**: http://localhost:8282/docs (Swagger UI) -### Características Docker +### Volumen de configuración: `./data` -- ✅ Health checks automáticos -- ✅ Auto-restart si falla -- ✅ Red compartida entre servicios -- ✅ Volúmenes persistentes para configuración -- ✅ FFmpeg incluido en la imagen +A partir de la configuración actual, el proyecto monta una única carpeta local `./data` dentro del contenedor en `/app/data`. +Coloca ahí los archivos de configuración y persistencia (por ejemplo: `cookies.txt`, `stream_config.json`, `streams_state.json`). -📚 **Documentación completa**: [DOCKER_GUIDE.md](DOCKER_GUIDE.md) +- Ventajas: + - Mantener todos los archivos de configuración en un solo lugar + - Puedes reemplazar `cookies.txt` desde fuera del servidor (host) sin editar el compose + - Evita montajes individuales de archivos que generen conflictos de permisos + +- Ejemplo (crear la carpeta si no existe): + +```bash +mkdir -p ./data +chmod 755 ./data +``` ## 📁 Estructura del Proyecto @@ -235,15 +242,15 @@ TubeScript-API/ ├── streamlit_app.py # Panel web de control ├── requirements.txt # Dependencias Python ├── Dockerfile # Imagen Docker optimizada -├── docker-compose.yml # Orquestación de servicios +├── docker-compose.yml # Orquestación de servicios (monta ./data -> /app/data) ├── docker-start.sh # Script de inicio automático ├── docker-stop.sh # Script para detener ├── docker-logs.sh # Script para ver logs -├── Dockerfile # Configuración Docker -├── docker-compose.yml # Orquestación de servicios -├── stream_config.json # Configuración de plataformas (generado) -├── streams_state.json # Estado de transmisiones (generado) -└── cookies.txt # Cookies de YouTube (opcional) +├── data/ # Carpeta montada en el contenedor (/app/data) para configuración persistente +│ ├── stream_config.json # Configuración de plataformas (generado/gestionado aquí) +│ ├── streams_state.json # Estado de transmisiones (generado/gestionado aquí) +│ └── cookies.txt # Cookies de YouTube (opcional — poner aquí o subir vía endpoint) +└── README.md # Documentación ``` ## 🔧 Configuración Avanzada @@ -255,7 +262,23 @@ Para acceder a videos con restricciones, puedes proporcionar cookies: 1. Instala la extensión "Get cookies.txt" en tu navegador 2. Visita youtube.com e inicia sesión 3. Exporta las cookies como `cookies.txt` -4. Coloca el archivo en la raíz del proyecto +4. Coloca el archivo en `./data/cookies.txt` o súbelo mediante el endpoint `/upload_cookies` + +Ejemplo: copiar manualmente al volumen montado: + +```bash +cp /ruta/local/cookies.txt ./data/cookies.txt +# (si el servicio ya está corriendo, reinicia el contenedor para que los procesos usen la nueva cookie si es necesario) +``` + +O usar el endpoint de la API (si la API está expuesta en el host): + +```bash +# Si usas docker-compose.yml (puerto 8282) +curl -v -X POST "http://127.0.0.1:8282/upload_cookies" -F "file=@/ruta/a/cookies.txt" -H "Accept: application/json" + +# Si usas docker-compose.local.yml y expones en 8000, ajusta el puerto a 8000 +``` ### Personalizar Calidad de Video @@ -289,7 +312,7 @@ command = [ ### Error: "No se pudo obtener la URL del stream" - Verifica que el video esté realmente en vivo -- Intenta agregar cookies de YouTube +- Intenta agregar cookies de YouTube (colocando `./data/cookies.txt` o subiéndolas vía `/upload_cookies`) - Verifica tu conexión a internet ### Error: "Transmisión con estado error" diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 241cd6a..413e493 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -7,14 +7,14 @@ services: dockerfile: Dockerfile.api container_name: tubescript-api image: tubescript-api:local + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" ports: - "8000:8000" volumes: - - ./cookies.txt:/app/cookies.txt - - ./stream_config.json:/app/stream_config.json:ro - - ./streams_state.json:/app/streams_state.json:rw + - ./data:/app/data:rw environment: API_BASE_URL: http://localhost:8000 + API_COOKIES_PATH: /app/data/cookies.txt TZ: UTC restart: unless-stopped healthcheck: @@ -34,8 +34,7 @@ services: ports: - "8501:8501" volumes: - - ./stream_config.json:/app/stream_config.json:ro - - ./cookies.txt:/app/cookies.txt:ro + - ./data:/app/data:ro environment: API_BASE_URL: http://localhost:8000 TZ: UTC diff --git a/docker-compose.yml b/docker-compose.yml index 1c284b6..a691032 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,20 +4,20 @@ services: build: context: . dockerfile: Dockerfile.api + args: + # Invalida solo la capa COPY . /app para que siempre tome el código más reciente + # sin necesidad de --no-cache (que descarga todo desde cero) + CACHEBUST: "${CACHEBUST:-1}" + image: tubescript-api:latest container_name: tubescript_api - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload ports: - - "8000:8000" + - "8282:8000" volumes: - - ./:/app:rw - - ./cookies.txt:/app/cookies.txt:ro - - ./stream_config.json:/app/stream_config.json:ro - - ./streams_state.json:/app/streams_state.json - - ./data:/app/data + - ./data:/app/data:rw environment: - PYTHONUNBUFFERED=1 - - API_COOKIES_PATH=/app/cookies.txt - # Optional: set API_PROXY when you want the container to use a SOCKS/HTTP proxy (e.g. tor) + - API_COOKIES_PATH=/app/data/cookies.txt + # Optional: set API_PROXY when you want the container to use a SOCKS/HTTP proxy - API_PROXY=${API_PROXY:-} restart: unless-stopped networks: @@ -32,3 +32,4 @@ services: networks: tubescript-network: name: tubescript-network + driver: bridge diff --git a/docker-rebuild.sh b/docker-rebuild.sh index 68abe96..011de5e 100755 --- a/docker-rebuild.sh +++ b/docker-rebuild.sh @@ -42,9 +42,21 @@ fi print_success "Docker encontrado" echo "" +# Asegurar carpeta data para montajes de configuración +echo "📁 Asegurando carpeta './data' para montaje de configuración..." +if [ ! -d "./data" ]; then + mkdir -p ./data + chmod 755 ./data || true + print_success "Carpeta ./data creada" +else + print_success "Carpeta ./data ya existe" +fi +echo "Nota: coloca aquí archivos persistentes como stream_config.json, streams_state.json y cookies.txt (ej: ./data/cookies.txt)" +echo "" + # Detener contenedores echo "🛑 Deteniendo contenedores existentes..." -docker-compose down 2>/dev/null || true +docker compose down 2>/dev/null || true print_success "Contenedores detenidos" echo "" @@ -53,22 +65,24 @@ echo "🧹 ¿Deseas eliminar las imágenes antiguas? (s/N)" read -p "> " clean_images if [ "$clean_images" = "s" ] || [ "$clean_images" = "S" ]; then echo "Eliminando imágenes antiguas..." - docker-compose down --rmi all 2>/dev/null || true + docker compose down --rmi all 2>/dev/null || true print_success "Imágenes antiguas eliminadas" fi echo "" -# Reconstruir sin cache -echo "🔨 Reconstruyendo imágenes sin cache..." -echo "Esto puede tardar varios minutos..." +# Reconstruir con CACHEBUST para invalidar solo la capa COPY . /app +# CACHEBUST=$(date +%s) se exporta para que docker-compose.yml lo tome via ${CACHEBUST:-1} +echo "🔨 Reconstruyendo imagen con código actualizado..." +echo "Usando CACHEBUST=$(date +%s) para forzar copia fresca del código..." echo "" -docker-compose build --no-cache +export CACHEBUST="$(date +%s)" +docker compose build if [ $? -eq 0 ]; then - print_success "Imágenes reconstruidas exitosamente" + print_success "Imagen reconstruida exitosamente" else - print_error "Error al reconstruir imágenes" + print_error "Error al reconstruir imagen" exit 1 fi echo "" @@ -79,22 +93,22 @@ read -p "> " start_services if [ "$start_services" != "n" ] && [ "$start_services" != "N" ]; then echo "" echo "🚀 Iniciando servicios..." - docker-compose up -d + docker compose up -d if [ $? -eq 0 ]; then print_success "Servicios iniciados" echo "" echo "📊 Estado de los servicios:" sleep 3 - docker-compose ps + docker compose ps echo "" echo "════════════════════════════════════════════════════════════" print_success "¡Rebuild completado!" echo "════════════════════════════════════════════════════════════" echo "" echo "🌐 Servicios disponibles:" - echo " Panel Web: http://localhost:8501" - echo " API: http://localhost:8080" + echo " API: http://localhost:8282" + echo " Docs API: http://localhost:8282/docs" echo "" else print_error "Error al iniciar servicios" @@ -105,7 +119,7 @@ else print_success "Rebuild completado (servicios no iniciados)" echo "" echo "Para iniciar los servicios:" - echo " docker-compose up -d" + echo " CACHEBUST=\$(date +%s) docker compose up -d --build" fi echo "════════════════════════════════════════════════════════════" diff --git a/docker-start-api.sh b/docker-start-api.sh index 06c20ee..93fa86a 100755 --- a/docker-start-api.sh +++ b/docker-start-api.sh @@ -21,11 +21,9 @@ docker run -d \ --name tubescript_api \ --network tubescript-network \ -p 8080:8000 \ - -v "$(pwd)/cookies.txt:/app/cookies.txt:ro" \ - -v "$(pwd)/stream_config.json:/app/stream_config.json" \ - -v "$(pwd)/streams_state.json:/app/streams_state.json" \ + -v "$(pwd)/data:/app/data:rw" \ + -e API_COOKIES_PATH=/app/data/cookies.txt \ -v "$(pwd)/process_state.json:/app/process_state.json" \ - -v "$(pwd)/data:/app/data" \ -e PYTHONUNBUFFERED=1 \ tubescript-api \ uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/docker-start-streamlit.sh b/docker-start-streamlit.sh index 1959c98..fedb596 100755 --- a/docker-start-streamlit.sh +++ b/docker-start-streamlit.sh @@ -30,11 +30,8 @@ docker run -d \ --name streamlit_panel \ --network tubescript-network \ -p 8501:8501 \ - -v "$(pwd)/cookies.txt:/app/cookies.txt:ro" \ - -v "$(pwd)/stream_config.json:/app/stream_config.json" \ - -v "$(pwd)/streams_state.json:/app/streams_state.json" \ - -v "$(pwd)/process_state.json:/app/process_state.json" \ - -v "$(pwd)/data:/app/data" \ + -v "$(pwd)/data:/app/data:ro" \ + -e API_COOKIES_PATH=/app/data/cookies.txt \ -e PYTHONUNBUFFERED=1 \ -e API_URL="$API_URL" \ tubescript-api \ diff --git a/docker-start.sh b/docker-start.sh index 68b6525..8d46d56 100644 --- a/docker-start.sh +++ b/docker-start.sh @@ -1,180 +1,79 @@ #!/bin/bash - -# Script para iniciar el stack completo de TubeScript con Docker - +# Script para iniciar TubeScript-API con docker compose set -e +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +print_success() { echo -e "${GREEN}✅ $1${NC}"; } +print_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; } +print_error() { echo -e "${RED}❌ $1${NC}"; } + echo "════════════════════════════════════════════════════════════" -echo " 🐳 TubeScript-API - Inicio con Docker" +echo " 🐳 TubeScript-API — docker compose up" echo "════════════════════════════════════════════════════════════" echo "" -# Colores para output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Función para imprimir mensajes con color -print_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -print_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -print_error() { - echo -e "${RED}❌ $1${NC}" -} - -# Verificar que Docker esté instalado -if ! command -v docker &> /dev/null; then - print_error "Docker no está instalado" - echo "Instala Docker desde: https://www.docker.com/get-started" - exit 1 +# Verificar Docker +if ! command -v docker &>/dev/null; then + print_error "Docker no está instalado"; exit 1 fi - -if ! command -v docker-compose &> /dev/null; then - print_error "Docker Compose no está instalado" - exit 1 -fi - -print_success "Docker y Docker Compose encontrados" - -# Solicitar URL de la API si no está configurada +print_success "Docker encontrado: $(docker --version)" echo "" -echo "🌐 Configuración de API URL..." -# Verificar si existe archivo .env -if [ ! -f ".env" ]; then - echo "" - echo "Por favor, ingresa la URL del dominio de la API:" - echo "(Ejemplos: https://api.tubescript.com, http://localhost:8080, https://mi-dominio.com)" - read -p "API URL [http://localhost:8080]: " api_url - api_url=${api_url:-http://localhost:8080} - - echo "API_URL=$api_url" > .env - print_success "Creado archivo .env con API_URL=$api_url" -else - # Leer URL existente - source .env - print_success "Usando API_URL existente: $API_URL" - - echo "¿Deseas cambiar la API URL? (s/N)" - read -p "> " change_url - if [ "$change_url" = "s" ] || [ "$change_url" = "S" ]; then - read -p "Nueva API URL: " new_api_url - if [ ! -z "$new_api_url" ]; then - sed -i.bak "s|API_URL=.*|API_URL=$new_api_url|" .env - print_success "API_URL actualizada a: $new_api_url" - fi - fi -fi - -# Crear archivos de configuración si no existen -echo "" -echo "📝 Verificando archivos de configuración..." - -if [ ! -f "stream_config.json" ]; then - echo '{ - "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} - } -}' > stream_config.json - print_success "Creado stream_config.json" -else - print_success "stream_config.json ya existe" -fi - -if [ ! -f "streams_state.json" ]; then - echo '{}' > streams_state.json - print_success "Creado streams_state.json" -else - print_success "streams_state.json ya existe" -fi - -if [ ! -f "cookies.txt" ]; then - touch cookies.txt - print_warning "Creado cookies.txt vacío (opcional para videos restringidos)" -else - print_success "cookies.txt existe" -fi - -# Crear directorio data si no existe +# Crear carpeta data con permisos correctos (necesaria para cookies.txt y otros) if [ ! -d "data" ]; then - mkdir -p data - print_success "Creado directorio data/" + mkdir -p data && chmod 755 data + print_success "Creado directorio ./data" +else + print_success "Directorio ./data ya existe" fi -# Detener contenedores existentes si los hay +# Sugerencia de cookies +if [ ! -f "data/cookies.txt" ]; then + touch data/cookies.txt + print_warning "data/cookies.txt vacío creado (sube cookies con POST /upload_cookies)" +else + print_success "data/cookies.txt encontrado" +fi echo "" + +# Detener contenedores existentes echo "🛑 Deteniendo contenedores existentes..." -docker-compose down 2>/dev/null || true +docker compose down 2>/dev/null || true +echo "" -# Construir las imágenes +# Build + arranque con CACHEBUST para forzar copia fresca del código +export CACHEBUST="$(date +%s)" +echo "🔨 Construyendo e iniciando servicios..." +echo " (CACHEBUST=${CACHEBUST} — solo invalida la capa de código, no las capas de apt/pip)" echo "" -echo "🔨 Construyendo imágenes Docker..." -docker-compose build +docker compose up -d --build -# Iniciar los servicios -echo "" -echo "🚀 Iniciando servicios..." -docker-compose up -d - -# Esperar a que los servicios estén listos -echo "" -echo "⏳ Esperando que los servicios inicien..." -sleep 5 - -# Verificar estado de los servicios -echo "" -echo "📊 Estado de los servicios:" -docker-compose ps - -# Mostrar logs iniciales -echo "" -echo "📋 Logs recientes:" -docker-compose logs --tail=10 - -echo "" -echo "════════════════════════════════════════════════════════════" -print_success "¡Servicios iniciados correctamente!" -echo "════════════════════════════════════════════════════════════" -echo "" -echo "📡 Servicios disponibles:" -echo "" -echo " 🌐 Panel Web Streamlit:" -echo " http://localhost:8501" -echo "" -echo " 📡 API FastAPI:" -echo " http://localhost:8080" -echo " http://localhost:8080/docs (Documentación Swagger)" -echo "" -echo "────────────────────────────────────────────────────────────" -echo "📝 Comandos útiles:" -echo "" -echo " Ver logs en tiempo real:" -echo " docker-compose logs -f" -echo "" -echo " Ver logs de un servicio:" -echo " docker-compose logs -f streamlit-panel" -echo " docker-compose logs -f tubescript-api" -echo "" -echo " Detener servicios:" -echo " docker-compose down" -echo "" -echo " Reiniciar servicios:" -echo " docker-compose restart" -echo "" -echo " Ver estado:" -echo " docker-compose ps" -echo "" -echo "════════════════════════════════════════════════════════════" -echo "🎉 ¡Listo para transmitir!" -echo "════════════════════════════════════════════════════════════" +if [ $? -eq 0 ]; then + echo "" + echo "⏳ Esperando arranque de uvicorn..." + sleep 8 + echo "" + echo "📊 Estado:" + docker compose ps + echo "" + echo "📋 Logs recientes:" + docker compose logs --tail=6 + echo "" + echo "════════════════════════════════════════════════════════════" + print_success "¡Listo!" + echo "════════════════════════════════════════════════════════════" + echo "" + echo " 🌐 API: http://localhost:8282" + echo " 📖 Docs: http://localhost:8282/docs" + echo " 🍪 Subir cookies: curl -X POST http://localhost:8282/upload_cookies -F 'file=@cookies.txt'" + echo "" + echo " 📝 Comandos útiles:" + echo " Logs en vivo: docker compose logs -f tubescript-api" + echo " Detener: docker compose down" + echo " Rebuild: CACHEBUST=\$(date +%s) docker compose up -d --build" + echo "" +else + print_error "Error al iniciar servicios" + docker compose logs --tail=20 + exit 1 +fi diff --git a/fetch_transcript.py b/fetch_transcript.py index bbb2a42..f271291 100755 --- a/fetch_transcript.py +++ b/fetch_transcript.py @@ -17,7 +17,7 @@ import os import subprocess import tempfile import glob -from main import parse_subtitle_format +from main import parse_subtitle_format, get_transcript_data def fetch_with_browser_cookies(video_id, lang="es", browser="chrome"): """Intenta obtener transcript usando cookies desde el navegador directamente.""" @@ -78,18 +78,15 @@ def main(): print(f" Idioma: {lang}") if browser: - print(f" Método: Cookies desde {browser}") + print(" Método: Cookies desde {}".format(browser)) segments, error = fetch_with_browser_cookies(video_id, lang, browser) else: - print(f" Método: API del proyecto") - print(f" Cookies: {os.getenv('API_COOKIES_PATH', './cookies.txt')}") - from main import get_transcript_data + print(" Método: API del proyecto") + print(" Cookies: {}".format(os.getenv('API_COOKIES_PATH', './data/cookies.txt'))) segments, error = get_transcript_data(video_id, lang) print("") - # Intentar obtener transcript - segments, error = get_transcript_data(video_id, lang) if error: print(f"❌ ERROR: {error}") diff --git a/fix-and-restart.sh b/fix-and-restart.sh new file mode 100755 index 0000000..44175e0 --- /dev/null +++ b/fix-and-restart.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Script para reconstruir y levantar TubeScript-API con soporte correcto de YouTube +set -e + +REPO_DIR="/home/xesar/PycharmProjects/TubeScript-API" +cd "$REPO_DIR" + +echo "======================================================" +echo " TubeScript-API - Fix & Restart" +echo "======================================================" + +# 1. Parar contenedor anterior si existe +echo "" +echo ">>> [1/7] Parando contenedor anterior..." +docker stop tubescript_api 2>/dev/null && echo " Parado." || echo " No estaba corriendo." +docker rm tubescript_api 2>/dev/null && echo " Eliminado." || echo " No existia." + +# 2. Construir imagen con tag explícito (siempre sin cache para forzar yt-dlp latest) +echo "" +echo ">>> [2/7] Construyendo imagen tubescript-api:latest ..." +docker build -f Dockerfile.api -t tubescript-api:latest . +echo " Build OK." + +# 3. Asegurar permisos de ./data +echo "" +echo ">>> [3/7] Asegurando permisos de ./data ..." +mkdir -p ./data +chown -R "$(id -u):$(id -g)" ./data 2>/dev/null || sudo chown -R "$(id -u):$(id -g)" ./data +chmod -R u+rwX ./data +ls -la ./data +echo " Permisos OK." + +# 4. Crear red si no existe +echo "" +echo ">>> [4/7] Asegurando red tubescript-network ..." +docker network create tubescript-network 2>/dev/null && echo " Red creada." || echo " Red ya existe." + +# 5. Levantar contenedor +echo "" +echo ">>> [5/7] Levantando contenedor ..." +docker run -d \ + --name tubescript_api \ + --network tubescript-network \ + -p 8282:8000 \ + -v "${REPO_DIR}/data:/app/data:rw" \ + -e PYTHONUNBUFFERED=1 \ + -e API_COOKIES_PATH=/app/data/cookies.txt \ + --restart unless-stopped \ + tubescript-api:latest + +echo " Contenedor iniciado. Esperando arranque de uvicorn..." +sleep 6 + +# 6. Verificaciones internas +echo "" +echo ">>> [6/7] Verificaciones del contenedor ..." + +echo "" +echo "-- Estado:" +docker ps --filter "name=tubescript_api" --format " ID={{.ID}} STATUS={{.Status}} PORTS={{.Ports}}" + +echo "" +echo "-- Logs uvicorn:" +docker logs tubescript_api 2>&1 | tail -6 + +echo "" +echo "-- Versiones:" +docker exec tubescript_api sh -c " + echo ' node :' \$(node --version 2>/dev/null || echo 'no instalado') + echo ' yt-dlp :' \$(yt-dlp --version 2>/dev/null || echo 'no instalado') +" + +# 7. Prueba real de yt-dlp con player_client=android (evita n-challenge sin Node extras) +echo "" +echo ">>> [7/7] Prueba yt-dlp (android client) ..." + +echo "" +echo "-- Sin cookies (android client):" +docker exec tubescript_api yt-dlp \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=android" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \ + && echo " OK" || echo " FALLO" + +echo "" +echo "-- Con cookies (mweb client — acepta cookies web sin n-challenge):" +if [ -s "${REPO_DIR}/data/cookies.txt" ]; then + docker exec tubescript_api yt-dlp \ + --cookies /app/data/cookies.txt \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=mweb" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \ + && echo " OK - título obtenido con cookies" || echo " FALLO con cookies" +else + echo " AVISO: cookies.txt vacío o no existe." + echo " Sube tus cookies: curl 'http://127.0.0.1:8282/upload_cookies' -F 'file=@/ruta/cookies.txt'" +fi + +echo "" +echo "-- Endpoint /debug/metadata:" +sleep 2 +curl -s --max-time 30 "http://127.0.0.1:8282/debug/metadata/dQw4w9WgXcQ" \ + | python3 -c " +import sys, json +try: + d = json.loads(sys.stdin.read()) + print(' title :', d.get('title','?')) + print(' is_live :', d.get('is_live','?')) + print(' id :', d.get('id','?')) +except Exception as e: + print(' ERROR:', e) +" 2>&1 + +echo "" +echo "======================================================" +echo " LISTO." +echo " API: http://127.0.0.1:8282" +echo " Docs: http://127.0.0.1:8282/docs" +echo "" +echo " Subir cookies:" +echo " curl 'http://127.0.0.1:8282/upload_cookies' -F 'file=@./data/cookies.txt'" +echo "======================================================" diff --git a/main.py b/main.py index 7aa006c..bea8a5e 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,8 @@ from yt_wrap import CookieManager 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') +# Por defecto, usar ./data/cookies.txt para agrupar configuraciones en la carpeta data +DEFAULT_COOKIES_PATH = './data/cookies.txt' # Proxy opcional para requests/yt-dlp (ej. socks5h://127.0.0.1:9050) DEFAULT_PROXY = os.getenv('API_PROXY', '') @@ -162,8 +163,8 @@ def format_segments_text(segments: List[Dict]) -> List[str]: s = str(t).strip() s = re.sub(r'^\s*Kind\s*:\s*.*$', '', s, flags=re.IGNORECASE).strip() # eliminar contenido entre corchetes (no-greedy) - s = re.sub(r'\[.*?\]', '', s) - s = re.sub(r'\(.*?\)', '', s) + s = re.sub(r'\[[^\]]*\]', '', s) + s = re.sub(r'\([^\)]*\)', '', s) s = re.sub(r'<[^>]+>', '', s) s = re.sub(r'[♪★■◆►▶◀•–—]', '', s) s = re.sub(r'\s+', ' ', s).strip() @@ -180,78 +181,55 @@ def format_segments_text(segments: List[Dict]) -> List[str]: return output -# Nuevo helper: obtener thumbnails para un video (intenta yt-dlp --dump-json, fallback a URLs estándar) -def get_video_thumbnails(video_id: str) -> List[str]: - """Devuelve una lista de URLs de thumbnail para el video. - Primero intenta obtener metadata con yt-dlp y extraer 'thumbnails' o 'thumbnail'. - Si falla, construye una lista de URLs por defecto (maxresdefault, sddefault, hqdefault, mqdefault, default). +NODE_PATH = "/usr/bin/node" + +def _yt_client_args(has_cookies: bool, for_stream: bool = False) -> list: + """Devuelve --extractor-args y --js-runtimes para metadata/streams. + + Estrategia (basada en pruebas reales 2026-03-05): + - Sin cookies → android (sin n-challenge, sin Node.js) + - Con cookies → web + Node.js (web acepta cookies; Node resuelve n-challenge/signature) + - for_stream → android (mejor compatibilidad HLS en lives) + + Diagnóstico: + - mweb con cookies → requiere GVS PO Token (no disponible) + - android con cookies → yt-dlp lo salta (no soporta cookies) + - web con cookies + --js-runtimes node → ✅ funciona """ - thumbs: List[str] = [] - url = f"https://www.youtube.com/watch?v={video_id}" - - cookie_mgr = CookieManager() - cookiefile_path = cookie_mgr.get_cookiefile_path() - cookies_path = cookiefile_path or os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) - proxy = os.getenv('API_PROXY', DEFAULT_PROXY) or None - - cmd = [ - "yt-dlp", - "--skip-download", - "--dump-json", - "--no-warnings", - url - ] - if os.path.exists(cookies_path): - cmd.extend(["--cookies", cookies_path]) - if proxy: - cmd.extend(['--proxy', proxy]) - - try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - if proc.returncode == 0 and proc.stdout: - try: - meta = json.loads(proc.stdout) - # thumbnails puede ser lista de dicts con 'url' - t = meta.get('thumbnails') or meta.get('thumbnail') - if isinstance(t, list): - for item in t: - if isinstance(item, dict) and item.get('url'): - thumbs.append(item.get('url')) - elif isinstance(item, str): - thumbs.append(item) - elif isinstance(t, dict) and t.get('url'): - thumbs.append(t.get('url')) - elif isinstance(t, str): - thumbs.append(t) - except Exception: - pass - except Exception: - pass - finally: - try: - cookie_mgr.cleanup() - except Exception: - pass - - # Si no obtuvimos thumbnails desde metadata, construir URLs estándar - if not thumbs: - thumbs = [ - f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg", - f"https://i.ytimg.com/vi/{video_id}/sddefault.jpg", - f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", - f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg", - f"https://i.ytimg.com/vi/{video_id}/default.jpg", + if for_stream or not has_cookies: + return ["--extractor-args", "youtube:player_client=android"] + else: + return [ + "--extractor-args", "youtube:player_client=web", + "--js-runtimes", f"node:{NODE_PATH}", ] - # deduplicate while preserving order - seen = set() - unique_thumbs = [] - for t in thumbs: - if t and t not in seen: - seen.add(t) - unique_thumbs.append(t) - return unique_thumbs +def _yt_subs_args(has_cookies: bool) -> list: + """Devuelve --extractor-args para descarga de subtítulos. + + Para subtítulos siempre usamos android: + - android sin cookies → ✅ funciona, obtiene auto-subs sin n-challenge + - android con cookies → yt-dlp lo salta pero descarga igual sin cookies + - web con cookies → falla en sub-langs no exactos (ej: en vs en-US) + Resultado: android es siempre el cliente más fiable para subtítulos. + """ + return ["--extractor-args", "youtube:player_client=android"] + + + +# Nuevo helper: obtener thumbnails para un video — usa URLs estáticas directas (sin yt-dlp) +def get_video_thumbnails(video_id: str) -> List[str]: + """Devuelve URLs de thumbnail sin llamar yt-dlp (rápido, sin bloquear el transcript). + YouTube siempre tiene estas URLs disponibles para cualquier video público. + """ + return [ + f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg", + f"https://img.youtube.com/vi/{video_id}/sddefault.jpg", + f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg", + f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg", + f"https://img.youtube.com/vi/{video_id}/default.jpg", + ] def get_transcript_data(video_id: str, lang: str = "es"): video_id = extract_video_id(video_id) @@ -302,11 +280,18 @@ def get_transcript_data(video_id: str, lang: str = "es"): # 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 + # Construir lista amplia de variantes de idioma + # yt-dlp usa códigos exactos; cubrimos las variantes más comunes sub_langs = [lang] - if len(lang) == 2: - sub_langs.append(f"{lang}-419") + if lang == "en": + sub_langs = ["en", "en-US", "en-en", "en-GB", "en-CA", "en-AU"] + elif lang == "es": + sub_langs = ["es", "es-419", "es-MX", "es-ES", "es-LA", "es-en"] + elif len(lang) == 2: + sub_langs = [lang, f"{lang}-{lang.upper()}", f"{lang}-419", f"{lang}-en"] + # siempre android para subtítulos — NO pasar --cookies porque android no las soporta + # (yt-dlp salta el cliente android si recibe cookies → no descarga nada) ytdlp_cmd = [ "yt-dlp", url, @@ -316,15 +301,9 @@ def get_transcript_data(video_id: str, lang: str = "es"): "--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)]) - - # attach cookiefile if exists - if cookiefile_path: - ytdlp_cmd.extend(["--cookies", cookiefile_path]) + "--sub-lang", ",".join(sub_langs), + ] + _yt_subs_args(False) + # NO se pasan cookies con android (android no las soporta en yt-dlp) # attach proxy if configured if proxy: @@ -332,8 +311,14 @@ def get_transcript_data(video_id: str, lang: str = "es"): try: result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=120) + stderr = (result.stderr or "").lower() + # Error: YouTube pide autenticación + if result.returncode != 0 and ('sign in' in stderr or 'confirm you' in stderr or 'bot' in stderr): + return None, get_video_thumbnails(video_id), "YouTube requiere autenticación para este video. Sube un cookies.txt válido con /upload_cookies." # Si yt-dlp falló por rate limiting, devolver mensaje claro stderr = (result.stderr or "").lower() + if result.returncode != 0 and ('sign in' in stderr or 'confirm you' in stderr or 'bot' in stderr): + return None, get_video_thumbnails(video_id), "YouTube requiere autenticación para este video. Sube un cookies.txt válido con /upload_cookies." if result.returncode != 0 and ('http error 429' in stderr or 'too many requests' in stderr): return None, get_video_thumbnails(video_id), "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): @@ -341,19 +326,33 @@ def get_transcript_data(video_id: str, lang: str = "es"): except subprocess.TimeoutExpired: pass - # revisar archivos creados - files = glob.glob(os.path.join(tmpdl, f"{video_id}.*")) + # revisar archivos creados — yt-dlp genera nombres con doble extensión: ID.lang.vtt + # glob "ID.*" no hace match; usar "ID*" para cubrir ID.en.vtt, ID.en-en.vtt, etc. + files = glob.glob(os.path.join(tmpdl, f"{video_id}*")) + # filtrar solo archivos de texto (vtt, json3, srv3, ttml, srt) + files = [f for f in files if os.path.isfile(f) and + any(f.endswith(ext) for ext in ('.vtt', '.json3', '.srv3', '.srt', '.ttml'))] if files: combined = [] + seen_content = set() for fpath in files: try: with open(fpath, 'r', encoding='utf-8') as fh: - combined.append(fh.read()) + content = fh.read() + # desduplicar archivos con mismo contenido (en.vtt vs en-en.vtt) + content_hash = hash(content[:500]) + if content_hash not in seen_content: + seen_content.add(content_hash) + combined.append(content) except Exception: continue if combined: vtt_combined = "\n".join(combined) parsed = parse_subtitle_format(vtt_combined, 'vtt') + # filtrar segmentos de ruido del header VTT + _noise = {'kind: captions', 'language:', 'webvtt', 'position:', 'align:'} + parsed = [s for s in parsed if s.get('text') and + not any(s['text'].lower().startswith(n) for n in _noise)] if parsed: return parsed, get_video_thumbnails(video_id), None finally: @@ -365,17 +364,16 @@ def get_transcript_data(video_id: str, lang: str = "es"): # ...existing code continues... # 1) Intento principal: obtener metadata con yt-dlp + _has_ck = os.path.exists(cookies_path) command = [ "yt-dlp", "--skip-download", "--dump-json", "--no-warnings", - url - ] + ] + _yt_client_args(_has_ck) + [url] - if os.path.exists(cookies_path): + if _has_ck: command.extend(["--cookies", cookies_path]) - # attach proxy if configured if proxy: command.extend(['--proxy', proxy]) @@ -538,13 +536,7 @@ def get_transcript_data(video_id: str, lang: str = "es"): vtt_combined = "\n".join(combined) formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt') if formatted_transcript: - return formatted_transcript, get_video_thumbnails(video_id), 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) + return formatted_transcript, get_video_thumbnails(video_id) except Exception as e: return None, get_video_thumbnails(video_id), f"Error al procesar los subtítulos: {str(e)[:200]}" @@ -587,7 +579,7 @@ def get_transcript_data(video_id: str, lang: str = "es"): vtt_combined = "\n".join(combined) formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt') if formatted_transcript: - return formatted_transcript, get_video_thumbnails(video_id), None + return formatted_transcript, get_video_thumbnails(video_id) except FileNotFoundError: return None, get_video_thumbnails(video_id), "yt-dlp no está instalado en el contenedor/entorno. Instala yt-dlp y vuelve a intentar." except Exception: @@ -618,17 +610,18 @@ def get_transcript_data(video_id: str, lang: str = "es"): "--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]) + ] + _yt_subs_args(False) + # NO cookies con android (android no las soporta, yt-dlp lo saltaría) # añadir proxy a la llamada de yt-dlp si está configurado if proxy: cmd.extend(['--proxy', proxy]) r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - # Revisar si se creó algún archivo en tmpdir - files = glob.glob(os.path.join(tmpdir, f"{video_id}.*")) + # Revisar si se creó algún archivo en tmpdir (doble ext: ID.en.vtt) + files = glob.glob(os.path.join(tmpdir, f"{video_id}*")) + files = [f for f in files if os.path.isfile(f) and + any(f.endswith(e) for e in ('.vtt', '.json3', '.srv3', '.srt', '.ttml'))] if files: # Tomar el primero válido downloaded = files[0] @@ -655,90 +648,288 @@ def get_transcript_data(video_id: str, lang: str = "es"): # No hacer crash, retornar mensaje general return None, get_video_thumbnails(video_id), f"Error al intentar descargar subtítulos con yt-dlp: {str(e)[:200]}" - return None, get_video_thumbnails(video_id), "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." + return None, get_video_thumbnails(video_id), ( + f"No se encontraron subtítulos para este video en idioma '{lang}'. " + "Puede que el video no tenga subtítulos, estén en otro idioma, o requiera autenticación. " + "Prueba: ?lang=en | /debug/fetch_subs/{video_id} | sube cookies con /upload_cookies" + ) + +# ── Clientes exactos de NewPipeExtractor (ClientsConstants.java dev 2026-03-05) ── +_NP_IOS = { + "clientName": "IOS", "clientVersion": "21.03.2", + "clientScreen": "WATCH", "platform": "MOBILE", + "deviceMake": "Apple", "deviceModel": "iPhone16,2", + "osName": "iOS", "osVersion": "18.7.2.22H124", + "userAgent": "com.google.ios.youtube/21.03.2 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X;)", +} +_NP_ANDROID = { + "clientName": "ANDROID", "clientVersion": "21.03.36", + "clientScreen": "WATCH", "platform": "MOBILE", + "osName": "Android", "osVersion": "16", "androidSdkVersion": 36, + "userAgent": "com.google.android.youtube/21.03.36 (Linux; U; Android 16) gzip", +} +# GAPIS: youtubei.googleapis.com — NewPipe lo usa para iOS y Android (YoutubeStreamHelper.java) +_GAPIS_BASE = "https://youtubei.googleapis.com/youtubei/v1" + + +def _np_build_ctx(client: dict, visitor_data: str = "") -> dict: + """context.client igual que prepareJsonBuilder de YoutubeParsingHelper.java.""" + ctx = { + "clientName": client["clientName"], + "clientVersion": client["clientVersion"], + "clientScreen": client.get("clientScreen", "WATCH"), + "platform": client.get("platform", "MOBILE"), + "hl": "en", "gl": "US", "utcOffsetMinutes": 0, + } + if visitor_data: + ctx["visitorData"] = visitor_data + for k in ("deviceMake", "deviceModel", "osName", "osVersion", "androidSdkVersion"): + if client.get(k): + ctx[k] = client[k] + return ctx + + +def _np_get_visitor_data(client: dict, proxies: dict = None) -> str: + """POST /visitor_id → responseContext.visitorData (getVisitorDataFromInnertube).""" + try: + ctx = _np_build_ctx(client) + payload = { + "context": { + "client": ctx, + "request": {"internalExperimentFlags": [], "useSsl": True}, + "user": {"lockedSafetyMode": False}, + } + } + headers = { + "User-Agent": client["userAgent"], + "X-Goog-Api-Format-Version": "2", + "Content-Type": "application/json", + } + r = requests.post( + f"{_GAPIS_BASE}/visitor_id?prettyPrint=false", + json=payload, headers=headers, timeout=8, proxies=proxies, + ) + if r.status_code == 200: + return r.json().get("responseContext", {}).get("visitorData", "") + except Exception: + pass + return "" + + +def _np_call_player(video_id: str, client: dict, + visitor_data: str = "", proxies: dict = None) -> dict: + """POST /player igual que getIosPlayerResponse/getAndroidPlayerResponse de NewPipe.""" + import string as _str + n = int(time.time()) + chars = _str.digits + _str.ascii_lowercase + t = "" + while n: + t = chars[n % 36] + t + n //= 36 + url = f"{_GAPIS_BASE}/player?prettyPrint=false&t={t or '0'}&id={video_id}" + ctx = _np_build_ctx(client, visitor_data) + payload = { + "context": { + "client": ctx, + "request": {"internalExperimentFlags": [], "useSsl": True}, + "user": {"lockedSafetyMode": False}, + }, + "videoId": video_id, + "contentCheckOk": True, + "racyCheckOk": True, + } + headers = { + "User-Agent": client["userAgent"], + "X-Goog-Api-Format-Version": "2", + "Content-Type": "application/json", + } + try: + r = requests.post(url, json=payload, headers=headers, timeout=15, proxies=proxies) + if r.status_code == 200: + return r.json() + except Exception: + pass + return {} + + +def innertube_get_stream(video_id: str, proxy: str = None) -> dict: + """ + Obtiene URL de stream replicando exactamente NewPipeExtractor: + 1. visitorData via /visitor_id (para ambos clientes) + 2. iOS /player → iosStreamingData.hlsManifestUrl (prioritario para lives) + 3. Android /player → formats directas (videos normales) + + Sin cookies | Sin firma JS | Sin PO Token | Sin bot-check desde servidores + """ + result = { + "title": None, "description": None, + "is_live": False, "hls_url": None, + "formats": [], "error": None, + } + proxies = {"http": proxy, "https": proxy} if proxy else None + + vd_ios = _np_get_visitor_data(_NP_IOS, proxies) + vd_android = _np_get_visitor_data(_NP_ANDROID, proxies) + + # iOS — preferido para hlsManifestUrl en lives (como hace NewPipe) + ios = _np_call_player(video_id, _NP_IOS, vd_ios, proxies) + ps = ios.get("playabilityStatus") or {} + if ps.get("status") == "LOGIN_REQUIRED": + result["error"] = f"Login requerido: {ps.get('reason','')}" + return result + + vd_meta = ios.get("videoDetails") or {} + result["title"] = vd_meta.get("title") + result["description"] = vd_meta.get("shortDescription") + result["is_live"] = bool(vd_meta.get("isLive") or vd_meta.get("isLiveContent")) + + ios_sd = ios.get("streamingData") or {} + hls = ios_sd.get("hlsManifestUrl") + if hls: + result["hls_url"] = hls + result["formats"] = [ + {"itag": f.get("itag"), "mimeType": f.get("mimeType"), "quality": f.get("quality")} + for f in (ios_sd.get("formats", []) + ios_sd.get("adaptiveFormats", []))[:8] + ] + return result + + # Android — para videos normales o si iOS no dio HLS + android = _np_call_player(video_id, _NP_ANDROID, vd_android, proxies) + if not result["title"]: + vd2 = android.get("videoDetails") or {} + result["title"] = vd2.get("title") + result["description"] = vd2.get("shortDescription") + result["is_live"] = bool(vd2.get("isLive") or vd2.get("isLiveContent")) + + android_sd = android.get("streamingData") or {} + hls = android_sd.get("hlsManifestUrl") + if hls: + result["hls_url"] = hls + return result + + all_fmts = android_sd.get("formats", []) + android_sd.get("adaptiveFormats", []) + best = sorted([f for f in all_fmts if f.get("url")], + key=lambda x: x.get("bitrate", 0), reverse=True) + if best: + result["hls_url"] = best[0]["url"] + result["formats"] = [ + {"itag": f.get("itag"), "mimeType": f.get("mimeType"), "quality": f.get("quality")} + for f in best[:8] + ] + return result + + result["error"] = ( + "Innertube no devolvió streamingData. " + "Puede ser DRM, región bloqueada, privado, o YouTube actualizó su API." + ) + return result + 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}" + Obtiene la URL de transmisión m3u8/HLS. + Devuelve: (stream_url, title, description, is_live, error) - # dynamically get cookiefile for this request + Estrategia: + 1. innertube_get_stream() — técnica NewPipe, sin cookies, sin bot-check + 2. Fallback yt-dlp si Innertube falla + """ + video_id = extract_video_id(video_id) + proxy = os.getenv('API_PROXY', DEFAULT_PROXY) or None + + # ── 1. Innertube directo (NewPipe) ──────────────────────────────────────── + it = innertube_get_stream(video_id, proxy=proxy) + if it.get("hls_url"): + return (it["hls_url"], it.get("title"), it.get("description"), + it.get("is_live", False), None) + + title = it.get("title") + description = it.get("description") + is_live = it.get("is_live", False) + + # ── 2. Fallback yt-dlp ──────────────────────────────────────────────────── cookie_mgr = CookieManager() cookiefile_path = cookie_mgr.get_cookiefile_path() + cookies_path_env = os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) + effective_cookie = cookiefile_path or ( + cookies_path_env if os.path.exists(cookies_path_env) else None) + has_ck = bool(effective_cookie) + yt_url = f"https://www.youtube.com/watch?v={video_id}" + BOT_MARKERS = ("sign in to confirm", "not a bot", "sign in to") + def _is_bot(s: str) -> bool: + return any(m in s.lower() for m in BOT_MARKERS) + + def _build_args(client: str) -> list: + args = ["--no-warnings", "--no-check-certificate", "--no-playlist", + "--extractor-args", f"youtube:player_client={client}"] + if client == "web": + args += ["--js-runtimes", f"node:{NODE_PATH}"] + if effective_cookie and client == "web": + args += ["--cookies", effective_cookie] + if proxy: + args += ["--proxy", proxy] + return args + + def _ytdlp_url(fmt: str, client: str): + cmd = ["yt-dlp", "-g", "-f", fmt] + _build_args(client) + [yt_url] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=90) + if res.returncode == 0 and res.stdout.strip(): + for line in res.stdout.strip().splitlines(): + line = line.strip() + if line.startswith("http"): + return line, False + return None, _is_bot(res.stderr or "") + except Exception: + return None, False + + clients = ["android", "ios"] + (["web"] if has_ck else []) + fmts = (["91", "92", "93", "94", "95", "96", + "best[protocol=m3u8_native]", "best[protocol=m3u8]", "best"] + if is_live else + ["best[ext=m3u8]", "best[protocol=m3u8_native]", + "best[protocol=m3u8]", "best", "best[ext=mp4]"]) + got_bot = False try: - # Lista de formatos a intentar en orden de prioridad - format_strategies = [ - ("best[ext=m3u8]", "Mejor calidad m3u8"), - ("best", "Mejor calidad disponible"), - ("best[ext=mp4]", "Mejor calidad MP4"), - ("bestvideo+bestaudio/best", "Mejor video y audio"), - ] - - for format_spec, description in format_strategies: - command = [ - "yt-dlp", - "-g", - "-f", format_spec, - "--no-warnings", - "--no-check-certificate", - "--extractor-args", "youtube:player_client=android", - ] - - if cookiefile_path: - command.extend(["--cookies", cookiefile_path]) - - command.append(url) - - try: - result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=60) - - if result.returncode == 0 and result.stdout.strip(): - # Obtener todas las URLs (puede haber video y audio separados) - urls = result.stdout.strip().split('\n') - - # Buscar la URL m3u8 o googlevideo - stream_url = None - for url_line in urls: - if url_line and url_line.strip(): - # Preferir URLs con m3u8 - if 'm3u8' in url_line.lower(): - stream_url = url_line.strip() - break - # O URLs de googlevideo - elif 'googlevideo.com' in url_line: - stream_url = url_line.strip() - break - - # Si no encontramos ninguna específica, usar la primera URL válida - if not stream_url and urls: - for url_line in urls: - if url_line and url_line.strip() and url_line.startswith('http'): - stream_url = url_line.strip() - break - - if stream_url: - return stream_url, None - - continue - - except subprocess.TimeoutExpired: - continue - except Exception: - continue - - return None, "No se pudo obtener la URL del stream. Verifica que el video esté EN VIVO (🔴) y no tenga restricciones." + for client in clients: + for fmt in fmts: + u, is_b = _ytdlp_url(fmt, client) + if u: + return u, title, description, is_live, None + if is_b: + got_bot = True finally: try: cookie_mgr.cleanup() except Exception: pass + if got_bot: + return None, title, description, is_live, ( + "YouTube detectó actividad de bot. " + "Sube cookies.txt: curl -X POST http://localhost:8282/upload_cookies -F 'file=@cookies.txt'" + ) + return None, title, description, is_live, ( + it.get("error") or + "No se pudo obtener la URL del stream. " + "Si es un live, verifica que esté EN VIVO (🔴) ahora mismo." + ) + +# ...existing code (old get_stream_url body — reemplazado arriba) — ELIMINAR... + @app.get("/transcript/{video_id}") def transcript_endpoint(video_id: str, lang: str = "es"): data, thumbnails, error = get_transcript_data(video_id, lang) + # Fallback automático a 'en' si no hay subs en el idioma pedido + if (error and lang != "en" and + "No se encontraron" in (error or "") and + "autenticación" not in (error or "")): + data_en, thumbnails_en, error_en = get_transcript_data(video_id, "en") + if data_en and not error_en: + data, thumbnails, error = data_en, thumbnails_en, None + if error: raise HTTPException(status_code=400, detail=error) @@ -794,68 +985,27 @@ def transcript_vtt(video_id: str, lang: str = 'es'): @app.get("/stream/{video_id}") def stream_endpoint(video_id: str): """ - Endpoint para obtener la URL de transmisión en vivo de un video de YouTube + Obtiene la URL de transmisión (m3u8/HLS) de un video/live de YouTube. - Retorna la URL m3u8 que se puede usar directamente con FFmpeg para retransmitir - a redes sociales usando RTMP. + - Para lives en vivo (🔴): devuelve URL HLS directa usable con FFmpeg/VLC. + - Para videos normales: devuelve la mejor URL de video disponible. - Ejemplo de uso con FFmpeg: - ffmpeg -re -i "URL_M3U8" -c copy -f flv rtmp://destino/stream_key + Ejemplo FFmpeg: + ffmpeg -re -i "URL_M3U8" -c copy -f flv rtmp://destino/stream_key """ - - stream_url, error = get_stream_url(video_id) + stream_url, title, description, is_live, error = get_stream_url(video_id) if error: raise HTTPException(status_code=400, detail=error) thumbnails = get_video_thumbnails(video_id) - - # Determinar el tipo de URL obtenida - url_type = "unknown" - if stream_url and "m3u8" in stream_url.lower(): - url_type = "m3u8/hls" - elif stream_url and "googlevideo.com" in stream_url: - url_type = "direct/mp4" - - # Obtener title y description con yt-dlp --dump-json - title = None - description = None - try: - _cookie_mgr = CookieManager() - _cookiefile = _cookie_mgr.get_cookiefile_path() - _cookies_path = _cookiefile or os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) - _proxy = os.getenv('API_PROXY', DEFAULT_PROXY) or None - - _cmd = [ - "yt-dlp", - "--skip-download", - "--dump-json", - "--no-warnings", - "--extractor-args", "youtube:player_client=android", - f"https://www.youtube.com/watch?v={video_id}" - ] - if _cookiefile: - _cmd.extend(["--cookies", _cookiefile]) - if _proxy: - _cmd.extend(["--proxy", _proxy]) - - _proc = subprocess.run(_cmd, capture_output=True, text=True, timeout=60) - if _proc.returncode == 0 and _proc.stdout: - _meta = json.loads(_proc.stdout) - title = _meta.get("title") - description = _meta.get("description") - except Exception: - pass - finally: - try: - _cookie_mgr.cleanup() - except Exception: - pass + url_type = "m3u8/hls" if stream_url and "m3u8" in stream_url.lower() else "direct/mp4" return { "video_id": video_id, "title": title, "description": description, + "is_live": is_live, "stream_url": stream_url, "url_type": url_type, "youtube_url": f"https://www.youtube.com/watch?v={video_id}", @@ -880,10 +1030,20 @@ async def upload_cookies(file: UploadFile = File(...)): content = await file.read() if not content: raise HTTPException(status_code=400, detail='Archivo vacío') - target = 'cookies.txt' + # Determinar ruta objetivo a partir de la variable de entorno + target = os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) + target_dir = os.path.dirname(target) or '.' + # Crear directorio si no existe + try: + os.makedirs(target_dir, exist_ok=True) + except Exception: + # Si no se puede crear el directorio, intentamos escribir en el working dir como fallback + target = os.path.basename(target) + # 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]}') @@ -906,9 +1066,8 @@ def debug_metadata(video_id: str): "--skip-download", "--dump-json", "--no-warnings", - "--extractor-args", "youtube:player_client=android", url - ] + ] + _yt_client_args(os.path.exists(cookies_path)) if os.path.exists(cookies_path): cmd.extend(["--cookies", cookies_path]) if proxy: @@ -991,7 +1150,7 @@ def debug_fetch_subs(video_id: str, lang: str = 'es'): '--sub-format', 'json3/vtt/srv3/best', '--output', out_template, url - ] + ] + _yt_subs_args(bool(cookiefile_path)) if cookiefile_path: cmd.extend(['--cookies', cookiefile_path]) @@ -1020,9 +1179,9 @@ def debug_fetch_subs(video_id: str, lang: str = 'es'): stderr = proc.stderr or '' rc = proc.returncode - # Buscar archivos generados + # Buscar archivos generados (yt-dlp usa doble extensión: ID.lang.vtt) generated = [] - for f in glob.glob(os.path.join(out_dir, f"{video_id}.*")): + for f in glob.glob(os.path.join(out_dir, f"{video_id}*")): size = None try: size = os.path.getsize(f) @@ -1090,7 +1249,7 @@ def fetch_vtt_subtitles(video_id: str, lang: str = 'es'): '--sub-format', 'vtt', '--output', out_template, url - ] + ] + _yt_subs_args(bool(cookiefile_path)) if cookiefile_path: cmd.extend(['--cookies', cookiefile_path]) @@ -1127,7 +1286,10 @@ def fetch_vtt_subtitles(video_id: str, lang: str = 'es'): return None, 'Acceso denegado al descargar subtítulos (HTTP 403). Usa cookies.txt con una cuenta autorizada.' return None, f'yt-dlp error: {proc.stderr[:1000]}' - files = glob.glob(os.path.join(tmpdir, f"{video_id}.*")) + # buscar archivos generados (doble extensión: ID.lang.vtt) + files = glob.glob(os.path.join(tmpdir, f"{video_id}*")) + files = [f for f in files if os.path.isfile(f) and + any(f.endswith(e) for e in ('.vtt', '.json3', '.srv3', '.srt', '.ttml'))] if not files: try: cookie_mgr.cleanup() @@ -1159,32 +1321,6 @@ def fetch_vtt_subtitles(video_id: str, lang: str = 'es'): pass return None, f'Error leyendo archivo de subtítulos: {str(e)[:200]}' -# Nuevo endpoint que devuelve VTT crudo, segmentos parseados y texto concatenado -@app.get('/transcript_vtt/{video_id}') -def transcript_vtt(video_id: str, lang: str = 'es'): - """Descarga (con yt-dlp) y devuelve subtítulos en VTT, además de segmentos parseados y texto concatenado.""" - vtt_text, error = fetch_vtt_subtitles(video_id, lang) - if error: - raise HTTPException(status_code=400, detail=error) - - # parsear VTT a segmentos usando parse_subtitle_format - segments = parse_subtitle_format(vtt_text, 'vtt') if vtt_text else [] - - combined_text = '\n'.join([s.get('text','') for s in segments]) - # format_text con texto limpio listo para procesamiento por agentes - format_text = format_segments_text(segments) - - thumbnails = get_video_thumbnails(video_id) - - return { - 'video_id': video_id, - 'vtt': vtt_text, - 'count': len(segments), - 'segments': segments, - 'text': combined_text, - 'format_text': format_text, - 'thumbnails': thumbnails - } @app.post('/upload_vtt/{video_id}') async def upload_vtt(video_id: str, file: UploadFile = File(...)): diff --git a/run-test.sh b/run-test.sh new file mode 100644 index 0000000..529fd11 --- /dev/null +++ b/run-test.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Sin cookies → android (sin n-challenge, sin Node.js) +# Con cookies → web + Node.js (Node.js resuelve n-challenge/signature) +# for_stream → android (mejor HLS en lives) +# Script de prueba completo — guarda TODO en /tmp/resultado.txt +exec > /tmp/resultado.txt 2>&1 + +REPO="/home/xesar/PycharmProjects/TubeScript-API" +cd "$REPO" + +echo "=== $(date) ===" + +# ---------- 1. Rebuild imagen ---------- +echo "--- Parando contenedor anterior ---" +docker rm -f tubescript_api 2>/dev/null || true + +echo "--- Construyendo imagen (CACHEBUST para forzar COPY . /app fresco) ---" +# --build-arg CACHEBUST=$(date +%s) invalida solo la capa COPY . /app +# (mucho más rápido que --no-cache que descarga todo desde cero) +docker build \ + --build-arg CACHEBUST="$(date +%s)" \ + -f Dockerfile.api \ + -t tubescript-api:latest . 2>&1 | tail -8 +echo "BUILD_RC=$?" + +# ---------- 2. Levantar ---------- +echo "--- Levantando contenedor ---" +docker run -d \ + --name tubescript_api \ + --network tubescript-network \ + -p 8282:8000 \ + -v "${REPO}/data:/app/data:rw" \ + -e PYTHONUNBUFFERED=1 \ + -e API_COOKIES_PATH=/app/data/cookies.txt \ + --restart unless-stopped \ + tubescript-api:latest +echo "RC_RUN=$?" + +sleep 10 + +echo "--- docker ps ---" +docker ps --format "{{.Names}} {{.Status}} {{.Ports}}" | grep tube || echo "NO CORRIENDO" + +echo "--- uvicorn logs ---" +docker logs tubescript_api 2>&1 | tail -4 + +echo "--- _yt_client_args en imagen (verificar lógica nueva) ---" +docker exec tubescript_api grep -A12 "def _yt_client_args" /app/main.py + +echo "" +echo "=== PRUEBA A: android SIN cookies ===" +docker exec tubescript_api yt-dlp \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=android" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 +echo "RC_A=$?" + +echo "" +echo "=== PRUEBA B: web + Node.js CON cookies ===" +docker exec tubescript_api yt-dlp \ + --cookies /app/data/cookies.txt \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=web" \ + --js-runtimes "node:/usr/bin/node" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 +echo "RC_B=$?" + +echo "" +echo "=== PRUEBA C: endpoint /debug/metadata ===" +sleep 2 +curl -s --max-time 30 "http://127.0.0.1:8282/debug/metadata/dQw4w9WgXcQ" \ + | python3 -c " +import sys,json +raw=sys.stdin.read() +try: + d=json.loads(raw) + if 'detail' in d: + print('ERROR:', d['detail'][:200]) + else: + print('title :', d.get('title','?')) + print('uploader:', d.get('uploader','?')) + print('duration:', d.get('duration','?')) +except Exception as e: + print('PARSE ERROR:', e) + print('RAW:', raw[:300]) +" +echo "RC_C=$?" + +echo "" +echo "=== PRUEBA D: endpoint /transcript?lang=en ===" +curl -s --max-time 90 "http://127.0.0.1:8282/transcript/dQw4w9WgXcQ?lang=en" \ + | python3 -c " +import sys,json +raw=sys.stdin.read() +try: + d=json.loads(raw) + if 'detail' in d: + print('ERROR:', d['detail'][:200]) + else: + print('count :', d.get('count','?')) + print('preview:', str(d.get('text','?'))[:120]) +except Exception as e: + print('PARSE ERROR:', e) + print('RAW:', raw[:200]) +" +echo "RC_D=$?" + +echo "" +echo "=== PRUEBA E: /transcript/QjK5wq8L3Ac (sin subtítulos — mensaje claro esperado) ===" +curl -s --max-time 60 "http://127.0.0.1:8282/transcript/QjK5wq8L3Ac?lang=es" \ + | python3 -c " +import sys,json +raw=sys.stdin.read() +try: + d=json.loads(raw) + if 'detail' in d: + print('DETALLE:', d['detail'][:250]) + else: + print('OK count:', d.get('count','?')) +except Exception as e: + print('RAW:', raw[:200]) +" +echo "RC_E=$?" + +echo "" +echo "=== PRUEBA F: /debug/metadata/QjK5wq8L3Ac (title con cookies) ===" +curl -s --max-time 30 "http://127.0.0.1:8282/debug/metadata/QjK5wq8L3Ac" \ + | python3 -c " +import sys,json +d=json.loads(sys.stdin.read()) +print('title:',d.get('title','?')) if 'title' in d else print('ERROR:',d.get('detail','?')[:200]) +" +echo "RC_F=$?" + +echo "" +echo "=== FIN ===" diff --git a/test-completo.sh b/test-completo.sh new file mode 100644 index 0000000..86064f0 --- /dev/null +++ b/test-completo.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Test completo de TubeScript-API con cookies reales +set -e + +REPO="/home/xesar/PycharmProjects/TubeScript-API" +cd "$REPO" +LOG="/tmp/tubescript_test_$(date +%H%M%S).log" + +echo "======================================================" +echo " TubeScript-API — Test completo" +echo " Log: $LOG" +echo "======================================================" + +# ---------- 1. Reconstruir imagen ---------- +echo "" +echo ">>> [1/5] Parando contenedor anterior..." +docker stop tubescript_api 2>/dev/null && echo " Parado." || echo " No estaba corriendo." +docker rm tubescript_api 2>/dev/null && echo " Eliminado." || echo " No existia." + +echo "" +echo ">>> [2/5] Construyendo imagen sin caché..." +docker build --no-cache -f Dockerfile.api -t tubescript-api:latest . 2>&1 \ + | grep -E "^#|DONE|ERROR|naming|Built" || true +echo " Build OK." + +# ---------- 2. Levantar ---------- +echo "" +echo ">>> [3/5] Levantando contenedor..." +docker run -d \ + --name tubescript_api \ + --network tubescript-network \ + -p 8282:8000 \ + -v "${REPO}/data:/app/data:rw" \ + -e PYTHONUNBUFFERED=1 \ + -e API_COOKIES_PATH=/app/data/cookies.txt \ + --restart unless-stopped \ + tubescript-api:latest +echo " Esperando arranque (8s)..." +sleep 8 + +docker logs tubescript_api 2>&1 | grep -E "Uvicorn running|startup|ERROR" | head -5 + +# ---------- 3. Verificar código en imagen ---------- +echo "" +echo ">>> [4/5] Verificando lógica de player_client en imagen..." +echo " Líneas clave en main.py:" +docker exec tubescript_api grep -n "mweb\|_yt_client_args\|client =" /app/main.py | head -10 + +# ---------- 4. Pruebas yt-dlp directas ---------- +echo "" +echo ">>> [5/5] Pruebas yt-dlp..." + +echo "" +echo " [A] android SIN cookies (cliente base, sin n-challenge):" +docker exec tubescript_api yt-dlp \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=android" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \ + && echo " ✅ OK" || echo " ❌ FALLO" + +echo "" +echo " [B] mweb,android CON cookies (mweb acepta cookies web, android como fallback):" +docker exec tubescript_api yt-dlp \ + --cookies /app/data/cookies.txt \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=mweb,android" \ + --print title \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \ + && echo " ✅ OK" || echo " ❌ FALLO" + +echo "" +echo " [C] dump-json CON cookies (para /debug/metadata):" +docker exec tubescript_api yt-dlp \ + --cookies /app/data/cookies.txt \ + --no-warnings --skip-download \ + --extractor-args "youtube:player_client=mweb" \ + --dump-json \ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \ + | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(' title:', d.get('title')); print(' uploader:', d.get('uploader'))" \ + && echo " ✅ OK" || echo " ❌ FALLO" + +# ---------- 5. Endpoints API ---------- +echo "" +echo " [D] Endpoint /debug/metadata:" +sleep 2 +RESULT=$(curl -s --max-time 30 "http://127.0.0.1:8282/debug/metadata/dQw4w9WgXcQ") +echo "$RESULT" | python3 -c " +import sys,json +d=json.loads(sys.stdin.read()) +if 'detail' in d: + print(' ❌ ERROR:', d['detail'][:200]) +else: + print(' ✅ title :', d.get('title','?')) + print(' ✅ uploader:', d.get('uploader','?')) + print(' ✅ is_live :', d.get('is_live','?')) +" 2>&1 + +echo "" +echo " [E] Endpoint /transcript/dQw4w9WgXcQ?lang=en:" +RESULT2=$(curl -s --max-time 60 "http://127.0.0.1:8282/transcript/dQw4w9WgXcQ?lang=en") +echo "$RESULT2" | python3 -c " +import sys,json +d=json.loads(sys.stdin.read()) +if 'detail' in d: + print(' ❌ ERROR:', d['detail'][:200]) +else: + print(' ✅ count :', d.get('count','?')) + print(' ✅ preview :', str(d.get('text',''))[:100]) +" 2>&1 + +echo "" +echo "======================================================" +echo " DONE. API: http://127.0.0.1:8282 Docs: http://127.0.0.1:8282/docs" +echo "======================================================" +