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