Genera subtitulos en espanol (o el idioma que elijas) para videos en ingles
de cualquier sitio soportado por yt-dlp,
usando faster-whisper para
transcribir y Google Translate para traducir. Dos modos: solo el archivo
.srt, o el video completo con los subtitulos quemados.
Esta es la version migrada de un monolito Flask + threads a una arquitectura distribuida: FastAPI + Celery + Redis, containerizada, pensada para correr en varias VPS de Hetzner (ver DEPLOYMENT.md para el reparto recomendado), o en una sola maquina para desarrollo/demo.
+--------------+ +-------------+ +--------------+ +--------------+
| Frontend |---->| FastAPI |---->| Redis |<--->| Celery |
| (estatico, |<----| (API) |<----| (broker + | | Workers |
| servido por | WS | | | backend) | | (Whisper + |
| la propia | | | | | | FFmpeg) |
| API) | +------+------+ +--------------+ +------+-------+
+--------------+ | |
VPS 1 | +--------------+ |
+----------->| S3-compat. |<-----------+
| storage |
| (MinIO/S3) |
+--------------+
VPS 1 VPS 1 VPS 2 VPS 3 y 4
(frontend) (api) (redis) (worker-1, worker-2)
El worker sube el resultado (.srt y, en modo video, el .mp4) a un bucket
S3-compatible; la API nunca toca el filesystem del worker, solo genera URLs
pre-firmadas contra ese mismo bucket. Ver Almacenamiento
para el porque.
- Levantar en local
- Variables de entorno
- Cookies de YouTube
- Despliegue distribuido
- Quemado distribuido por segmentos (diseño, pendiente de datos de producción)
- Correr los tests
- Decisiones de arquitectura
- Seguridad
- Checklist de verificacion manual
Requisitos: Docker y Docker Compose v2.
cp .env.example .env
docker compose up --buildEsto levanta 5 contenedores: redis, minio (stand-in local del
almacenamiento S3), api, worker-1 (con Celery Beat embebido) y
worker-2. La app queda en http://localhost:8000. La consola web de
MinIO queda en http://localhost:9001 (usuario/clave: subgen /
subgen12345, configurables en .env).
Primera vez: la descarga del modelo Whisper small puede tardar unos
minutos (se cachea en la imagen del worker en el primer uso, no en cada
arranque de contenedor gracias al volumen implicito de Docker).
Para bajar todo: docker compose down -v (el -v borra los volumenes de
Redis y MinIO -- sacalo si queres conservar los datos entre corridas).
Todas con prefijo SUBGEN_. Ver .env.example para los defaults completos.
| Variable | Servicio(s) | Descripcion |
|---|---|---|
REDIS_URL |
api, worker | Broker + result backend de Celery |
CELERY_TASK_TIME_LIMIT / _SOFT_TIME_LIMIT |
worker | Limite duro/blando por tarea (segundos) |
CELERY_MAX_RETRIES / _RETRY_BACKOFF / _RETRY_BACKOFF_MAX |
worker | Politica de reintentos para fallos transitorios |
CELERY_TASK_ALWAYS_EAGER |
api | Solo para tests de integracion |
WHISPER_MODEL |
worker | Modelo de faster-whisper (small por default, igual al original) |
WHISPER_DEVICE / WHISPER_COMPUTE_TYPE |
worker | cpu/int8 por default -- ver nota abajo |
WHISPER_BEAM_SIZE, WHISPER_VAD_* |
worker | Parametros de transcripcion (iguales al original) |
TRANSLATE_BATCH_SIZE |
worker | Tamano de lote de traduccion (25, igual al original) |
MAX_VIDEO_DURATION_SECONDS |
api, worker | Limite de duracion aceptada (0 = sin limite) |
BURN_*, FFMPEG_* |
worker | Estilo de subtitulos quemados y flags de FFmpeg -- no tocar, es el estilo validado |
S3_ENDPOINT_URL |
api, worker | Endpoint interno del bucket S3-compatible |
S3_PUBLIC_ENDPOINT_URL |
api | Endpoint que ve el navegador del usuario (para presigned URLs) |
S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET / S3_REGION / S3_USE_SSL |
api, worker | Credenciales y bucket |
S3_PRESIGNED_URL_EXPIRY_SECONDS |
api | Vigencia de los links de descarga |
CLEANUP_MAX_AGE_HOURS / CLEANUP_INTERVAL_SECONDS |
worker | Limpieza automatica (Celery Beat) |
CORS_ORIGINS |
api | Lista separada por comas. Nunca * en produccion |
RATE_LIMIT_CREATE_JOB |
api | Sintaxis N/minute, N/hour (slowapi) |
MAX_URL_LENGTH |
api | Validacion de la URL de entrada |
SERVE_FRONTEND / FRONTEND_DIR |
api | La API sirve el frontend estatico (igual que el Flask original) |
LOG_LEVEL |
api, worker | Nivel de logging (JSON estructurado) |
YTDLP_COOKIES_FILE |
worker | Ruta DENTRO del contenedor al cookies.txt (fija, no tocar) |
Nota sobre
WHISPER_DEVICE/WHISPER_COMPUTE_TYPE: los defaults (cpu/int8) asumen VPS de Hetzner sin GPU, que es lo tipico en sus planes estandar. Si desplegas en una VPS con GPU, cambia acuday uncompute_typecomofloat16.
YouTube viene bloqueando cada vez mas la descarga sin sesion con un error
tipo "Sign in to confirm you're not a bot" -- le pasa a yt-dlp sin
cookies, mas seguido todavia desde IPs de datacenter/VPS. No es un bug de
esta app: el app.py original de Flask tenia exactamente el mismo
problema potencial (usaba yt-dlp sin cookies tambien), solo que la
deteccion de YouTube se volvio mas agresiva con el tiempo.
Como exportar tus cookies (elegi una):
- Extension de navegador tipo "Get cookies.txt LOCALLY" (Chrome/Firefox):
entra a youtube.com logueado, exporta en formato Netscape, guardalo como
cookies.txt. - O con el propio yt-dlp instalado en tu máquina (no en el contenedor):
yt-dlp --cookies-from-browser chrome --cookies cookies.txt --skip-download "https://youtube.com/watch?v=dQw4w9WgXcQ"(cualquier URL sirve, el flag--skip-downloadsolo exporta las cookies).
Como configurarlo:
# En .env (o en el .env de la VPS correspondiente si es despliegue distribuido):
YTDLP_COOKIES_FILE_HOST=/ruta/absoluta/a/tu/cookies.txt
docker compose up -d --build worker-1 worker-2Si YTDLP_COOKIES_FILE_HOST queda vacío, docker-compose.yml monta
/dev/null en su lugar -- yt-dlp simplemente no usa cookies, exactamente
el comportamiento de antes. No hace falta nada más para que la app siga
funcionando con sitios que no bloquean por bots.
Importante:
cookies.txtson credenciales de sesión reales. Ya está en.gitignore-- nunca lo commitees.- Las cookies expiran. Si después de un tiempo vuelve a aparecer el error de bot, repetí la exportación.
- Solo el worker necesita esto (es quien corre
yt-dlp); la API no lo usa para nada.
Ver DEPLOYMENT.md para la guía completa: como se reparte en varias VPS de Hetzner, por que se agrupan api+redis+minio en una sola VPS chica en vez de una por servicio, por que se recomienda la linea CCX (vCPU dedicado) para los workers en vez de CPX (compartido), y los pasos de despliegue paso a paso.
Worker y API tienen dependencias muy distintas (el worker instala
faster-whisper/deep-translator/yt-dlp; la API no) y ambos declaran un
paquete top-level llamado app -- por diseno, para que cada uno se vea a
si mismo como from app.something import ... tanto corriendo local como
dentro de su propio contenedor. Eso significa que no se pueden correr
todos los tests en el mismo entorno virtual: hay que instalar cada
servicio en su propio venv y correr sus tests por separado, igual que se
buildean en Dockerfiles separados.
# Worker: logica de pipeline, progreso/ETA, sanitizacion, tareas Celery
python -m venv .venv-worker
.venv-worker/bin/pip install -e "worker[dev]"
cd worker && ../.venv-worker/bin/pytest ../tests/test_subtitles.py ../tests/test_progress.py ../tests/test_pipeline.py --cov=app
cd ..
# API: endpoints, validacion, WebSocket, rate limiting
python -m venv .venv-api
.venv-api/bin/pip install -e "api[dev]"
cd api && ../.venv-api/bin/pytest ../tests/test_api.py --cov=app
cd ..Los tests de la API usan Celery en modo eager con un backend en
memoria (cache+memory://) y una tarea stub registrada bajo el mismo
nombre que usa la API para encolar -- no hace falta Redis real ni el
pipeline pesado del worker. /api/health se testea con el cliente de Redis
mockeado.
Type-checking estricto en ambos:
cd worker && ../.venv-worker/bin/mypy app && cd ..
cd api && ../.venv-api/bin/mypy app && cd ..Estado actual: 75 tests / 79% cobertura en el worker, 17 tests / 81%
cobertura en la API, mypy --strict limpio en los dos. Todo esto se
corrio de verdad (no es una cifra aspiracional) -- en el camino se
encontraron y corrigieron varios bugs reales: un overflow de milisegundos
en fmt_ts (1.9999s -> "01,1000" en vez de acarrear el segundo), un
Dockerfile del worker con rutas de COPY que no coincidian con el build
context declarado en el compose, un campo cors_origins: list[str] en la
config de la API que rompia porque pydantic-settings intenta decodificar
listas como JSON antes de correr un validador propio, y un send_task()
que ignoraba task_always_eager en los tests (arreglado usando
Signature.apply_async(), que si lo respeta cuando la tarea esta
registrada localmente y cae a send_task si no -- mismo comportamiento en
produccion, testeable en eager mode).
El original usaba threading.Thread(daemon=True) por request: no
sobrevive a un restart del proceso, no tiene reintentos, no tiene limite
de tiempo, y sobre todo no es compatible con workers en otra maquina -- el
requisito central de este proyecto (worker-1/worker-2 en VPS
separadas) ya descarta esa opcion por si solo. Celery da cola persistente,
reintentos con backoff, limites de tiempo, y el mismo codigo de tarea
corre igual en un proceso local o en 10 VPS.
Alcanza sobradamente para el volumen de este proyecto, es un solo binario sin configuracion pesada, y sirve para tres cosas a la vez: broker de Celery, result backend, y canal de Pub/Sub para el progreso en tiempo real del WebSocket -- un componente menos que correr y operar.
La decision mas importante del proyecto: como comparten archivos el worker (que los genera) y la API (que los sirve), estando en VPS distintas.
Elegido: almacenamiento S3-compatible (MinIO para desarrollo local,
incluido en docker-compose.yml; Hetzner Object Storage, tu propio MinIO,
o AWS S3 en produccion -- se elige solo cambiando S3_ENDPOINT_URL y
credenciales). El worker sube el resultado al terminar; la API nunca lee
el filesystem del worker, genera URLs pre-firmadas contra el mismo bucket.
Por que: es el patron estandar para "un proceso genera un archivo, otro lo
sirve" cuando estan en maquinas distintas, no depende de que las VPS
compartan filesystem, escala a mas workers sin tocar infraestructura, y
ademas resolvio de paso una vulnerabilidad de path traversal que tenia
el codigo original (SUBTITLES_DIR / filename con el filename tomado
directo de la URL, sin sanitizar mas alla de chequear que existiera). En
el diseno nuevo el unico input del usuario en la URL de descarga es
job_id, que se usa solo para consultar Celery; el nombre real de archivo
y la key en el bucket salen del resultado que guardo el worker, nunca del
request.
Alternativa documentada: volumen compartido (NFS/SSHFS) entre las VPS.
Mas simple en el papel (los workers escriben como si fuera disco local),
pero en produccion distribuida es la parte mas fragil de todo el sistema:
hay que montar NFS por la red privada, manejar permisos, y el servidor NFS
es un punto unico de falla. Si preferis esta ruta, reemplaza
worker/app/storage.py y api/app/storage.py por operaciones de
filesystem sobre un volumen Docker montado desde el mismo NFS share en las
VPS de worker.
En vez de que la API haga polling interno del backend de resultados de
Celery, el worker publica cada actualizacion de progreso a un canal
progress:{job_id} en Redis (ver worker/app/progress.py) y la API se
suscribe a ese canal por cada WebSocket conectado (api/app/websocket.py)
y reenvia en tiempo real. El endpoint REST GET /api/jobs/{id} sigue
disponible como fallback si el WebSocket no puede conectar -- usa el mismo
formato de payload, solo que por polling cada 1.5s desde el frontend en
vez de push.
Cada worker carga su propio modelo Whisper en memoria (singleton por
proceso). Con concurrency=1 por worker (ver Dockerfile/compose), un
job a la vez por VPS -- evita tener N copias del modelo en RAM y
sobresuscribir CPU en tareas ya de por si intensivas (Whisper + FFmpeg).
El escalado es horizontal: mas contenedores worker, no mas threads dentro
de uno -- que es justo lo que worker-1/worker-2 demuestran.
Celery Beat corre embebido en worker-1 (flag -B de Celery), no como un
servicio aparte -- evita pedir una VPS extra solo para el scheduler. El
resto de los workers (worker-2, worker-3, ...) corren sin ese flag
para que la tarea periodica de limpieza no se dispare duplicada.
tasks.py distingue DeterministicPipelineError (URL invalida, sin
audio, video que supera la duracion maxima -- reintentar no cambia nada) de
TransientPipelineError (timeout, conexion reseteada, servicio
temporalmente caido en descarga o traduccion -- reintentar con backoff SI
tiene sentido). Solo la segunda categoria dispara autoretry_for en la
tarea Celery.
Este proyecto usa Docker Compose a proposito (asi lo pide el enunciado), pero esta disenado para no pelear con una migracion a k8s mas adelante:
- Cada servicio ya es una imagen Docker independiente con su propio
Dockerfile-- se traducen 1:1 a Deployments. - La configuracion ya es 100% por variables de entorno (Pydantic Settings) -- se traduce directo a ConfigMaps/Secrets.
- Redis pasaria a ser un servicio gestionado (o un
StatefulSetcon volumen persistente) en vez de un contenedor suelto. worker-1/worker-2se convierten en un unico Deployment conreplicas: Ny un HorizontalPodAutoscaler -- la limpieza (Celery Beat) se moveria a unCronJobseparado en vez de vivir embebida en una replica particular del worker (ahora mismo depende de que "worker-1" sea una instancia fija, que en k8s con replicas intercambiables no aplica).- El bucket S3-compatible no cambia nada -- es exactamente el mismo patron en k8s que fuera de el.
- Los health checks de Docker (
HEALTHCHECK) se traducen directo a liveness/readiness probes.
- Path traversal: cerrado al redisenar las descargas alrededor de
job_id+ Celery + presigned URLs (ver Almacenamiento). El original tenia esta vulnerabilidad activa (filenamede la URL sin sanitizar antes de armar la ruta de disco). - Subprocess: todas las llamadas (yt-dlp interno, ffmpeg, ffprobe) usan listas de argumentos, nunca concatenacion de strings para shell.
- Sanitizacion de nombre de archivo:
sanitize_filename()solo permite[a-zA-Z0-9 _-], bloquea nombres vacios/reservados de Windows, y trunca longitud. - Rate limiting:
POST /api/jobslimitado por IP (slowapi), configurable viaSUBGEN_RATE_LIMIT_CREATE_JOB. - CORS: configurable por entorno, nunca
*por default. - Errores sin fugas: el handler global de excepciones de FastAPI nunca devuelve stack traces ni rutas internas al cliente.
- Usuario no-root: los dos contenedores corren como usuario
subgen(uid 1000), no root. - Limite de duracion: video que supera
MAX_VIDEO_DURATION_SECONDSse rechaza antes de gastar computo en transcribirlo.
Verificado en producción: si un worker muere a mitad de una tarea (crash,
docker compose down sin querer, restart del host), Celery con
acks_late=True debería devolver esa tarea a la cola para que otro worker
la retome. Pero Redis como broker tiene su propio visibility_timeout
(default de kombu: 3600s) — hasta que no pase ese tiempo, considera que el
mensaje "probablemente sigue siendo procesado" y no lo redistribuye. Sin
configurarlo explícitamente, un crash real puede dejar un job mostrando su
último progreso conocido durante hasta una hora, sin que nadie lo esté
procesando de verdad.
Está configurado en celery_app.py como task_time_limit + 300 (justo
por encima del tiempo máximo que puede durar una tarea legítima, para no
redistribuir un job que todavía está corriendo bien). Si cambiás
SUBGEN_CELERY_TASK_TIME_LIMIT, este valor se ajusta solo.
Verificado en vivo: el primer job que procesa CADA proceso worker paga dos costos que no se repiten después:
- Descarga del modelo desde HuggingFace (cientos de MB) la primera vez
que ESE CONTENEDOR lo necesita — persiste en el volumen nombrado
whisper-cache(/home/subgen/.cache/huggingfacedentro del contenedor), así que sobrevive a restarts y rebuilds de la imagen. Sin este volumen, cadadocker compose build --no-cachefuerza una descarga nueva completa. - Carga del modelo en memoria (singleton por proceso, ver
worker/app/pipeline/transcribe.py) — esto se repite en cada restart del contenedor (no hay forma de evitarlo, es cargar pesos a RAM), pero es mucho más rápido que la descarga: unos segundos, no decenas.
Con dos workers (worker-1/worker-2), cada uno tiene su PROPIO proceso y
su propio modelo cargado en memoria — Celery reparte los jobs al que esté
libre, así que no hay control directo sobre cuál toma cada uno. Es
esperable que los primeros dos jobs (uno por worker) sean más lentos que
los siguientes.
Verificado en vivo (levantando la API real con Redis apagado a proposito):
si Redis deja de responder, Celery agota sus propios reintentos de
reconexion y el pool de conexiones del backend queda en un estado que su
propio log describe como "the Celery application must be restarted". La
API no se cae -- cada POST /api/jobs devuelve 503 de forma limpia (ver
QueueUnavailableError en api/app/celery_client.py) -- pero, dependiendo
de cuanto haya durado el corte, el proceso de la API puede necesitar un
restart manual (docker compose restart api) incluso despues de que Redis
vuelva, porque ese pool interno de Celery no siempre se recupera solo. En
docker-compose.yml/.prod.yml el healthcheck de api va a marcar el
contenedor como unhealthy en ese escenario, pero Docker Compose (a
diferencia de Swarm/k8s) no reinicia automaticamente por un healthcheck
fallido -- si automatizas el redeploy, agrega un watchdog externo
(autoheal, un cron simple, o el liveness probe correspondiente si migras
a k8s) que reinicie contenedores unhealthy.
Antes de considerar esto "andando" en un entorno nuevo (local o VPS):
- Servicios arriba:
docker compose ps-- los 5 contenedores (o 4 en prod sin MinIO) en estadohealthy, no solorunning. - Health check:
curl http://localhost:8000/api/health->{"status": "ok", "redis": true}. - Frontend carga: abrir
http://localhost:8000-- se ve el logo, el tema oscuro por default (o el que tenga el sistema operativo), sin flash blanco al cargar. - Tema claro: click en el boton de tema -- cambia sin recargar,
persiste al refrescar la pagina (
localStorage). - Idioma de interfaz: click en el boton "EN" -- todos los textos de la
UI cambian (encabezados, botones, footer). El
<select>de "Traducir a:" NO cambia de idioma con este boton -- son cosas distintas, y hay un texto aclarandolo debajo del select. - Job modo SRT: pegar una URL de YouTube corta (~1-2 min), modo "Solo
subtitulos" -- si da
"Sign in to confirm you're not a bot", ver Cookies de YouTube, o probar primero conhttps://archive.org/details/big-buck-bunny-720-10s-5-mb-1(dominio publico, sin bloqueo) para validar que el pipeline en si funciona. Verificar:- La barra de progreso avanza de forma continua (no saltos fijos).
- Los mensajes de progreso van cambiando (descarga -> transcripcion -> traduccion).
- Al terminar, aparece el boton de descarga y la vista previa de los primeros segmentos.
- El archivo
.srtdescargado abre bien y tiene los timestamps correctos.
- Job modo video: mismo video, modo "Video con subtitulos". Verificar
ademas:
- Durante el quemado, el mensaje muestra porcentaje Y un ETA
(
"Quemando subtitulos -- 47% . faltan ~2m 15s"o el equivalente en ingles). - El
.mp4descargado tiene los subtitulos quemados con el estilo esperado (Arial, blanco con borde negro).
- Durante el quemado, el mensaje muestra porcentaje Y un ETA
(
- WebSocket real: con las devtools abiertas (pestana Network -> WS), confirmar que hay una conexion WebSocket activa durante el procesamiento, no requests HTTP repetidos cada 1.5s.
- Fallback a polling: cortar el WebSocket a mano (bloquear el puerto, o simular con devtools) y confirmar que el progreso sigue actualizando via polling REST.
- URL invalida: probar con
no-es-una-url-> error claro, sin stack trace, sin tumbar el job anterior. - Rate limit: mandar 6+ requests seguidos a
POST /api/jobs-> el 6to (segun el limite configurado) devuelve 429. 11b. Redis caido: parar el contenedorrediscon un job en curso y confirmar quePOST /api/jobsresponde503(no500) mientras dure el corte, y que al levantarredisde nuevo la API vuelve a aceptar jobs sin intervencion manual — si no, ver la nota operativa de la seccion Seguridad y reiniciar el contenedorapi. - Escalado horizontal: con un job en curso, matar
worker-1a mitad de proceso (docker compose kill worker-1) -- gracias aacks_late=True, la tarea tiene que reaparecer y la terminaworker-2(puede reiniciar desde el principio, no hay checkpointing intra-tarea, pero no se pierde silenciosamente). - Limpieza automatica: confirmar en los logs de
worker-1que la tareacleanup_expired_outputscorre periodicamente (cadaSUBGEN_CLEANUP_INTERVAL_SECONDS). - Logs estructurados: confirmar que los logs de
apiy de los workers son JSON, y que un mismojob_idaparece correlacionado en los logs de ambos servicios para un mismo trabajo.