diff --git a/DOCKER_GUIDE.md b/DOCKER_GUIDE.md index 20b5866..27bbdbf 100644 --- a/DOCKER_GUIDE.md +++ b/DOCKER_GUIDE.md @@ -1,3 +1,7 @@ +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado en esta rama; las instrucciones que mencionan Streamlit se mantienen solo como referencia histórica. Usa la API (main.py) para operaciones actuales. + # 🐳 Guía de Uso con Docker - TubeScript-API ## 🎯 Descripción @@ -77,11 +81,6 @@ docker-compose logs -f Una vez iniciados los contenedores: -### Panel Web Streamlit (Frontend) -``` -http://localhost:8501 -``` - ### API FastAPI (Backend) ``` http://localhost:8080 @@ -102,9 +101,9 @@ http://localhost:8080/docs ├─────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Streamlit Panel │ │ FastAPI Backend │ │ -│ │ (Puerto 8501) │◄────►│ (Puerto 8000) │ │ -│ │ streamlit_panel │ │ tubescript_api │ │ +│ │ FastAPI Backend │ ◄──│ Streamlit Panel │ │ +│ │ (Puerto 8000) │ │ (Puerto 8501) │ │ +│ │ tubescript_api │ │ streamlit_panel │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ └─────────┬───────────────┘ │ @@ -155,9 +154,6 @@ docker-compose logs -f # O usar el script ./docker-logs.sh -# Solo el panel Streamlit -docker-compose logs -f streamlit-panel - # Solo la API docker-compose logs -f tubescript-api ``` @@ -178,7 +174,6 @@ docker-compose down docker-compose restart # Reiniciar uno específico -docker-compose restart streamlit-panel docker-compose restart tubescript-api ``` @@ -198,11 +193,10 @@ docker-compose up -d --build ```bash # Acceder al shell del contenedor -docker exec -it streamlit_panel bash docker exec -it tubescript_api bash # Ejecutar comando en el contenedor -docker exec streamlit_panel ls -la +docker exec tubescript_api ls -la ``` ### Limpiar Todo @@ -239,10 +233,6 @@ Edita `docker-compose.yml`: ```yaml services: - streamlit-panel: - ports: - - "9090:8501" # Cambiar puerto del host a 9090 - tubescript-api: ports: - "9091:8000" # Cambiar puerto del host a 9091 @@ -255,7 +245,6 @@ services: Los servicios tienen health checks configurados: - **FastAPI**: Verifica `/docs` cada 30 segundos -- **Streamlit**: Verifica puerto 8501 cada 30 segundos Ver estado de salud: diff --git a/DOCKER_RUN.md b/DOCKER_RUN.md new file mode 100644 index 0000000..3797b24 --- /dev/null +++ b/DOCKER_RUN.md @@ -0,0 +1,53 @@ +# Ejecutar servicios Docker (API y herramientas) por separado + +Este archivo explica cómo levantar el API de forma separada para probar cookies/proxy y cómo pasar la variable `API_PROXY` para usar Tor u otro proxy. + +Levantar solo el API (recomendado para desarrollo): + +```bash +# Reconstruir la imagen del API y levantar solo el servicio tubescript-api +API_PROXY="" docker compose -f docker-compose.yml build --no-cache tubescript-api +API_PROXY="" docker compose -f docker-compose.yml up -d tubescript-api + +# Ver logs +docker logs -f tubescript_api +``` + +Levantar el API con proxy (Tor ejemplo): + +```bash +# Asegúrate de tener Tor corriendo en tu host (socks5 en 127.0.0.1:9050) +# macOS: brew install tor && tor & + +# Levantar con API_PROXY apuntando a tor +API_PROXY="socks5h://host.docker.internal:9050" \ + docker compose -f docker-compose.yml up -d --build tubescript-api + +# Nota: en macOS dentro de contenedores, usa host.docker.internal para apuntar al host +``` + +Montar cookies y probar endpoints + +```bash +# Asegúrate de que ./cookies.txt existe en la raíz del proyecto o súbelo con la API +curl -X POST "http://127.0.0.1:8000/upload_cookies" -F "file=@/ruta/a/cookies.txt" + +# Probar metadata +curl -s "http://127.0.0.1:8000/debug/metadata/K08TM4OVLyo" | jq . + +# Intento de subtítulos verboso +curl -s "http://127.0.0.1:8000/debug/fetch_subs/K08TM4OVLyo?lang=es" | jq . + +# Obtener stream URL +curl -s "http://127.0.0.1:8000/stream/K08TM4OVLyo" | jq . +``` + +Rebuild del API (cuando hagas cambios en `main.py` o dependencias): + +```bash +# Reconstruir imagen (sin cache) y levantar +docker compose -f docker-compose.yml build --no-cache tubescript-api +docker compose -f docker-compose.yml up -d tubescript-api + +# Si cambias requirements.txt, reconstruye la imagen para que pip instale nuevas dependencias +``` diff --git a/GUIA_CHROME_TRANSCRIPTS.md b/GUIA_CHROME_TRANSCRIPTS.md new file mode 100644 index 0000000..fed838d --- /dev/null +++ b/GUIA_CHROME_TRANSCRIPTS.md @@ -0,0 +1,218 @@ +# 🎯 Guía Rápida: Obtener Transcripts de YouTube usando Chrome + +## ✅ Método Recomendado: Usar cookies desde Chrome directamente + +Este método es el **MÁS FÁCIL** porque no necesitas exportar cookies manualmente. yt-dlp lee las cookies directamente desde tu navegador. + +### 📋 Requisitos +- Chrome, Firefox o Brave instalado +- Estar logueado en YouTube en el navegador +- yt-dlp actualizado: `pip install -U yt-dlp` + +--- + +## 🚀 Opción 1: Usar el script automático (Recomendado) + +### Paso 1: Ejecutar el script +```bash +./get_transcript_chrome.sh VIDEO_ID +``` + +**Ejemplos:** +```bash +# Usar perfil por defecto de Chrome +./get_transcript_chrome.sh K08TM4OVLyo + +# Especificar idioma +./get_transcript_chrome.sh K08TM4OVLyo es + +# Usar Firefox en lugar de Chrome +./get_transcript_chrome.sh K08TM4OVLyo es firefox + +# Usar un perfil específico de Chrome +./get_transcript_chrome.sh K08TM4OVLyo es chrome Default +./get_transcript_chrome.sh K08TM4OVLyo es chrome "Profile 1" +``` + +### Paso 2: Resultado +El script generará: +- `VIDEO_ID.LANG.vtt` - Archivo de subtítulos +- `VIDEO_ID_transcript.txt` - Texto plano del transcript + +--- + +## 🔧 Opción 2: Comando manual de yt-dlp + +### Comando básico +```bash +yt-dlp --cookies-from-browser chrome \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +### Con perfil específico +```bash +yt-dlp --cookies-from-browser "chrome:Profile 1" \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +--- + +## 📂 Encontrar tus perfiles de Chrome + +### macOS: +```bash +ls -la ~/Library/Application\ Support/Google/Chrome/ +``` + +### Linux: +```bash +ls -la ~/.config/google-chrome/ +``` + +### Windows: +```cmd +dir "%LOCALAPPDATA%\Google\Chrome\User Data" +``` + +Los perfiles típicos son: +- `Default` - Perfil por defecto +- `Profile 1`, `Profile 2`, etc. - Perfiles adicionales + +--- + +## 🔍 Ver qué perfil estás usando en Chrome + +1. Abre Chrome +2. Haz clic en tu avatar (esquina superior derecha) +3. El nombre del perfil aparece en el menú +4. O ve a `chrome://version/` y busca "Profile Path" + +--- + +## ⚠️ Solución de Problemas + +### Problema 1: "ERROR: Unable to extract cookies" +**Solución**: Cierra Chrome completamente antes de ejecutar el comando. + +```bash +# macOS: Cerrar Chrome por completo +killall "Google Chrome" + +# Luego ejecuta el comando +./get_transcript_chrome.sh VIDEO_ID +``` + +### Problema 2: "HTTP Error 429" +Significa que YouTube está bloqueando tu IP. **Soluciones**: + +1. **Usa Tor/VPN** (más efectivo): +```bash +# Instalar y arrancar Tor +brew install tor && tor & + +# Usar con proxy +yt-dlp --cookies-from-browser chrome \ + --proxy "socks5h://127.0.0.1:9050" \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +2. **Espera unas horas** y vuelve a intentar +3. **Usa otra red** (móvil 4G/5G, otra WiFi) + +### Problema 3: No se generan archivos +Verifica que: +- Estés logueado en YouTube en ese navegador +- El video tenga subtítulos (prueba con otro video) +- Tengas la última versión de yt-dlp: `pip install -U yt-dlp` + +--- + +## 📖 Ejemplos Prácticos + +### Obtener transcript de un video en vivo +```bash +./get_transcript_chrome.sh VIDEO_ID_EN_VIVO es chrome +``` + +### Obtener en múltiples idiomas +```bash +# Español +./get_transcript_chrome.sh VIDEO_ID es chrome + +# Inglés +./get_transcript_chrome.sh VIDEO_ID en chrome + +# Español (variantes latinoamericanas) +yt-dlp --cookies-from-browser chrome \ + --skip-download --write-auto-sub \ + --sub-lang "es,es-419,es-MX" --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +### Convertir VTT a texto plano +```bash +# Usando grep (rápido) +grep -v "WEBVTT" VIDEO_ID.es.vtt | grep -v "^$" | grep -v "^[0-9][0-9]:" > transcript.txt + +# Usando el script de Python del proyecto +python3 << 'EOF' +from main import parse_subtitle_format +with open('VIDEO_ID.es.vtt', 'r') as f: + vtt = f.read() +segments = parse_subtitle_format(vtt, 'vtt') +text = '\n'.join([s['text'] for s in segments]) +print(text) +EOF +``` + +--- + +## 🔗 Integración con la API + +Una vez que obtengas el archivo VTT, puedes subirlo al API: + +```bash +curl -X POST "http://127.0.0.1:8000/upload_vtt/VIDEO_ID" \ + -F "file=@VIDEO_ID.es.vtt" | jq . +``` + +La API te devolverá: +- `segments`: Array de segmentos parseados con timestamps +- `text`: Texto concatenado completo +- `count`: Número de segmentos + +--- + +## 🎯 Resumen: ¿Qué método usar? + +| Situación | Método Recomendado | +|-----------|-------------------| +| **Uso diario, varios videos** | Script `get_transcript_chrome.sh` | +| **Comando rápido, un video** | `yt-dlp --cookies-from-browser chrome ...` | +| **Tienes HTTP 429** | Usar Tor/VPN + cookies desde Chrome | +| **No puedes ejecutar comandos** | Subir VTT manualmente al API | + +--- + +## 📞 Ayuda Adicional + +Ver documentación completa: `SOLUCION_HTTP_429_TRANSCRIPT.md` + +**Comando de ayuda del script:** +```bash +./get_transcript_chrome.sh +``` + +--- + +**Última actualización**: 2025-02-22 diff --git a/PANEL_STREAMLIT_GUIA.md b/PANEL_STREAMLIT_GUIA.md index c02855c..a1b3289 100644 --- a/PANEL_STREAMLIT_GUIA.md +++ b/PANEL_STREAMLIT_GUIA.md @@ -1,3 +1,7 @@ +# Nota: Streamlit eliminado + +> El panel Streamlit fue eliminado en esta rama; este documento se mantiene solo como referencia histórica. Usa la API (main.py) para operar. + # 📺 TubeScript - Panel de Control Web Panel de control web interactivo para gestionar transmisiones en vivo desde YouTube hacia múltiples plataformas de redes sociales simultáneamente. diff --git a/QUICKSTART_TRANSCRIPTS.md b/QUICKSTART_TRANSCRIPTS.md new file mode 100644 index 0000000..22e2c68 --- /dev/null +++ b/QUICKSTART_TRANSCRIPTS.md @@ -0,0 +1,173 @@ +# 🚀 INICIO RÁPIDO: Obtener Transcripts de YouTube + +## ⚡ Método Más Rápido (30 segundos) + +### Usando cookies de Chrome directamente: + +```bash +# 1. Asegúrate de estar logueado en YouTube en Chrome +# 2. Ejecuta este comando: + +yt-dlp --cookies-from-browser chrome --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=K08TM4OVLyo" + +# 3. Listo! Verás el archivo: K08TM4OVLyo.es.vtt +``` + +--- + +## 🎯 3 Formas de Obtener Transcripts + +### 1️⃣ Script Bash (Recomendado - MÁS FÁCIL) +```bash +./get_transcript_chrome.sh VIDEO_ID +``` + +**Ventajas**: +- ✅ Genera VTT + TXT automáticamente +- ✅ Muestra preview del contenido +- ✅ Maneja múltiples perfiles de Chrome + +### 2️⃣ Script Python +```bash +python3 fetch_transcript.py VIDEO_ID es chrome +``` + +**Ventajas**: +- ✅ Genera JSON + TXT +- ✅ Integrado con el proyecto +- ✅ Maneja formatos automáticamente + +### 3️⃣ Comando directo yt-dlp +```bash +yt-dlp --cookies-from-browser chrome \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +**Ventajas**: +- ✅ Control total +- ✅ Personalizable +- ✅ Para usuarios avanzados + +--- + +## 🔑 Usar Perfil Específico de Chrome + +### Ver perfiles disponibles: +```bash +# macOS +ls ~/Library/Application\ Support/Google/Chrome/ + +# Verás algo como: +# Default +# Profile 1 +# Profile 2 +``` + +### Usar un perfil específico: +```bash +# Opción 1: Script bash +./get_transcript_chrome.sh VIDEO_ID es chrome "Profile 1" + +# Opción 2: Python +python3 fetch_transcript.py VIDEO_ID es "chrome:Profile 1" + +# Opción 3: yt-dlp directo +yt-dlp --cookies-from-browser "chrome:Profile 1" \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +--- + +## ❌ Si te sale "HTTP Error 429" + +YouTube está bloqueando tu IP. **Solución rápida con Tor**: + +```bash +# 1. Instalar Tor +brew install tor # macOS +# sudo apt install tor # Linux + +# 2. Iniciar Tor +tor & + +# 3. Usar con proxy +yt-dlp --cookies-from-browser chrome \ + --proxy "socks5h://127.0.0.1:9050" \ + --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +--- + +## 📊 Ejemplo Completo + +```bash +# 1. Obtener transcript +./get_transcript_chrome.sh K08TM4OVLyo es chrome + +# Salida: +# ✅ Archivo generado: K08TM4OVLyo.es.vtt +# 💾 Texto guardado en: K08TM4OVLyo_transcript.txt + +# 2. Ver el transcript +cat K08TM4OVLyo_transcript.txt + +# 3. Subir al API (opcional) +curl -X POST "http://127.0.0.1:8000/upload_vtt/K08TM4OVLyo" \ + -F "file=@K08TM4OVLyo.es.vtt" | jq . +``` + +--- + +## 🆘 Problemas Comunes + +| Problema | Solución | +|----------|----------| +| "Unable to extract cookies" | Cierra Chrome: `killall "Google Chrome"` | +| "HTTP Error 429" | Usa Tor/VPN (ver arriba) | +| No se genera archivo | Verifica que estés logueado en YouTube | +| "yt-dlp not found" | Instala: `pip install yt-dlp` | + +--- + +## 📚 Más Información + +- **Guía completa Chrome**: `GUIA_CHROME_TRANSCRIPTS.md` +- **Soluciones HTTP 429**: `SOLUCION_HTTP_429_TRANSCRIPT.md` +- **API Endpoints**: Consulta `/docs` de la API + +--- + +## 🎬 Demo de 30 segundos + +```bash +# Instalar yt-dlp (si no lo tienes) +pip install yt-dlp + +# Obtener transcript +yt-dlp --cookies-from-browser chrome --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=K08TM4OVLyo" + +# Convertir a texto plano +grep -v "WEBVTT" K08TM4OVLyo.es.vtt | grep -v "^$" | grep -v "^[0-9][0-9]:" > transcript.txt + +# Ver el transcript +cat transcript.txt +``` + +**¡Listo! 🎉** + +--- + +**Última actualización**: 2025-02-22 diff --git a/RESUMEN_EJECUTIVO.txt b/RESUMEN_EJECUTIVO.txt index 7c118c8..effe44a 100644 --- a/RESUMEN_EJECUTIVO.txt +++ b/RESUMEN_EJECUTIVO.txt @@ -1,3 +1,5 @@ +NOTA: El frontend Streamlit fue eliminado en esta rama. El documento conserva información histórica sobre el panel, pero el flujo actual se basa en la API (main.py). + ╔══════════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ ✅ ACTUALIZACIÓN COMPLETADA CON ÉXITO ║ diff --git a/SOLUCION_HTTP_429_TRANSCRIPT.md b/SOLUCION_HTTP_429_TRANSCRIPT.md new file mode 100644 index 0000000..a5ca097 --- /dev/null +++ b/SOLUCION_HTTP_429_TRANSCRIPT.md @@ -0,0 +1,291 @@ +# 🎯 Solución HTTP 429 - Extracción de Subtítulos YouTube + +## 📋 Estado Actual + +### ✅ Implementado +- ✅ Endpoint `/transcript/{video_id}` - Obtiene transcript parseado (segmentos + texto) +- ✅ Endpoint `/transcript_vtt/{video_id}` - Descarga VTT con yt-dlp y devuelve crudo + parseado +- ✅ Endpoint `/stream/{video_id}` - Obtiene URL m3u8 para streaming +- ✅ Endpoint `/upload_vtt/{video_id}` - Permite subir VTT manualmente y parsearlo +- ✅ Endpoint `/debug/metadata/{video_id}` - Muestra metadata de yt-dlp +- ✅ Endpoint `/debug/fetch_subs/{video_id}` - Intenta descargar con verbose y devuelve logs +- ✅ Soporte de cookies (`API_COOKIES_PATH=/app/cookies.txt`) +- ✅ Soporte de proxy (`API_PROXY=socks5h://127.0.0.1:9050`) +- ✅ Script `fetch_transcript.py` - CLI para obtener transcript y guardarlo en JSON/TXT +- ✅ Script `docker-update-ytdlp.sh` - Actualiza yt-dlp en contenedores sin rebuild + +### ❌ Problema Actual +**HTTP Error 429: Too Many Requests** al intentar descargar subtítulos desde YouTube. + +- **Causa**: YouTube está limitando peticiones al endpoint `timedtext` desde tu IP +- **Afectado**: Tanto `requests` como `yt-dlp` reciben 429 +- **Ocurre**: Al intentar descargar subtítulos automáticos (ASR) de videos + +## 🔧 Soluciones Disponibles + +### Opción 1: Usar Proxy/Tor (Recomendado) +Evita el rate-limit cambiando la IP de salida. + +#### Setup rápido con Tor: +```bash +# Instalar Tor +brew install tor # macOS +# sudo apt install tor # Linux + +# Iniciar Tor +tor & + +# Exportar proxy y arrancar API +export API_PROXY="socks5h://127.0.0.1:9050" +docker compose -f docker-compose.yml up -d --build tubescript-api + +# Probar +curl "http://127.0.0.1:8000/transcript_vtt/K08TM4OVLyo?lang=es" | jq . +``` + +### Opción 2: Usar Cookies Directamente desde Chrome/Firefox (Recomendado) +`yt-dlp` puede leer cookies directamente desde tu navegador sin necesidad de exportarlas. + +#### Opción 2A: Usar cookies del navegador directamente +```bash +# Chrome (macOS) +yt-dlp --cookies-from-browser chrome --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" + +# Chrome con perfil específico +yt-dlp --cookies-from-browser chrome:Profile1 --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" + +# Firefox +yt-dlp --cookies-from-browser firefox --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" + +# Brave +yt-dlp --cookies-from-browser brave --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +**Encontrar perfiles de Chrome:** +```bash +# macOS +ls -la ~/Library/Application\ Support/Google/Chrome/ + +# Linux +ls -la ~/.config/google-chrome/ + +# Los perfiles típicos son: Default, Profile 1, Profile 2, etc. +``` + +#### Opción 2B: Exportar cookies manualmente (si la opción 2A no funciona) +1. Instala extensión "cookies.txt" en Chrome/Firefox +2. Abre YouTube estando logueado en tu cuenta +3. Exporta cookies (extensión → Export → `cookies.txt`) +4. Reemplaza `./cookies.txt` en la raíz del proyecto +5. Reinicia contenedor: +```bash +docker compose down +docker compose up -d --build tubescript-api +``` + +### Opción 3: Cambiar de IP +- Usar VPN +- Tethering móvil (4G/5G) +- Esperar algunas horas (el rate-limit puede ser temporal) + +### Opción 4: Workaround Manual (Más Rápido) +Si necesitas el transcript YA y no puedes resolver el 429: + +#### Opción 4A: Subir VTT manualmente al API +```bash +# Descarga el VTT desde otro equipo/navegador donde no esté bloqueado +# o pídele a alguien que te lo pase + +# Súbelo al API +curl -X POST "http://127.0.0.1:8000/upload_vtt/VIDEO_ID" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@/ruta/al/archivo.vtt" | jq . + +# El API responde con: { segments, text, count, path } +``` + +#### Opción 4B: Usar youtube-transcript-api (Python alternativo) +```bash +pip install youtube-transcript-api + +python3 << 'EOF' +from youtube_transcript_api import YouTubeTranscriptApi +import json + +video_id = "K08TM4OVLyo" +try: + transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['es']) + with open(f"{video_id}_transcript.json", 'w', encoding='utf-8') as f: + json.dump(transcript, f, ensure_ascii=False, indent=2) + print(f"✅ Guardado: {video_id}_transcript.json") +except Exception as e: + print(f"❌ Error: {e}") +EOF +``` + +#### Opción 4C: Script usando cookies desde Chrome directamente +```bash +# Crear script que usa cookies del navegador +cat > get_transcript_chrome.sh << 'SCRIPT' +#!/bin/bash +VIDEO_ID="${1:-K08TM4OVLyo}" +LANG="${2:-es}" +BROWSER="${3:-chrome}" # chrome, firefox, brave, etc. + +echo "🔍 Obteniendo transcript de: $VIDEO_ID" +echo " Idioma: $LANG" +echo " Navegador: $BROWSER" + +yt-dlp --cookies-from-browser "$BROWSER" \ + --skip-download --write-auto-sub \ + --sub-lang "$LANG" --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=$VIDEO_ID" 2>&1 | grep -E "Writing|ERROR|✓" + +if [ -f "${VIDEO_ID}.${LANG}.vtt" ]; then + echo "✅ Archivo generado: ${VIDEO_ID}.${LANG}.vtt" + echo "📝 Primeras líneas:" + head -n 20 "${VIDEO_ID}.${LANG}.vtt" +else + echo "❌ No se generó el archivo VTT" +fi +SCRIPT + +chmod +x get_transcript_chrome.sh + +# Usar el script +./get_transcript_chrome.sh VIDEO_ID es chrome +``` + +## 📚 Uso de Endpoints + +### 1. Obtener Transcript (intenta automáticamente con yt-dlp) +```bash +curl "http://127.0.0.1:8000/transcript/K08TM4OVLyo?lang=es" | jq . +``` + +Respuesta: +```json +{ + "video_id": "K08TM4OVLyo", + "count": 150, + "segments": [...], + "text": "texto concatenado de todos los segmentos" +} +``` + +### 2. Obtener VTT Crudo + Parseado +```bash +curl "http://127.0.0.1:8000/transcript_vtt/K08TM4OVLyo?lang=es" | jq . +``` + +Respuesta: +```json +{ + "video_id": "K08TM4OVLyo", + "vtt": "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHola...", + "count": 150, + "segments": [...], + "text": "..." +} +``` + +### 3. Debug: Ver Metadata +```bash +curl "http://127.0.0.1:8000/debug/metadata/K08TM4OVLyo" | jq . +``` + +### 4. Debug: Intentar Descarga Verbose +```bash +curl "http://127.0.0.1:8000/debug/fetch_subs/K08TM4OVLyo?lang=es" | jq . +``` + +Respuesta incluye: +- `rc`: código de salida de yt-dlp +- `stdout_tail`: últimas 2000 chars de stdout +- `stderr_tail`: últimas 2000 chars de stderr (aquí verás "HTTP Error 429") +- `generated`: lista de archivos generados (si hubo éxito) + +### 5. Subir VTT Manualmente +```bash +curl -X POST "http://127.0.0.1:8000/upload_vtt/K08TM4OVLyo" \ + -F "file=@K08TM4OVLyo.vtt" | jq . +``` + +## 🐳 Docker + +### Comandos útiles +```bash +# Rebuild y levantar (aplica cambios en main.py) +docker compose -f docker-compose.yml build --no-cache tubescript-api +docker compose -f docker-compose.yml up -d tubescript-api + +# Ver logs +docker logs -f tubescript_api + +# Actualizar yt-dlp (sin rebuild) +bash docker-update-ytdlp.sh + +# Entrar al contenedor +docker exec -it tubescript_api /bin/sh + +# Verificar cookies montadas +docker exec -it tubescript_api cat /app/cookies.txt | head -n 10 +``` + +### Variables de Entorno +```yaml +environment: + - API_COOKIES_PATH=/app/cookies.txt + - API_PROXY=socks5h://127.0.0.1:9050 # opcional +``` + +## 🔍 Diagnóstico + +### Verificar HTTP 429 +```bash +# Host (local) +yt-dlp --verbose --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + --cookies ./cookies.txt -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=K08TM4OVLyo" 2>&1 | grep -i "error\|429" + +# Dentro del contenedor +docker exec -it tubescript_api sh -c \ + "yt-dlp --verbose --skip-download --write-auto-sub \ + --sub-lang es --sub-format vtt \ + --cookies /app/cookies.txt -o '/tmp/%(id)s.%(ext)s' \ + 'https://www.youtube.com/watch?v=K08TM4OVLyo'" 2>&1 | grep -i "error\|429" +``` + +### Probar con otro video +```bash +# Prueba con un video de noticias 24/7 (menos probabilidad de 429) +curl "http://127.0.0.1:8000/transcript/NNL3iiDf1HI?lang=es" | jq . +``` + +## 📖 Referencias + +- [yt-dlp GitHub](https://github.com/yt-dlp/yt-dlp) +- [Guía PO Token (si yt-dlp requiere)](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide) +- [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) + +## 🎯 Próximos Pasos + +1. **Inmediato**: Probar Opción 1 (Tor) o Opción 4B (youtube-transcript-api) +2. **Corto plazo**: Re-exportar cookies válidas (Opción 2) +3. **Mediano plazo**: Implementar rotación de IPs/proxies automática +4. **Largo plazo**: Considerar usar YouTube Data API v3 (requiere API key pero evita rate-limits) + +--- + +**Última actualización**: 2025-02-22 +**Estado**: HTTP 429 confirmado; soluciones alternativas implementadas diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d2fc118..241cd6a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -5,18 +5,42 @@ services: build: context: . dockerfile: Dockerfile.api - image: tubescript-api:local container_name: tubescript-api + image: tubescript-api:local ports: - "8000:8000" - environment: - - TZ=UTC - - API_BASE_URL=${API_BASE_URL:-http://localhost:8000} volumes: - ./cookies.txt:/app/cookies.txt + - ./stream_config.json:/app/stream_config.json:ro + - ./streams_state.json:/app/streams_state.json:rw + environment: + API_BASE_URL: http://localhost:8000 + TZ: UTC restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/docs" ] + test: ["CMD", "curl", "-f", "http://localhost:8000/docs"] interval: 30s timeout: 10s retries: 3 + + streamlit: + build: + context: . + dockerfile: Dockerfile.streamlit + container_name: tubescript-streamlit + image: tubescript-streamlit:local + depends_on: + - api + ports: + - "8501:8501" + volumes: + - ./stream_config.json:/app/stream_config.json:ro + - ./cookies.txt:/app/cookies.txt:ro + environment: + API_BASE_URL: http://localhost:8000 + TZ: UTC + restart: unless-stopped + +networks: + default: + name: tubescript-api_default diff --git a/docker-compose.yml b/docker-compose.yml index f28d935..1c284b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,24 @@ -version: '3.8' - services: # Servicio FastAPI - Backend API tubescript-api: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.api container_name: tubescript_api + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload ports: - - "8080:8000" + - "8000:8000" volumes: - - ./cookies.txt:/app/cookies.txt:ro # Solo lectura - - ./stream_config.json:/app/stream_config.json + - ./:/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 # Directorio para datos persistentes + - ./data:/app/data 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_PROXY=${API_PROXY:-} restart: unless-stopped networks: - tubescript-network diff --git a/docker-stop-all.sh b/docker-stop-all.sh index 021ae69..a291556 100755 --- a/docker-stop-all.sh +++ b/docker-stop-all.sh @@ -8,16 +8,20 @@ echo "🛑 Deteniendo servicios..." echo "" # Detener servicios individuales -echo "Deteniendo FastAPI..." -docker stop tubescript_api 2>/dev/null && echo "✅ FastAPI detenido" || echo "⚠️ FastAPI no estaba corriendo" +services=(tubescript_api streamlit_panel) -echo "Deteniendo Streamlit..." -docker stop streamlit_panel 2>/dev/null && echo "✅ Streamlit detenido" || echo "⚠️ Streamlit no estaba corriendo" +for s in "${services[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -q "^$s$"; then + echo "Deteniendo $s..." + docker stop $s 2>/dev/null && echo "✅ $s detenido" || echo "⚠️ $s no estaba corriendo" + fi +done echo "" echo "🗑️ Eliminando contenedores..." -docker rm tubescript_api 2>/dev/null -docker rm streamlit_panel 2>/dev/null +for s in "${services[@]}"; do + docker rm $s 2>/dev/null +done echo "" echo "✅ Todos los servicios han sido detenidos" diff --git a/docker-update-ytdlp.sh b/docker-update-ytdlp.sh index 9aebe48..f9ef5b3 100755 --- a/docker-update-ytdlp.sh +++ b/docker-update-ytdlp.sh @@ -29,52 +29,61 @@ print_error() { echo "🔍 Verificando contenedores..." if ! docker ps | grep -q streamlit_panel; then - print_error "El contenedor streamlit_panel no está corriendo" - echo "Inicia los contenedores con: docker-compose up -d" - exit 1 + print_warning "El contenedor streamlit_panel no está corriendo" +else + print_success "Contenedor streamlit_panel encontrado" fi if ! docker ps | grep -q tubescript_api; then print_error "El contenedor tubescript_api no está corriendo" echo "Inicia los contenedores con: docker-compose up -d" exit 1 +else + print_success "Contenedor tubescript_api encontrado" fi -print_success "Contenedores encontrados" echo "" -# Actualizar yt-dlp en streamlit_panel -echo "📦 Actualizando yt-dlp en streamlit_panel..." -docker exec streamlit_panel pip install --upgrade yt-dlp +# Actualizar yt-dlp en streamlit_panel si existe +if docker ps --format '{{.Names}}' | grep -q '^streamlit_panel$'; then + echo "📦 Actualizando yt-dlp en streamlit_panel..." + docker exec streamlit_panel pip install --upgrade yt-dlp -if [ $? -eq 0 ]; then - print_success "yt-dlp actualizado en streamlit_panel" + if [ $? -eq 0 ]; then + print_success "yt-dlp actualizado en streamlit_panel" - # Verificar versión - version=$(docker exec streamlit_panel python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) - if [ ! -z "$version" ]; then - echo " Versión instalada: $version" - fi + # Verificar versión + version=$(docker exec streamlit_panel python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) + if [ ! -z "$version" ]; then + echo " Versión instalada: $version" + fi + else + print_error "Error al actualizar yt-dlp en streamlit_panel" + fi else - print_error "Error al actualizar yt-dlp en streamlit_panel" + echo "streamlit_panel no encontrado — omitiendo actualización en Streamlit" fi echo "" # Actualizar yt-dlp en tubescript_api -echo "📦 Actualizando yt-dlp en tubescript_api..." -docker exec tubescript_api pip install --upgrade yt-dlp +if docker ps --format '{{.Names}}' | grep -q '^tubescript_api$'; then + echo "📦 Actualizando yt-dlp en tubescript_api..." + docker exec tubescript_api pip install --upgrade yt-dlp -if [ $? -eq 0 ]; then - print_success "yt-dlp actualizado en tubescript_api" + if [ $? -eq 0 ]; then + print_success "yt-dlp actualizado en tubescript_api" - # Verificar versión - version=$(docker exec tubescript_api python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) - if [ ! -z "$version" ]; then - echo " Versión instalada: $version" - fi + # Verificar versión + version=$(docker exec tubescript_api python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) + if [ ! -z "$version" ]; then + echo " Versión instalada: $version" + fi + else + print_error "Error al actualizar yt-dlp en tubescript_api" + fi else - print_error "Error al actualizar yt-dlp en tubescript_api" + echo "tubescript_api no encontrado — omitiendo actualización en API" fi echo "" @@ -82,6 +91,7 @@ echo "════════════════════════ print_success "Actualización completada" echo "═══════════════════════════════════════════════════════════" echo "" -echo "💡 Ahora puedes probar con un video en vivo en:" -echo " http://localhost:8501" +echo "💡 Ahora puedes probar con un video en vivo en la API Docs:" +echo " http://localhost:8080/docs" +echo " Para obtener stream URL: curl http://localhost:8080/stream/VIDEO_ID" echo "" diff --git a/fetch_transcript.py b/fetch_transcript.py new file mode 100755 index 0000000..bbb2a42 --- /dev/null +++ b/fetch_transcript.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Script para obtener transcript de un video de YouTube usando las funciones del proyecto. +Maneja HTTP 429 y guarda el resultado en JSON. + +Uso: + python3 fetch_transcript.py VIDEO_ID [LANG] [BROWSER] + +Ejemplos: + python3 fetch_transcript.py K08TM4OVLyo es + python3 fetch_transcript.py K08TM4OVLyo es chrome + python3 fetch_transcript.py K08TM4OVLyo es "chrome:Profile 1" +""" +import sys +import json +import os +import subprocess +import tempfile +import glob +from main import parse_subtitle_format + +def fetch_with_browser_cookies(video_id, lang="es", browser="chrome"): + """Intenta obtener transcript usando cookies desde el navegador directamente.""" + print(f"🔑 Usando cookies desde navegador: {browser}") + + with tempfile.TemporaryDirectory() as tmpdir: + cmd = [ + "yt-dlp", + "--cookies-from-browser", browser, + "--skip-download", + "--write-auto-sub", + "--write-sub", + "--sub-lang", lang, + "--sub-format", "vtt", + "-o", os.path.join(tmpdir, "%(id)s.%(ext)s"), + f"https://www.youtube.com/watch?v={video_id}" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + # Buscar archivos VTT generados + files = glob.glob(os.path.join(tmpdir, f"{video_id}*.vtt")) + if files: + with open(files[0], 'r', encoding='utf-8') as f: + vtt_content = f.read() + segments = parse_subtitle_format(vtt_content, 'vtt') + return segments, None + else: + stderr = result.stderr or '' + return None, f"No se generaron archivos. Error: {stderr[:500]}" + + except subprocess.TimeoutExpired: + return None, "Timeout al ejecutar yt-dlp" + except FileNotFoundError: + return None, "yt-dlp no está instalado. Ejecuta: pip install yt-dlp" + except Exception as e: + return None, f"Error: {str(e)[:200]}" + +def main(): + if len(sys.argv) < 2: + print("Uso: python3 fetch_transcript.py VIDEO_ID [LANG] [BROWSER]") + print("") + print("Ejemplos:") + print(" python3 fetch_transcript.py K08TM4OVLyo") + print(" python3 fetch_transcript.py K08TM4OVLyo es") + print(" python3 fetch_transcript.py K08TM4OVLyo es chrome") + print(" python3 fetch_transcript.py K08TM4OVLyo es 'chrome:Profile 1'") + print(" python3 fetch_transcript.py K08TM4OVLyo es firefox") + print("") + sys.exit(1) + + video_id = sys.argv[1] + lang = sys.argv[2] if len(sys.argv) > 2 else "es" + browser = sys.argv[3] if len(sys.argv) > 3 else None + + print(f"🔍 Intentando obtener transcript para: {video_id}") + print(f" Idioma: {lang}") + + if browser: + print(f" Método: Cookies desde {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 + 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}") + sys.exit(1) + + if not segments: + print("❌ No se obtuvieron segmentos") + sys.exit(1) + + print(f"✅ Éxito: {len(segments)} segmentos obtenidos") + + # Guardar a JSON + output_file = f"{video_id}_transcript.json" + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(segments, f, ensure_ascii=False, indent=2) + print(f"💾 Guardado en: {output_file}") + + # Guardar texto concatenado + text_file = f"{video_id}_transcript.txt" + combined_text = "\n".join([seg.get('text', '') for seg in segments]) + with open(text_file, 'w', encoding='utf-8') as f: + f.write(combined_text) + print(f"📄 Texto guardado en: {text_file}") + + # Mostrar primeros 10 segmentos + print("\n📝 Primeros 10 segmentos:") + for seg in segments[:10]: + print(f" [{seg.get('start', 0):.1f}s] {seg.get('text', '')}") + +if __name__ == "__main__": + main() diff --git a/fix-ytdlp.sh b/fix-ytdlp.sh index 0309ab4..5bb5411 100755 --- a/fix-ytdlp.sh +++ b/fix-ytdlp.sh @@ -1,132 +1,20 @@ #!/bin/bash -# Script para forzar reinstalación de yt-dlp en contenedores +# Script de arreglo de yt-dlp - solo actúa si el contenedor existe -echo "═══════════════════════════════════════════════════════════" -echo " 🔧 Reinstalación Forzada de yt-dlp" -echo "═══════════════════════════════════════════════════════════" -echo "" - -# Colores -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -print_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -print_error() { - echo -e "${RED}❌ $1${NC}" -} - -print_info() { - echo -e "${YELLOW}ℹ️ $1${NC}" -} - -# Verificar Docker -if ! command -v docker &> /dev/null; then - print_error "Docker no está instalado" - exit 1 -fi - -# Verificar contenedores -echo "🔍 Verificando contenedores..." -if ! docker ps | grep -q streamlit_panel; then - print_error "El contenedor streamlit_panel no está corriendo" - print_info "Inicia con: docker-compose up -d" - exit 1 -fi - -if ! docker ps | grep -q tubescript_api; then - print_error "El contenedor tubescript_api no está corriendo" - print_info "Inicia con: docker-compose up -d" - exit 1 -fi - -print_success "Contenedores encontrados" -echo "" - -# Desinstalar yt-dlp actual -echo "🗑️ Desinstalando yt-dlp antiguo en streamlit_panel..." -docker exec streamlit_panel pip uninstall -y yt-dlp 2>/dev/null -docker exec streamlit_panel pip uninstall -y yt_dlp 2>/dev/null - -echo "🗑️ Desinstalando yt-dlp antiguo en tubescript_api..." -docker exec tubescript_api pip uninstall -y yt-dlp 2>/dev/null -docker exec tubescript_api pip uninstall -y yt_dlp 2>/dev/null - -echo "" - -# Limpiar cache de pip -echo "🧹 Limpiando cache de pip..." -docker exec streamlit_panel pip cache purge 2>/dev/null -docker exec tubescript_api pip cache purge 2>/dev/null - -echo "" - -# Reinstalar yt-dlp desde cero -echo "📦 Reinstalando yt-dlp en streamlit_panel..." -docker exec streamlit_panel pip install --no-cache-dir --force-reinstall yt-dlp - -if [ $? -eq 0 ]; then - print_success "yt-dlp reinstalado en streamlit_panel" - - # Verificar versión - version=$(docker exec streamlit_panel python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) - if [ ! -z "$version" ]; then - print_info "Versión instalada: $version" - fi +if docker ps --format '{{.Names}}' | grep -q '^streamlit_panel$'; then + echo "Actualizando yt-dlp en streamlit_panel..." + docker exec streamlit_panel pip uninstall -y yt-dlp yt_dlp 2>/dev/null || true + docker exec streamlit_panel pip install --no-cache-dir --force-reinstall yt-dlp else - print_error "Error al reinstalar yt-dlp en streamlit_panel" + echo "Contenedor streamlit_panel no encontrado — saltando acciones relacionadas con Streamlit" fi -echo "" - -echo "📦 Reinstalando yt-dlp en tubescript_api..." -docker exec tubescript_api pip install --no-cache-dir --force-reinstall yt-dlp - -if [ $? -eq 0 ]; then - print_success "yt-dlp reinstalado en tubescript_api" - - # Verificar versión - version=$(docker exec tubescript_api python3 -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) - if [ ! -z "$version" ]; then - print_info "Versión instalada: $version" - fi +# Actualizar en el contenedor de API si existe +if docker ps --format '{{.Names}}' | grep -q '^tubescript_api$'; then + echo "Actualizando yt-dlp en tubescript_api..." + docker exec tubescript_api pip uninstall -y yt-dlp yt_dlp 2>/dev/null || true + docker exec tubescript_api pip install --no-cache-dir --force-reinstall yt-dlp else - print_error "Error al reinstalar yt-dlp en tubescript_api" + echo "Contenedor tubescript_api no encontrado — asegúrate que la API esté corriendo si deseas actualizar yt-dlp" fi - -echo "" - -# Verificar instalación -echo "🔍 Verificando instalación..." -echo "" - -echo "Streamlit Panel:" -docker exec streamlit_panel yt-dlp --version 2>&1 | head -1 -echo "" - -echo "Tubescript API:" -docker exec tubescript_api yt-dlp --version 2>&1 | head -1 -echo "" - -# Reiniciar contenedores -echo "🔄 Reiniciando contenedores para aplicar cambios..." -docker-compose restart streamlit-panel tubescript-api - -echo "" -echo "═══════════════════════════════════════════════════════════" -print_success "Reinstalación completada" -echo "═══════════════════════════════════════════════════════════" -echo "" -print_info "Ahora puedes probar con un video en vivo en:" -echo " http://localhost:8501" -echo "" -print_info "Si el error persiste, ejecuta:" -echo " docker-compose down" -echo " docker-compose build --no-cache" -echo " docker-compose up -d" -echo "" diff --git a/get_transcript_chrome.sh b/get_transcript_chrome.sh new file mode 100644 index 0000000..a118ded --- /dev/null +++ b/get_transcript_chrome.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Script para obtener transcripts de YouTube usando cookies desde Chrome/Firefox directamente +# Uso: ./get_transcript_chrome.sh VIDEO_ID [LANG] [BROWSER] [PROFILE] + +VIDEO_ID="${1}" +LANG="${2:-es}" +BROWSER="${3:-chrome}" +PROFILE="${4:-}" + +if [ -z "$VIDEO_ID" ]; then + echo "❌ Error: Debes proporcionar un VIDEO_ID" + echo "" + echo "Uso: $0 VIDEO_ID [LANG] [BROWSER] [PROFILE]" + echo "" + echo "Ejemplos:" + echo " $0 K08TM4OVLyo" + echo " $0 K08TM4OVLyo es chrome" + echo " $0 K08TM4OVLyo es chrome:Profile1" + echo " $0 K08TM4OVLyo es firefox" + echo " $0 K08TM4OVLyo es brave" + echo "" + echo "Perfiles disponibles de Chrome (macOS):" + ls -1 ~/Library/Application\ Support/Google/Chrome/ 2>/dev/null | grep -E "^(Default|Profile)" || echo " (no se encontraron)" + exit 1 +fi + +# Construir el argumento de browser +if [ -n "$PROFILE" ]; then + BROWSER_ARG="${BROWSER}:${PROFILE}" +else + BROWSER_ARG="${BROWSER}" +fi + +echo "🔍 Obteniendo transcript de YouTube" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " 📹 Video ID: $VIDEO_ID" +echo " 🌐 Idioma: $LANG" +echo " 🔑 Navegador: $BROWSER_ARG" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# Ejecutar yt-dlp +yt-dlp --cookies-from-browser "$BROWSER_ARG" \ + --skip-download --write-auto-sub --write-sub \ + --sub-lang "$LANG" --sub-format vtt \ + -o "%(id)s.%(ext)s" \ + "https://www.youtube.com/watch?v=$VIDEO_ID" + +EXIT_CODE=$? + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Buscar archivos VTT generados +VTT_FILES=$(ls ${VIDEO_ID}*.vtt 2>/dev/null) + +if [ -n "$VTT_FILES" ]; then + echo "✅ Éxito: Archivos VTT generados" + echo "" + for file in $VTT_FILES; do + LINES=$(wc -l < "$file") + SIZE=$(du -h "$file" | cut -f1) + echo " 📄 $file ($LINES líneas, $SIZE)" + done + + # Mostrar preview del primer archivo + FIRST_VTT=$(echo "$VTT_FILES" | head -n 1) + echo "" + echo "📝 Preview de $FIRST_VTT:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + head -n 30 "$FIRST_VTT" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Crear archivo de texto plano + TXT_FILE="${VIDEO_ID}_transcript.txt" + grep -v "WEBVTT" "$FIRST_VTT" | grep -v "^$" | grep -v "^[0-9][0-9]:" | grep -v "^Kind:" | grep -v "^Language:" > "$TXT_FILE" + echo "" + echo "💾 Texto guardado en: $TXT_FILE" + + exit 0 +else + echo "❌ Error: No se generaron archivos VTT" + echo "" + echo "💡 Posibles soluciones:" + echo " 1. Verifica que estés logueado en YouTube en $BROWSER" + echo " 2. Prueba con otro navegador: chrome, firefox, brave" + echo " 3. Si usas múltiples perfiles, especifica el perfil:" + echo " $0 $VIDEO_ID $LANG chrome Profile1" + echo " 4. Cierra el navegador antes de ejecutar este script" + echo "" + exit 1 +fi diff --git a/main.py b/main.py index e513ab1..5dee0ad 100644 --- a/main.py +++ b/main.py @@ -9,10 +9,29 @@ import glob from fastapi import FastAPI, HTTPException, UploadFile, File from typing import List, Dict +# Intentar importar youtube_transcript_api como fallback +try: + from youtube_transcript_api import YouTubeTranscriptApi + from youtube_transcript_api._errors import TranscriptsDisabled, NoTranscriptFound + YOUTUBE_TRANSCRIPT_API_AVAILABLE = True +except Exception: + # definir placeholders para evitar NameError si la librería no está instalada + YouTubeTranscriptApi = None + class TranscriptsDisabled(Exception): + pass + class NoTranscriptFound(Exception): + pass + YOUTUBE_TRANSCRIPT_API_AVAILABLE = False + +# Import CookieManager from yt_wrap to provide cookiefile paths per request +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') +# Proxy opcional para requests/yt-dlp (ej. socks5h://127.0.0.1:9050) +DEFAULT_PROXY = os.getenv('API_PROXY', '') def clean_youtube_json(raw_json: Dict) -> List[Dict]: """ @@ -134,14 +153,22 @@ def get_transcript_data(video_id: str, lang: str = "es"): return None, "video_id inválido o vacío" url = f"https://www.youtube.com/watch?v={video_id}" - # 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) + + # Use CookieManager to get a cookiefile path per request (may be None) + cookie_mgr = CookieManager() + cookiefile_path = cookie_mgr.get_cookiefile_path() + + # cookies_path: prefer the temporary cookiefile if present, otherwise fall back to env path + cookies_path = cookiefile_path or os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) + # proxy support + proxy = os.getenv('API_PROXY', DEFAULT_PROXY) or None + proxies = {'http': proxy, 'https': proxy} if proxy else None 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): + if not path or not os.path.exists(path): return cookies with open(path, 'r', encoding='utf-8', errors='ignore') as fh: for line in fh: @@ -164,7 +191,7 @@ def get_transcript_data(video_id: str, lang: str = "es"): return {} return cookies - cookies_for_requests = load_cookies_from_file(cookies_path) + cookies_for_requests = load_cookies_from_file(cookies_path) if cookies_path else {} # Intento rápido y fiable: usar yt-dlp para descargar subtítulos (auto o manual) al tmpdir try: @@ -189,8 +216,13 @@ def get_transcript_data(video_id: str, lang: str = "es"): if sub_langs: ytdlp_cmd.extend(["--sub-lang", ",".join(sub_langs)]) - if os.path.exists(cookies_path): - ytdlp_cmd.extend(["--cookies", cookies_path]) + # attach cookiefile if exists + if cookiefile_path: + ytdlp_cmd.extend(["--cookies", cookiefile_path]) + + # attach proxy if configured + if proxy: + ytdlp_cmd.extend(['--proxy', proxy]) try: result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=120) @@ -200,7 +232,7 @@ def get_transcript_data(video_id: str, lang: str = "es"): 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: + except subprocess.TimeoutExpired: pass # revisar archivos creados @@ -218,11 +250,13 @@ def get_transcript_data(video_id: str, lang: str = "es"): 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 + finally: + # cleanup any temp cookiefile created for this request + try: + cookie_mgr.cleanup() + except Exception: + pass + # ...existing code continues... # 1) Intento principal: obtener metadata con yt-dlp command = [ @@ -235,6 +269,9 @@ def get_transcript_data(video_id: str, lang: str = "es"): if os.path.exists(cookies_path): command.extend(["--cookies", cookies_path]) + # attach proxy if configured + if proxy: + command.extend(['--proxy', proxy]) try: result = subprocess.run(command, capture_output=True, text=True, timeout=60) @@ -295,17 +332,20 @@ def get_transcript_data(video_id: str, lang: str = "es"): max_retries = 3 response = None + rate_limited = False for attempt in range(max_retries): try: - response = requests.get(sub_url, headers=headers, timeout=30, cookies=cookies_for_requests) + response = requests.get(sub_url, headers=headers, timeout=30, cookies=cookies_for_requests, proxies=proxies) if response.status_code == 200: break elif response.status_code == 429: + rate_limited = True 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." + # salir del loop y usar fallback con yt-dlp más abajo + break 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: @@ -321,6 +361,7 @@ def get_transcript_data(video_id: str, lang: str = "es"): except requests.exceptions.RequestException as e: return None, f"Error de conexión al descargar subtítulos: {str(e)[:100]}" + # Si obtuvimos un 200, procesarlo; si hubo rate limiting, intentar fallback con yt-dlp if response and response.status_code == 200: subtitle_format = requested_subs[lang_key].get('ext', 'json3') try: @@ -336,12 +377,16 @@ def get_transcript_data(video_id: str, lang: str = "es"): combined = [] for idx, u in enumerate(urls): try: - r2 = requests.get(u, headers=headers, timeout=20, cookies=cookies_for_requests) + r2 = requests.get(u, headers=headers, timeout=20, cookies=cookies_for_requests, proxies=proxies) if r2.status_code == 200 and r2.text: combined.append(r2.text) continue + # Si recibimos 429, 403, o falló, intentaremos con yt-dlp (fallback) + if r2.status_code == 429: + # fallback a yt-dlp + raise Exception('rate_limited') except Exception: - # fallthrough al fallback + # fallthrough al fallback con yt-dlp pass # Intento 2 (fallback): usar yt-dlp para descargar ese timedtext/url a un archivo temporal @@ -357,6 +402,9 @@ def get_transcript_data(video_id: str, lang: str = "es"): if os.path.exists(cookies_path): ytdlp_cmd.extend(["--cookies", cookies_path]) + # pasar proxy a yt-dlp si está configurado + if proxy: + ytdlp_cmd.extend(['--proxy', proxy]) try: res2 = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=60) stderr2 = (res2.stderr or "").lower() @@ -365,8 +413,8 @@ def get_transcript_data(video_id: str, lang: str = "es"): 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 + except Exception: + pass # leer cualquier archivo creado en el tempdir for fpath in glob.glob(os.path.join(tdir, "timedtext_*.*")): @@ -398,9 +446,53 @@ def get_transcript_data(video_id: str, lang: str = "es"): return None, "Los subtítulos están vacíos o no se pudieron procesar." return formatted_transcript, None + # Si hubo rate limiting, intentar fallback con yt-dlp para descargar la URL de subtítulos + if rate_limited and (not response or response.status_code != 200): + # Intentar descargar la URL de subtítulos directamente con yt-dlp (usa cookies si existen) + try: + with tempfile.TemporaryDirectory() as tdir: + out_template = os.path.join(tdir, "sub.%(ext)s") + ytdlp_cmd = [ + "yt-dlp", + sub_url, + "-o", out_template, + "--no-warnings", + ] + if os.path.exists(cookies_path): + ytdlp_cmd.extend(["--cookies", cookies_path]) + + if proxy: + ytdlp_cmd.extend(['--proxy', proxy]) + res = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=90) + stderr = (res.stderr or "").lower() + if res.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 cookies.txt válido o intenta más tarde." + # Leer archivos generados + combined = [] + for fpath in glob.glob(os.path.join(tdir, "*.*")): + try: + with open(fpath, 'r', encoding='utf-8') as fh: + txt = fh.read() + if txt: + combined.append(txt) + 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 + except FileNotFoundError: + return None, "yt-dlp no está instalado en el contenedor/entorno. Instala yt-dlp y vuelve a intentar." + except Exception: + # seguir con otros fallbacks + pass + + # si no logró con yt-dlp, continuar y dejar que los fallbacks posteriores manejen el caso + # Fallback: intentarlo descargando subtítulos con yt-dlp a un directorio temporal - # (esto cubre casos en que la metadata no incluye requested_subtitles) + # (esto cubre casos en que la metadata no incluye requested_subs) try: with tempfile.TemporaryDirectory() as tmpdir: # Intentar con auto-sub primero, luego con sub (manual) @@ -424,10 +516,10 @@ def get_transcript_data(video_id: str, lang: str = "es"): 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 + # 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}.*")) @@ -464,74 +556,78 @@ 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}" - # 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"), - ("best", "Mejor calidad disponible"), - ("best[ext=mp4]", "Mejor calidad MP4"), - ("bestvideo+bestaudio/best", "Mejor video y audio"), - ] + # dynamically get cookiefile for this request + cookie_mgr = CookieManager() + cookiefile_path = cookie_mgr.get_cookiefile_path() - for format_spec, description in format_strategies: - # Comando optimizado para obtener la mejor URL disponible - command = [ - "yt-dlp", - "-g", # Obtener solo la URL - "-f", format_spec, - "--no-warnings", # Sin advertencias - "--no-check-certificate", # Ignorar errores de certificado - "--extractor-args", "youtube:player_client=android", # Usar cliente Android + 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"), ] - # Agregar cookies solo si el archivo existe - if os.path.exists(cookies_path): - command.extend(["--cookies", cookies_path]) + 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", + ] - command.append(url) + if cookiefile_path: + command.extend(["--cookies", cookiefile_path]) - try: - result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=60) + command.append(url) - if result.returncode == 0 and result.stdout.strip(): - # Obtener todas las URLs (puede haber video y audio separados) - urls = result.stdout.strip().split('\n') + try: + result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=60) - # 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 + if result.returncode == 0 and result.stdout.strip(): + # Obtener todas las URLs (puede haber video y audio separados) + urls = result.stdout.strip().split('\n') - # Si no encontramos ninguna específica, usar la primera URL válida - if not stream_url and urls: + # Buscar la URL m3u8 o googlevideo + stream_url = None for url_line in urls: - if url_line and url_line.strip() and url_line.startswith('http'): - stream_url = url_line.strip() - break + 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 - if stream_url: - return stream_url, None + # 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 - # Este formato falló, intentar el siguiente - continue + if stream_url: + return stream_url, None - except subprocess.TimeoutExpired: - continue - except Exception as e: - continue + continue - # Si todos los formatos fallaron - return None, "No se pudo obtener la URL del stream. Verifica que el video esté EN VIVO (🔴) y no tenga restricciones." + 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." + finally: + try: + cookie_mgr.cleanup() + except Exception: + pass @app.get("/transcript/{video_id}") def transcript_endpoint(video_id: str, lang: str = "es"): @@ -564,6 +660,7 @@ def stream_endpoint(video_id: str): Ejemplo de uso con FFmpeg: ffmpeg -re -i "URL_M3U8" -c copy -f flv rtmp://destino/stream_key """ + stream_url, error = get_stream_url(video_id) if error: @@ -609,6 +706,377 @@ async def upload_cookies(file: UploadFile = File(...)): except Exception as e: raise HTTPException(status_code=500, detail=f'Error al guardar cookies: {str(e)[:200]}') +@app.get("/debug/metadata/{video_id}") +def debug_metadata(video_id: str): + """Endpoint de depuración: obtiene --dump-json de yt-dlp para un video. + Devuelve la metadata (automatic_captions, subtitles, requested_subtitles) para inspección. + """ + # try to use dynamic cookiefile per request + 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 + + url = f"https://www.youtube.com/watch?v={video_id}" + + cmd = [ + "yt-dlp", + "--skip-download", + "--dump-json", + "--no-warnings", + "--extractor-args", "youtube:player_client=android", + 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=60) + except FileNotFoundError: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail="yt-dlp no está instalado en el contenedor/entorno.") + except subprocess.TimeoutExpired: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=504, detail="yt-dlp demoró demasiado en responder.") + except Exception as e: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail=str(e)[:300]) + + if proc.returncode != 0: + stderr = proc.stderr or '' + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail=f"yt-dlp error: {stderr[:1000]}") + + try: + metadata = json.loads(proc.stdout) + except Exception: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail="No se pudo parsear la salida JSON de yt-dlp.") + + try: + cookie_mgr.cleanup() + except Exception: + pass + + # Devolver solo las partes útiles para depuración + debug_info = { + 'id': metadata.get('id'), + 'title': metadata.get('title'), + 'uploader': metadata.get('uploader'), + 'is_live': metadata.get('is_live'), + 'automatic_captions': metadata.get('automatic_captions'), + 'subtitles': metadata.get('subtitles'), + 'requested_subtitles': metadata.get('requested_subtitles'), + 'formats_sample': metadata.get('formats')[:5] if metadata.get('formats') else None, + } + return debug_info + +@app.get('/debug/fetch_subs/{video_id}') +def debug_fetch_subs(video_id: str, lang: str = 'es'): + """Intenta descargar subtítulos con yt-dlp dentro del entorno y devuelve el log y el contenido (parcial) si existe. + Usa cookies definidas en API_COOKIES_PATH. + """ + cookie_mgr = CookieManager() + cookiefile_path = cookie_mgr.get_cookiefile_path() + out_dir = tempfile.mkdtemp(prefix='subs_') + out_template = os.path.join(out_dir, '%(id)s.%(ext)s') + url = f"https://www.youtube.com/watch?v={video_id}" + + cmd = [ + 'yt-dlp', + '--verbose', + '--skip-download', + '--write-auto-sub', + '--write-sub', + '--sub-lang', lang, + '--sub-format', 'json3/vtt/srv3/best', + '--output', out_template, + url + ] + if cookiefile_path: + cmd.extend(['--cookies', cookiefile_path]) + + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=240) + except FileNotFoundError: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail='yt-dlp no está instalado en el contenedor.') + except subprocess.TimeoutExpired: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=504, detail='La ejecución de yt-dlp demoró demasiado.') + except Exception as e: + try: + cookie_mgr.cleanup() + except Exception: + pass + raise HTTPException(status_code=500, detail=str(e)[:300]) + + stdout = proc.stdout or '' + stderr = proc.stderr or '' + rc = proc.returncode + + # Buscar archivos generados + generated = [] + for f in glob.glob(os.path.join(out_dir, f"{video_id}.*")): + size = None + try: + size = os.path.getsize(f) + # tomar las primeras 200 líneas para no retornar archivos enormes + with open(f, 'r', encoding='utf-8', errors='ignore') as fh: + sample = ''.join([next(fh) for _ in range(200)]) if size > 0 else '' + generated.append({ + 'path': f, + 'size': size, + 'sample': sample + }) + except StopIteration: + # menos de 200 líneas + try: + with open(f, 'r', encoding='utf-8', errors='ignore') as fh: + sample = fh.read() + except Exception: + sample = None + if size is None: + try: + size = os.path.getsize(f) + except Exception: + size = 0 + generated.append({'path': f, 'size': size, 'sample': sample}) + except Exception: + if size is None: + try: + size = os.path.getsize(f) + except Exception: + size = 0 + generated.append({'path': f, 'size': size, 'sample': None}) + + try: + cookie_mgr.cleanup() + except Exception: + pass + + return { + 'video_id': video_id, + 'rc': rc, + 'stdout_tail': stdout[-2000:], + 'stderr_tail': stderr[-2000:], + 'generated': generated, + 'out_dir': out_dir + } + +# Nuevo helper para descargar VTT directamente y retornarlo como texto +def fetch_vtt_subtitles(video_id: str, lang: str = 'es'): + """Descarga subtítulos en formato VTT usando yt-dlp y devuelve su contenido. + Retorna (vtt_text, None) en caso de éxito o (None, error_message) en caso de error. + """ + url = f"https://www.youtube.com/watch?v={video_id}" + + cookie_mgr = CookieManager() + cookiefile_path = cookie_mgr.get_cookiefile_path() + + with tempfile.TemporaryDirectory() as tmpdir: + out_template = os.path.join(tmpdir, '%(id)s.%(ext)s') + cmd = [ + 'yt-dlp', + '--skip-download', + '--write-auto-sub', + '--write-sub', + '--sub-lang', lang, + '--sub-format', 'vtt', + '--output', out_template, + url + ] + if cookiefile_path: + cmd.extend(['--cookies', cookiefile_path]) + + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + except FileNotFoundError: + try: + cookie_mgr.cleanup() + except Exception: + pass + return None, 'yt-dlp no está instalado en el entorno.' + except subprocess.TimeoutExpired: + try: + cookie_mgr.cleanup() + except Exception: + pass + return None, 'La descarga de subtítulos tardó demasiado.' + except Exception as e: + try: + cookie_mgr.cleanup() + except Exception: + pass + return None, f'Error ejecutando yt-dlp: {str(e)[:200]}' + + stderr = (proc.stderr or '').lower() + if proc.returncode != 0: + try: + cookie_mgr.cleanup() + except Exception: + pass + if 'http error 429' in stderr or 'too many requests' in stderr: + return None, 'YouTube está limitando las peticiones al descargar subtítulos (HTTP 429). Revisa cookies.txt o prueba desde otra IP.' + if 'http error 403' in stderr or 'forbidden' in stderr: + 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}.*")) + if not files: + try: + cookie_mgr.cleanup() + except Exception: + pass + return None, 'No se generaron archivos de subtítulos.' + + # intentar preferir .vtt + vtt_path = None + for f in files: + if f.lower().endswith('.vtt'): + vtt_path = f + break + if not vtt_path: + vtt_path = files[0] + + try: + with open(vtt_path, 'r', encoding='utf-8', errors='ignore') as fh: + content = fh.read() + try: + cookie_mgr.cleanup() + except Exception: + pass + return content, None + except Exception as e: + try: + cookie_mgr.cleanup() + except Exception: + 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]) + + return { + 'video_id': video_id, + 'vtt': vtt_text, + 'count': len(segments), + 'segments': segments, + 'text': combined_text + } + +@app.post('/upload_vtt/{video_id}') +async def upload_vtt(video_id: str, file: UploadFile = File(...)): + """Permite subir un archivo VTT para un video y devuelve segmentos parseados y texto. + Guarda el archivo en /app/data/{video_id}.vtt (sobrescribe si existe). + """ + try: + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail='Archivo vacío') + + target_dir = os.path.join(os.getcwd(), 'data') + os.makedirs(target_dir, exist_ok=True) + target_path = os.path.join(target_dir, f"{video_id}.vtt") + + with open(target_path, 'wb') as fh: + fh.write(content) + + # Leer como texto para parsear + text = content.decode('utf-8', errors='ignore') + segments = parse_subtitle_format(text, 'vtt') if text else [] + combined_text = '\n'.join([s.get('text','') for s in segments]) + + return { + 'video_id': video_id, + 'path': target_path, + 'count': len(segments), + 'segments': segments, + 'text': combined_text + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f'Error al guardar/parsear VTT: {str(e)[:200]}') + +@app.get('/transcript_alt/{video_id}') +def transcript_alt(video_id: str, lang: str = 'es'): + """Intento alternativo de obtener transcript usando youtube-transcript-api (si está disponible). + Retorna segmentos en el mismo formato que get_transcript_data para mantener consistencia. + """ + if not YOUTUBE_TRANSCRIPT_API_AVAILABLE: + raise HTTPException(status_code=501, detail='youtube-transcript-api no está instalado en el entorno.') + + vid = extract_video_id(video_id) + if not vid: + raise HTTPException(status_code=400, detail='video_id inválido') + + # preparar idiomas a probar + langs = [lang] + if len(lang) == 2: + langs.append(f"{lang}-419") + + try: + # get_transcript puede lanzar excepciones si no hay transcript + transcript_list = YouTubeTranscriptApi.get_transcript(vid, languages=langs) + except NoTranscriptFound: + raise HTTPException(status_code=404, detail='No se encontró transcript con youtube-transcript-api') + except TranscriptsDisabled: + raise HTTPException(status_code=403, detail='Los transcripts están deshabilitados para este video') + except Exception as e: + raise HTTPException(status_code=500, detail=f'Error youtube-transcript-api: {str(e)[:300]}') + + # transcript_list tiene items con keys: text, start, duration + segments = [] + for item in transcript_list: + segments.append({ + 'start': float(item.get('start', 0)), + 'duration': float(item.get('duration', 0)), + 'text': item.get('text', '').strip() + }) + + combined_text = '\n'.join([s['text'] for s in segments if s.get('text')]) + + return { + 'video_id': vid, + 'count': len(segments), + 'segments': segments, + 'text': combined_text, + 'source': 'youtube-transcript-api' + } + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt index 57a6de2..b6de90b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ requests yt-dlp python-multipart pydantic +youtube-transcript-api # Nota: streamlit y paquetes relacionados fueron removidos porque el frontend fue eliminado diff --git a/yt_wrap.py b/yt_wrap.py new file mode 100644 index 0000000..2c04b74 --- /dev/null +++ b/yt_wrap.py @@ -0,0 +1,232 @@ +"""Utility wrapper for yt-dlp calls with robust cookie handling via cookiejar. + +Provides: +- CookieManager: reads cookie string from RedisArchivist and writes a Netscape cookie file + that can be passed to yt-dlp via the --cookiefile option (path). +- YtDlpClient: base class to perform download/extract calls to yt-dlp with consistent + error handling and optional automatic cookie injection. + +Usage example: + from yt_wrap import YtDlpClient + client = YtDlpClient(config=config_dict) + info, err = client.extract_info('https://www.youtube.com/watch?v=K08TM4OVLyo') + +""" +from __future__ import annotations + +import logging +import os +import tempfile +import typing as t +from http import cookiejar + +import yt_dlp + +# Import project-specific RedisArchivist if available; otherwise provide a local stub +try: + from common.src.ta_redis import RedisArchivist +except Exception: + class RedisArchivist: + """Fallback stub for environments without the project's RedisArchivist. + + Methods mimic the interface used by CookieManager: get_message_str, set_message, + del_message, get_message_dict. These stubs are no-ops and return None/False. + """ + def __init__(self, *args, **kwargs): + pass + + def get_message_str(self, key: str): + return None + + def set_message(self, key: str, value, expire: int = None, save: bool = False): + return False + + def del_message(self, key: str): + return False + + def get_message_dict(self, key: str): + return None + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class CookieManager: + """Manage cookie string storage and provide a cookiefile path for yt-dlp. + + This writes the Netscape cookie string to a temporary file (Netscape format) + which is compatible with `yt-dlp`'s `--cookiefile` option and with + `http.cookiejar.MozillaCookieJar`. + """ + + DEFAULT_MESSAGE_KEY = "cookie" + + def __init__(self, redis_archivist: t.Optional[object] = None): + # Accept a RedisArchivist-like object for testability; otherwise try to create one + if redis_archivist is not None: + self.redis = redis_archivist + else: + if RedisArchivist is None: + self.redis = None + else: + try: + self.redis = RedisArchivist() + except Exception: + self.redis = None + self._temp_files: list[str] = [] + + def get_cookiefile_path(self, message_key: str | None = None) -> t.Optional[str]: + """Return a filesystem path to a Netscape cookie file written from the stored cookie string. + + If no cookie is available, returns None. + """ + key = message_key or self.DEFAULT_MESSAGE_KEY + cookie_str = None + if self.redis is not None and hasattr(self.redis, "get_message_str"): + try: + cookie_str = self.redis.get_message_str(key) + except Exception as exc: + logger.debug("CookieManager: error reading from redis: %s", exc) + cookie_str = None + + if not cookie_str: + # No cookie stored + return None + + # Ensure cookie string ends with newline + cookie_str = cookie_str.strip("\x00") + if not cookie_str.endswith("\n"): + cookie_str = cookie_str + "\n" + + # Write to a temp file in the system temp dir + tf = tempfile.NamedTemporaryFile(mode="w", delete=False, prefix="yt_cookies_", suffix=".txt") + tf.write(cookie_str) + tf.flush() + tf.close() + self._temp_files.append(tf.name) + + # Validate it's a Netscape cookie file by attempting to load with MozillaCookieJar + try: + jar = cookiejar.MozillaCookieJar() + jar.load(tf.name, ignore_discard=True, ignore_expires=True) + except Exception: + # It's okay if load fails; yt-dlp expects the netscape format; keep the file anyway + logger.debug("CookieManager: written cookie file but couldn't load with MozillaCookieJar") + + return tf.name + + def cleanup(self) -> None: + """Remove temporary cookie files created by get_cookiefile_path.""" + for p in getattr(self, "_temp_files", []): + try: + os.unlink(p) + except Exception: + logger.debug("CookieManager: failed to unlink temp cookie file %s", p) + self._temp_files = [] + + +class YtDlpClient: + """Base client to interact with yt-dlp. + + - `base_opts` are merged with per-call options. + - If `use_redis_cookies` is True, the client will try to fetch a cookiefile + path from Redis via `CookieManager` and inject `cookiefile` into options. + + Methods return tuples like (result, error) where result is data or True/False and + error is a string or None. + """ + + DEFAULT_OPTS: dict = { + "quiet": True, + "socket_timeout": 10, + "extractor_retries": 2, + "retries": 3, + } + + def __init__(self, base_opts: dict | None = None, use_redis_cookies: bool = True, redis_archivist: t.Optional[object] = None): + self.base_opts = dict(self.DEFAULT_OPTS) + if base_opts: + self.base_opts.update(base_opts) + self.use_redis_cookies = use_redis_cookies + self.cookie_mgr = CookieManager(redis_archivist) + + def _build_opts(self, extra: dict | None = None) -> dict: + opts = dict(self.base_opts) + if extra: + opts.update(extra) + + # If cookie management is enabled, attempt to attach a cookiefile path + if self.use_redis_cookies: + cookiefile = self.cookie_mgr.get_cookiefile_path() + if cookiefile: + opts["cookiefile"] = cookiefile + return opts + + def extract_info(self, url: str, extra_opts: dict | None = None) -> tuple[dict | None, str | None]: + """Extract info for a url using yt-dlp.extract_info. + + Returns (info_dict, error_str). If successful, error_str is None. + """ + opts = self._build_opts(extra_opts) + try: + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=False) + except cookiejar.LoadError as exc: + logger.error("Cookie load error: %s", exc) + return None, f"cookie_load_error: {exc}" + except yt_dlp.utils.ExtractorError as exc: + logger.warning("ExtractorError for %s: %s", url, exc) + return None, str(exc) + except yt_dlp.utils.DownloadError as exc: + msg = str(exc) + logger.warning("DownloadError for %s: %s", url, msg) + if "Temporary failure in name resolution" in msg: + raise ConnectionError("lost the internet, abort!") from exc + # Detect rate limiting + if "HTTP Error 429" in msg or "too many requests" in msg.lower(): + return None, "HTTP 429: rate limited" + return None, msg + except Exception as exc: # pragma: no cover - defensive + logger.exception("Unexpected error in extract_info: %s", exc) + return None, str(exc) + finally: + # Clean up temp cookie files after the call + try: + self.cookie_mgr.cleanup() + except Exception: + pass + + return info, None + + def download(self, url: str, extra_opts: dict | None = None) -> tuple[bool, str | None]: + """Invoke ydl.download for the provided url. Returns (success, error_message). + """ + opts = self._build_opts(extra_opts) + try: + with yt_dlp.YoutubeDL(opts) as ydl: + ydl.download([url]) + except yt_dlp.utils.DownloadError as exc: + msg = str(exc) + logger.warning("DownloadError while downloading %s: %s", url, msg) + if "Temporary failure in name resolution" in msg: + raise ConnectionError("lost the internet, abort!") from exc + if "HTTP Error 429" in msg or "too many requests" in msg.lower(): + return False, "HTTP 429: rate limited" + return False, msg + except Exception as exc: + logger.exception("Unexpected error during download: %s", exc) + return False, str(exc) + finally: + try: + self.cookie_mgr.cleanup() + except Exception: + pass + + return True, None + + +# If running as a script, show a tiny demo (no network calls are performed here) +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + client = YtDlpClient() + print(client._build_opts())