Compare commits

...

2 Commits

13 changed files with 943 additions and 483 deletions

View File

@ -3,24 +3,43 @@ FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
curl \ curl \
ca-certificates \ 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 WORKDIR /app
# Copiar requirements y instalar dependencias # Copiar requirements y instalar dependencias Python
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt \ RUN pip install --no-cache-dir -r /app/requirements.txt
&& pip install --no-cache-dir yt-dlp
# 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 # Copiar el resto del código
COPY . /app 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 EXPOSE 8000
# Comando por defecto para ejecutar la API # Comando por defecto para ejecutar la API

View File

@ -27,7 +27,7 @@ chmod +x docker-start.sh
# 3. Abrir en navegador # 3. Abrir en navegador
# Panel Web: http://localhost:8501 # 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. 📚 Ver [DOCKER_README.md](DOCKER_README.md) para más información.
@ -48,8 +48,8 @@ chmod +x docker-start.sh
Esto iniciará: Esto iniciará:
- **Panel Web Streamlit**: http://localhost:8501 - **Panel Web Streamlit**: http://localhost:8501
- **API FastAPI**: http://localhost:8080 - **API FastAPI**: http://localhost:8282
- **Documentación API**: http://localhost:8080/docs - **Documentación API**: http://localhost:8282/docs
📚 Documentación completa: [DOCKER_GUIDE.md](DOCKER_GUIDE.md) 📚 Documentación completa: [DOCKER_GUIDE.md](DOCKER_GUIDE.md)
@ -214,18 +214,25 @@ docker-compose down
Esto iniciará: Esto iniciará:
- **Panel Streamlit**: http://localhost:8501 (Frontend) - **Panel Streamlit**: http://localhost:8501 (Frontend)
- **API FastAPI**: http://localhost:8080 (Backend) - **API FastAPI**: http://localhost:8282 (Backend)
- **Docs API**: http://localhost:8080/docs (Swagger UI) - **Docs API**: http://localhost:8282/docs (Swagger UI)
### Características Docker ### Volumen de configuración: `./data`
- ✅ Health checks automáticos A partir de la configuración actual, el proyecto monta una única carpeta local `./data` dentro del contenedor en `/app/data`.
- ✅ Auto-restart si falla Coloca ahí los archivos de configuración y persistencia (por ejemplo: `cookies.txt`, `stream_config.json`, `streams_state.json`).
- ✅ Red compartida entre servicios
- ✅ Volúmenes persistentes para configuración
- ✅ FFmpeg incluido en la imagen
📚 **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 ## 📁 Estructura del Proyecto
@ -235,15 +242,15 @@ TubeScript-API/
├── streamlit_app.py # Panel web de control ├── streamlit_app.py # Panel web de control
├── requirements.txt # Dependencias Python ├── requirements.txt # Dependencias Python
├── Dockerfile # Imagen Docker optimizada ├── 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-start.sh # Script de inicio automático
├── docker-stop.sh # Script para detener ├── docker-stop.sh # Script para detener
├── docker-logs.sh # Script para ver logs ├── docker-logs.sh # Script para ver logs
├── Dockerfile # Configuración Docker ├── data/ # Carpeta montada en el contenedor (/app/data) para configuración persistente
├── docker-compose.yml # Orquestación de servicios │ ├── stream_config.json # Configuración de plataformas (generado/gestionado aquí)
├── stream_config.json # Configuración de plataformas (generado) │ ├── streams_state.json # Estado de transmisiones (generado/gestionado aquí)
├── streams_state.json # Estado de transmisiones (generado) │ └── cookies.txt # Cookies de YouTube (opcional — poner aquí o subir vía endpoint)
└── cookies.txt # Cookies de YouTube (opcional) └── README.md # Documentación
``` ```
## 🔧 Configuración Avanzada ## 🔧 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 1. Instala la extensión "Get cookies.txt" en tu navegador
2. Visita youtube.com e inicia sesión 2. Visita youtube.com e inicia sesión
3. Exporta las cookies como `cookies.txt` 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 ### Personalizar Calidad de Video
@ -289,7 +312,7 @@ command = [
### Error: "No se pudo obtener la URL del stream" ### Error: "No se pudo obtener la URL del stream"
- Verifica que el video esté realmente en vivo - 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 - Verifica tu conexión a internet
### Error: "Transmisión con estado error" ### Error: "Transmisión con estado error"

View File

@ -7,14 +7,14 @@ services:
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
container_name: tubescript-api container_name: tubescript-api
image: tubescript-api:local image: tubescript-api:local
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./cookies.txt:/app/cookies.txt - ./data:/app/data:rw
- ./stream_config.json:/app/stream_config.json:ro
- ./streams_state.json:/app/streams_state.json:rw
environment: environment:
API_BASE_URL: http://localhost:8000 API_BASE_URL: http://localhost:8000
API_COOKIES_PATH: /app/data/cookies.txt
TZ: UTC TZ: UTC
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@ -34,8 +34,7 @@ services:
ports: ports:
- "8501:8501" - "8501:8501"
volumes: volumes:
- ./stream_config.json:/app/stream_config.json:ro - ./data:/app/data:ro
- ./cookies.txt:/app/cookies.txt:ro
environment: environment:
API_BASE_URL: http://localhost:8000 API_BASE_URL: http://localhost:8000
TZ: UTC TZ: UTC

View File

@ -4,20 +4,20 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.api 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 container_name: tubescript_api
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
ports: ports:
- "8000:8000" - "8282:8000"
volumes: volumes:
- ./:/app:rw - ./data:/app/data: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
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- API_COOKIES_PATH=/app/cookies.txt - API_COOKIES_PATH=/app/data/cookies.txt
# Optional: set API_PROXY when you want the container to use a SOCKS/HTTP proxy (e.g. tor) # Optional: set API_PROXY when you want the container to use a SOCKS/HTTP proxy
- API_PROXY=${API_PROXY:-} - API_PROXY=${API_PROXY:-}
restart: unless-stopped restart: unless-stopped
networks: networks:
@ -28,7 +28,7 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
networks: networks:
tubescript-network: tubescript-network:
name: tubescript-network name: tubescript-network
driver: bridge

View File

@ -42,9 +42,21 @@ fi
print_success "Docker encontrado" print_success "Docker encontrado"
echo "" 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 # Detener contenedores
echo "🛑 Deteniendo contenedores existentes..." echo "🛑 Deteniendo contenedores existentes..."
docker-compose down 2>/dev/null || true docker compose down 2>/dev/null || true
print_success "Contenedores detenidos" print_success "Contenedores detenidos"
echo "" echo ""
@ -53,22 +65,24 @@ echo "🧹 ¿Deseas eliminar las imágenes antiguas? (s/N)"
read -p "> " clean_images read -p "> " clean_images
if [ "$clean_images" = "s" ] || [ "$clean_images" = "S" ]; then if [ "$clean_images" = "s" ] || [ "$clean_images" = "S" ]; then
echo "Eliminando imágenes antiguas..." 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" print_success "Imágenes antiguas eliminadas"
fi fi
echo "" echo ""
# Reconstruir sin cache # Reconstruir con CACHEBUST para invalidar solo la capa COPY . /app
echo "🔨 Reconstruyendo imágenes sin cache..." # CACHEBUST=$(date +%s) se exporta para que docker-compose.yml lo tome via ${CACHEBUST:-1}
echo "Esto puede tardar varios minutos..." echo "🔨 Reconstruyendo imagen con código actualizado..."
echo "Usando CACHEBUST=$(date +%s) para forzar copia fresca del código..."
echo "" echo ""
docker-compose build --no-cache export CACHEBUST="$(date +%s)"
docker compose build
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
print_success "Imágenes reconstruidas exitosamente" print_success "Imagen reconstruida exitosamente"
else else
print_error "Error al reconstruir imágenes" print_error "Error al reconstruir imagen"
exit 1 exit 1
fi fi
echo "" echo ""
@ -79,22 +93,22 @@ read -p "> " start_services
if [ "$start_services" != "n" ] && [ "$start_services" != "N" ]; then if [ "$start_services" != "n" ] && [ "$start_services" != "N" ]; then
echo "" echo ""
echo "🚀 Iniciando servicios..." echo "🚀 Iniciando servicios..."
docker-compose up -d docker compose up -d
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
print_success "Servicios iniciados" print_success "Servicios iniciados"
echo "" echo ""
echo "📊 Estado de los servicios:" echo "📊 Estado de los servicios:"
sleep 3 sleep 3
docker-compose ps docker compose ps
echo "" echo ""
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"
print_success "¡Rebuild completado!" print_success "¡Rebuild completado!"
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"
echo "" echo ""
echo "🌐 Servicios disponibles:" echo "🌐 Servicios disponibles:"
echo " Panel Web: http://localhost:8501" echo " API: http://localhost:8282"
echo " API: http://localhost:8080" echo " Docs API: http://localhost:8282/docs"
echo "" echo ""
else else
print_error "Error al iniciar servicios" print_error "Error al iniciar servicios"
@ -105,7 +119,7 @@ else
print_success "Rebuild completado (servicios no iniciados)" print_success "Rebuild completado (servicios no iniciados)"
echo "" echo ""
echo "Para iniciar los servicios:" echo "Para iniciar los servicios:"
echo " docker-compose up -d" echo " CACHEBUST=\$(date +%s) docker compose up -d --build"
fi fi
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"

View File

@ -21,11 +21,9 @@ docker run -d \
--name tubescript_api \ --name tubescript_api \
--network tubescript-network \ --network tubescript-network \
-p 8080:8000 \ -p 8080:8000 \
-v "$(pwd)/cookies.txt:/app/cookies.txt:ro" \ -v "$(pwd)/data:/app/data:rw" \
-v "$(pwd)/stream_config.json:/app/stream_config.json" \ -e API_COOKIES_PATH=/app/data/cookies.txt \
-v "$(pwd)/streams_state.json:/app/streams_state.json" \
-v "$(pwd)/process_state.json:/app/process_state.json" \ -v "$(pwd)/process_state.json:/app/process_state.json" \
-v "$(pwd)/data:/app/data" \
-e PYTHONUNBUFFERED=1 \ -e PYTHONUNBUFFERED=1 \
tubescript-api \ tubescript-api \
uvicorn main:app --host 0.0.0.0 --port 8000 --reload uvicorn main:app --host 0.0.0.0 --port 8000 --reload

View File

@ -30,11 +30,8 @@ docker run -d \
--name streamlit_panel \ --name streamlit_panel \
--network tubescript-network \ --network tubescript-network \
-p 8501:8501 \ -p 8501:8501 \
-v "$(pwd)/cookies.txt:/app/cookies.txt:ro" \ -v "$(pwd)/data:/app/data:ro" \
-v "$(pwd)/stream_config.json:/app/stream_config.json" \ -e API_COOKIES_PATH=/app/data/cookies.txt \
-v "$(pwd)/streams_state.json:/app/streams_state.json" \
-v "$(pwd)/process_state.json:/app/process_state.json" \
-v "$(pwd)/data:/app/data" \
-e PYTHONUNBUFFERED=1 \ -e PYTHONUNBUFFERED=1 \
-e API_URL="$API_URL" \ -e API_URL="$API_URL" \
tubescript-api \ tubescript-api \

View File

@ -1,180 +1,79 @@
#!/bin/bash #!/bin/bash
# Script para iniciar TubeScript-API con docker compose
# Script para iniciar el stack completo de TubeScript con Docker
set -e 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 "════════════════════════════════════════════════════════════"
echo " 🐳 TubeScript-API - Inicio con Docker" echo " 🐳 TubeScript-API — docker compose up"
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"
echo "" echo ""
# Colores para output # Verificar Docker
GREEN='\033[0;32m' if ! command -v docker &>/dev/null; then
YELLOW='\033[1;33m' print_error "Docker no está instalado"; exit 1
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
fi fi
print_success "Docker encontrado: $(docker --version)"
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
echo "" echo ""
echo "🌐 Configuración de API URL..."
# Verificar si existe archivo .env # Crear carpeta data con permisos correctos (necesaria para cookies.txt y otros)
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
if [ ! -d "data" ]; then if [ ! -d "data" ]; then
mkdir -p data mkdir -p data && chmod 755 data
print_success "Creado directorio data/" print_success "Creado directorio ./data"
else
print_success "Directorio ./data ya existe"
fi 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 "" echo ""
# Detener contenedores existentes
echo "🛑 Deteniendo 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 ""
echo "🔨 Construyendo imágenes Docker..." docker compose up -d --build
docker-compose build
# Iniciar los servicios if [ $? -eq 0 ]; then
echo "" echo ""
echo "🚀 Iniciando servicios..." echo "⏳ Esperando arranque de uvicorn..."
docker-compose up -d sleep 8
echo ""
# Esperar a que los servicios estén listos echo "📊 Estado:"
echo "" docker compose ps
echo "⏳ Esperando que los servicios inicien..." echo ""
sleep 5 echo "📋 Logs recientes:"
docker compose logs --tail=6
# Verificar estado de los servicios echo ""
echo "" echo "════════════════════════════════════════════════════════════"
echo "📊 Estado de los servicios:" print_success "¡Listo!"
docker-compose ps echo "════════════════════════════════════════════════════════════"
echo ""
# Mostrar logs iniciales echo " 🌐 API: http://localhost:8282"
echo "" echo " 📖 Docs: http://localhost:8282/docs"
echo "📋 Logs recientes:" echo " 🍪 Subir cookies: curl -X POST http://localhost:8282/upload_cookies -F 'file=@cookies.txt'"
docker-compose logs --tail=10 echo ""
echo " 📝 Comandos útiles:"
echo "" echo " Logs en vivo: docker compose logs -f tubescript-api"
echo "════════════════════════════════════════════════════════════" echo " Detener: docker compose down"
print_success "¡Servicios iniciados correctamente!" echo " Rebuild: CACHEBUST=\$(date +%s) docker compose up -d --build"
echo "════════════════════════════════════════════════════════════" echo ""
echo "" else
echo "📡 Servicios disponibles:" print_error "Error al iniciar servicios"
echo "" docker compose logs --tail=20
echo " 🌐 Panel Web Streamlit:" exit 1
echo " http://localhost:8501" fi
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 "════════════════════════════════════════════════════════════"

View File

@ -17,7 +17,7 @@ import os
import subprocess import subprocess
import tempfile import tempfile
import glob 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"): def fetch_with_browser_cookies(video_id, lang="es", browser="chrome"):
"""Intenta obtener transcript usando cookies desde el navegador directamente.""" """Intenta obtener transcript usando cookies desde el navegador directamente."""
@ -78,18 +78,15 @@ def main():
print(f" Idioma: {lang}") print(f" Idioma: {lang}")
if browser: 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) segments, error = fetch_with_browser_cookies(video_id, lang, browser)
else: else:
print(f" Método: API del proyecto") print(" Método: API del proyecto")
print(f" Cookies: {os.getenv('API_COOKIES_PATH', './cookies.txt')}") print(" Cookies: {}".format(os.getenv('API_COOKIES_PATH', './data/cookies.txt')))
from main import get_transcript_data
segments, error = get_transcript_data(video_id, lang) segments, error = get_transcript_data(video_id, lang)
print("") print("")
# Intentar obtener transcript
segments, error = get_transcript_data(video_id, lang)
if error: if error:
print(f"❌ ERROR: {error}") print(f"❌ ERROR: {error}")

124
fix-and-restart.sh Executable file
View File

@ -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 "======================================================"

640
main.py
View File

@ -29,7 +29,8 @@ from yt_wrap import CookieManager
app = FastAPI(title="TubeScript API Pro - JSON Cleaner") app = FastAPI(title="TubeScript API Pro - JSON Cleaner")
# Ruta de cookies configurable vía variable de entorno: API_COOKIES_PATH # 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) # Proxy opcional para requests/yt-dlp (ej. socks5h://127.0.0.1:9050)
DEFAULT_PROXY = os.getenv('API_PROXY', '') DEFAULT_PROXY = os.getenv('API_PROXY', '')
@ -162,8 +163,8 @@ def format_segments_text(segments: List[Dict]) -> List[str]:
s = str(t).strip() s = str(t).strip()
s = re.sub(r'^\s*Kind\s*:\s*.*$', '', s, flags=re.IGNORECASE).strip() s = re.sub(r'^\s*Kind\s*:\s*.*$', '', s, flags=re.IGNORECASE).strip()
# eliminar contenido entre corchetes (no-greedy) # 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 = re.sub(r'[♪★■◆►▶◀•–—]', '', s)
s = re.sub(r'\s+', ' ', s).strip() s = re.sub(r'\s+', ' ', s).strip()
@ -180,78 +181,55 @@ def format_segments_text(segments: List[Dict]) -> List[str]:
return output return output
# Nuevo helper: obtener thumbnails para un video (intenta yt-dlp --dump-json, fallback a URLs estándar) NODE_PATH = "/usr/bin/node"
def get_video_thumbnails(video_id: str) -> List[str]:
"""Devuelve una lista de URLs de thumbnail para el video. def _yt_client_args(has_cookies: bool, for_stream: bool = False) -> list:
Primero intenta obtener metadata con yt-dlp y extraer 'thumbnails' o 'thumbnail'. """Devuelve --extractor-args y --js-runtimes para metadata/streams.
Si falla, construye una lista de URLs por defecto (maxresdefault, sddefault, hqdefault, mqdefault, default).
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] = [] if for_stream or not has_cookies:
url = f"https://www.youtube.com/watch?v={video_id}" return ["--extractor-args", "youtube:player_client=android"]
else:
cookie_mgr = CookieManager() return [
cookiefile_path = cookie_mgr.get_cookiefile_path() "--extractor-args", "youtube:player_client=web",
cookies_path = cookiefile_path or os.getenv('API_COOKIES_PATH', DEFAULT_COOKIES_PATH) "--js-runtimes", f"node:{NODE_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",
] ]
# 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"): def get_transcript_data(video_id: str, lang: str = "es"):
video_id = extract_video_id(video_id) 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 # Intento rápido y fiable: usar yt-dlp para descargar subtítulos (auto o manual) al tmpdir
try: try:
with tempfile.TemporaryDirectory() as tmpdl: 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] sub_langs = [lang]
if len(lang) == 2: if lang == "en":
sub_langs.append(f"{lang}-419") 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 = [ ytdlp_cmd = [
"yt-dlp", "yt-dlp",
url, url,
@ -316,15 +301,9 @@ def get_transcript_data(video_id: str, lang: str = "es"):
"--sub-format", "vtt/json3/srv3/best", "--sub-format", "vtt/json3/srv3/best",
"-o", os.path.join(tmpdl, "%(id)s.%(ext)s"), "-o", os.path.join(tmpdl, "%(id)s.%(ext)s"),
"--no-warnings", "--no-warnings",
] "--sub-lang", ",".join(sub_langs),
] + _yt_subs_args(False)
# agregar sub-lang si hay variantes # NO se pasan cookies con android (android no las soporta en yt-dlp)
if sub_langs:
ytdlp_cmd.extend(["--sub-lang", ",".join(sub_langs)])
# attach cookiefile if exists
if cookiefile_path:
ytdlp_cmd.extend(["--cookies", cookiefile_path])
# attach proxy if configured # attach proxy if configured
if proxy: if proxy:
@ -332,8 +311,14 @@ def get_transcript_data(video_id: str, lang: str = "es"):
try: try:
result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=120) 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 # Si yt-dlp falló por rate limiting, devolver mensaje claro
stderr = (result.stderr or "").lower() 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): 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." 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): 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: except subprocess.TimeoutExpired:
pass pass
# revisar archivos creados # revisar archivos creados — yt-dlp genera nombres con doble extensión: ID.lang.vtt
files = glob.glob(os.path.join(tmpdl, f"{video_id}.*")) # 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: if files:
combined = [] combined = []
seen_content = set()
for fpath in files: for fpath in files:
try: try:
with open(fpath, 'r', encoding='utf-8') as fh: 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: except Exception:
continue continue
if combined: if combined:
vtt_combined = "\n".join(combined) vtt_combined = "\n".join(combined)
parsed = parse_subtitle_format(vtt_combined, 'vtt') 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: if parsed:
return parsed, get_video_thumbnails(video_id), None return parsed, get_video_thumbnails(video_id), None
finally: finally:
@ -365,17 +364,16 @@ def get_transcript_data(video_id: str, lang: str = "es"):
# ...existing code continues... # ...existing code continues...
# 1) Intento principal: obtener metadata con yt-dlp # 1) Intento principal: obtener metadata con yt-dlp
_has_ck = os.path.exists(cookies_path)
command = [ command = [
"yt-dlp", "yt-dlp",
"--skip-download", "--skip-download",
"--dump-json", "--dump-json",
"--no-warnings", "--no-warnings",
url ] + _yt_client_args(_has_ck) + [url]
]
if os.path.exists(cookies_path): if _has_ck:
command.extend(["--cookies", cookies_path]) command.extend(["--cookies", cookies_path])
# attach proxy if configured
if proxy: if proxy:
command.extend(['--proxy', proxy]) command.extend(['--proxy', proxy])
@ -538,13 +536,7 @@ def get_transcript_data(video_id: str, lang: str = "es"):
vtt_combined = "\n".join(combined) vtt_combined = "\n".join(combined)
formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt') formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt')
if formatted_transcript: if formatted_transcript:
return formatted_transcript, get_video_thumbnails(video_id), None return formatted_transcript, get_video_thumbnails(video_id)
try:
subtitle_data = response.json()
formatted_transcript = parse_subtitle_format(subtitle_data, subtitle_format)
except json.JSONDecodeError:
formatted_transcript = parse_subtitle_format(response.text, subtitle_format)
except Exception as e: except Exception as e:
return None, get_video_thumbnails(video_id), f"Error al procesar los subtítulos: {str(e)[:200]}" 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) vtt_combined = "\n".join(combined)
formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt') formatted_transcript = parse_subtitle_format(vtt_combined, 'vtt')
if formatted_transcript: if formatted_transcript:
return formatted_transcript, get_video_thumbnails(video_id), None return formatted_transcript, get_video_thumbnails(video_id)
except FileNotFoundError: 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." 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: except Exception:
@ -618,17 +610,18 @@ def get_transcript_data(video_id: str, lang: str = "es"):
"--sub-format", "json3/vtt/srv3/best", "--sub-format", "json3/vtt/srv3/best",
"-o", os.path.join(tmpdir, "%(id)s.%(ext)s"), "-o", os.path.join(tmpdir, "%(id)s.%(ext)s"),
"--no-warnings", "--no-warnings",
] ] + _yt_subs_args(False)
if os.path.exists(cookies_path): # NO cookies con android (android no las soporta, yt-dlp lo saltaría)
cmd.extend(["--cookies", cookies_path])
# añadir proxy a la llamada de yt-dlp si está configurado # añadir proxy a la llamada de yt-dlp si está configurado
if proxy: if proxy:
cmd.extend(['--proxy', proxy]) cmd.extend(['--proxy', proxy])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
# Revisar si se creó algún archivo en tmpdir # 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 = 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: if files:
# Tomar el primero válido # Tomar el primero válido
downloaded = files[0] downloaded = files[0]
@ -655,90 +648,288 @@ def get_transcript_data(video_id: str, lang: str = "es"):
# No hacer crash, retornar mensaje general # 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), 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): 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 Obtiene la URL de transmisión m3u8/HLS.
""" Devuelve: (stream_url, title, description, is_live, error)
url = f"https://www.youtube.com/watch?v={video_id}"
# 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() cookie_mgr = CookieManager()
cookiefile_path = cookie_mgr.get_cookiefile_path() 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: try:
# Lista de formatos a intentar en orden de prioridad for client in clients:
format_strategies = [ for fmt in fmts:
("best[ext=m3u8]", "Mejor calidad m3u8"), u, is_b = _ytdlp_url(fmt, client)
("best", "Mejor calidad disponible"), if u:
("best[ext=mp4]", "Mejor calidad MP4"), return u, title, description, is_live, None
("bestvideo+bestaudio/best", "Mejor video y audio"), if is_b:
] got_bot = True
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."
finally: finally:
try: try:
cookie_mgr.cleanup() cookie_mgr.cleanup()
except Exception: except Exception:
pass 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}") @app.get("/transcript/{video_id}")
def transcript_endpoint(video_id: str, lang: str = "es"): def transcript_endpoint(video_id: str, lang: str = "es"):
data, thumbnails, error = get_transcript_data(video_id, lang) 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: if error:
raise HTTPException(status_code=400, detail=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}") @app.get("/stream/{video_id}")
def stream_endpoint(video_id: str): 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 - Para lives en vivo (🔴): devuelve URL HLS directa usable con FFmpeg/VLC.
a redes sociales usando RTMP. - Para videos normales: devuelve la mejor URL de video disponible.
Ejemplo de uso con FFmpeg: Ejemplo FFmpeg:
ffmpeg -re -i "URL_M3U8" -c copy -f flv rtmp://destino/stream_key ffmpeg -re -i "URL_M3U8" -c copy -f flv rtmp://destino/stream_key
""" """
stream_url, title, description, is_live, error = get_stream_url(video_id)
stream_url, error = get_stream_url(video_id)
if error: if error:
raise HTTPException(status_code=400, detail=error) raise HTTPException(status_code=400, detail=error)
thumbnails = get_video_thumbnails(video_id) thumbnails = get_video_thumbnails(video_id)
url_type = "m3u8/hls" if stream_url and "m3u8" in stream_url.lower() else "direct/mp4"
# 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
return { return {
"video_id": video_id, "video_id": video_id,
"title": title, "title": title,
"description": description, "description": description,
"is_live": is_live,
"stream_url": stream_url, "stream_url": stream_url,
"url_type": url_type, "url_type": url_type,
"youtube_url": f"https://www.youtube.com/watch?v={video_id}", "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() content = await file.read()
if not content: if not content:
raise HTTPException(status_code=400, detail='Archivo vacío') 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 # Guardar con permisos de escritura
with open(target, 'wb') as fh: with open(target, 'wb') as fh:
fh.write(content) fh.write(content)
return {"detail": "cookies.txt guardado correctamente", "path": os.path.abspath(target)} return {"detail": "cookies.txt guardado correctamente", "path": os.path.abspath(target)}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f'Error al guardar cookies: {str(e)[:200]}') 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", "--skip-download",
"--dump-json", "--dump-json",
"--no-warnings", "--no-warnings",
"--extractor-args", "youtube:player_client=android",
url url
] ] + _yt_client_args(os.path.exists(cookies_path))
if os.path.exists(cookies_path): if os.path.exists(cookies_path):
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
if proxy: if proxy:
@ -991,7 +1150,7 @@ def debug_fetch_subs(video_id: str, lang: str = 'es'):
'--sub-format', 'json3/vtt/srv3/best', '--sub-format', 'json3/vtt/srv3/best',
'--output', out_template, '--output', out_template,
url url
] ] + _yt_subs_args(bool(cookiefile_path))
if cookiefile_path: if cookiefile_path:
cmd.extend(['--cookies', 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 '' stderr = proc.stderr or ''
rc = proc.returncode rc = proc.returncode
# Buscar archivos generados # Buscar archivos generados (yt-dlp usa doble extensión: ID.lang.vtt)
generated = [] 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 size = None
try: try:
size = os.path.getsize(f) size = os.path.getsize(f)
@ -1090,7 +1249,7 @@ def fetch_vtt_subtitles(video_id: str, lang: str = 'es'):
'--sub-format', 'vtt', '--sub-format', 'vtt',
'--output', out_template, '--output', out_template,
url url
] ] + _yt_subs_args(bool(cookiefile_path))
if cookiefile_path: if cookiefile_path:
cmd.extend(['--cookies', 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, 'Acceso denegado al descargar subtítulos (HTTP 403). Usa cookies.txt con una cuenta autorizada.'
return None, f'yt-dlp error: {proc.stderr[:1000]}' 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: if not files:
try: try:
cookie_mgr.cleanup() cookie_mgr.cleanup()
@ -1159,32 +1321,6 @@ def fetch_vtt_subtitles(video_id: str, lang: str = 'es'):
pass pass
return None, f'Error leyendo archivo de subtítulos: {str(e)[:200]}' 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}') @app.post('/upload_vtt/{video_id}')
async def upload_vtt(video_id: str, file: UploadFile = File(...)): async def upload_vtt(video_id: str, file: UploadFile = File(...)):

138
run-test.sh Normal file
View File

@ -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 ==="

116
test-completo.sh Normal file
View File

@ -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 "======================================================"