Construyendo un extractor de audio (YouTube MP3) con FastAPI, yt-dlp y ffmpeg
Source: Dev.to

Introducción
Extraer el audio de un vídeo para escucharlo como podcast o repasar una clase es un caso de uso muy típico. El problema aparece cuando quieres hacerlo:
- Sin instalar nada raro en el ordenador
- Desde el móvil
En este post cuento cómo está montado por dentro el extractor de audio que usamos en
👉
y en la herramienta de
👉
No explico el código entero, pero sí las ideas clave: FastAPI, yt‑dlp, ffmpeg, reCAPTCHA y algunos trucos para que funcione razonablemente bien con YouTube (incluidos Shorts).
Flujo general: de URL a MP3 descargable
El flujo del endpoint “URL → MP3” es algo así:
- El usuario envía un POST con:
source_url: enlace de YouTube (u otra plataforma compatible)calidad_mp3:192k,128ko64krecaptcha_token: para evitar abuso/robots
- Validamos el reCAPTCHA y la URL.
- Usamos yt‑dlp para inspeccionar los formatos disponibles y elegir el mejor stream de audio.
- Descargamos solo el audio (o un stream progresivo de vídeo + audio si no queda otra).
- Pasamos ese archivo a ffmpeg para convertirlo a MP3 con el bitrate solicitado.
- Devolvemos un JSON con:
download_urlpara un endpoint de descarga tipo/descargar/{filename}
- Limpiamos archivos temporales.
A nivel de usuario, todo eso es un formulario muy simple en
👉
Capa HTTP: FastAPI + reCAPTCHA
La parte web es un endpoint clásico de FastAPI con formulario:
@router.post("/convertir_video_a_mp3")
async def convertir_enlace_a_mp3(
request: Request,
source_url: str = Form(...),
calidad_mp3: str = Form("128k"),
):
# 1) Log de request (request_id, IP, path, etc.)
# 2) Verificar reCAPTCHA con el token del formulario
# 3) Validar/normalizar la URL
# 4) Lanzar la lógica de descarga + conversión
...
Cosas a destacar
- reCAPTCHA: la verificación se hace antes de gastar CPU/red con yt‑dlp.
- Normalización de URL: usamos una función
normalize_youtube_url(...)que:- fuerza
https:// - limpia parámetros prescindibles en URLs de YouTube
- fuerza
- request id (rid): cada petición lleva un UUID en logs, lo que ayuda muchísimo a depurar problemas con vídeos concretos.
yt‑dlp: elegir bien el stream de audio
yt‑dlp devuelve mucha información de formatos. En lugar de usar un simple "format": "bestaudio/best", nos interesaba:
- Evitar formatos sin URL directa (storyboards, miniaturas, etc.)
- Preferir audio‑only (ej.
m4a/140) si está disponible - Ignorar formatos pseudo‑vacíos
- Tener un fallback progresivo (vídeo + audio) para casos raros
La idea de la función de selección de formato es algo así (pseudocódigo simplificado):
def pick_best_audio_format(info_dict):
fmts = info_dict.get("formats") or []
def is_valid_audio(f):
return (
f.get("vcodec") == "none"
and (f.get("acodec") or "").lower() not in {"", "none"}
and has_url(f)
and not is_storyboard(f)
)
audio_only = [f for f in fmts if is_valid_audio(f)]
# Preferimos m4a / id 140 si existe (muy típico en YouTube)
m4a = [f for f in audio_only if f.get("ext") == "m4a" or f.get("format_id") == "140"]
if m4a:
return best_by_abr(m4a)
# Luego cualquier audio‑only razonable
if audio_only:
return best_by_abr(audio_only)
# Fallback: stream progresivo vídeo+audio (extraeremos audio con ffmpeg)
progressive = [f for f in fmts if is_progressive_with_audio(f)]
if progressive:
return best_by_abr_or_tbr(progressive)
# Último recurso
return "bestaudio/best"
Filtros extra (implementación real)
has_url(f): revisaurl,manifest_urlofragment_base_url.is_storyboard(f): descarta formatos conformat_idtiposb*o extensionesmhtml, imágenes, etc.
Todo esto reduce bastante los casos de “no hay formatos válidos” y ahorra ancho de banda.
Jugar con player_client: Android, iOS y Web
Un detalle interesante de yt‑dlp es el argumento:
"extractor_args": {"youtube": {"player_client": [player_client]}}
YouTube no se comporta igual si finges ser:
- un cliente Android
- un cliente iOS
- el reproductor web
En la práctica, para cierto contenido (sobre todo Shorts) algunos clientes funcionan mejor que otros. El endpoint hace algo como:
clients = ["android", "ios", "web"]
downloaded = False
for client in clients:
try:
opts = ytdlp_opts_base(client)
info = extract_info_only(opts, source_url)
fmt = pick_best_audio_format(info)
download_with_format(opts, source_url, fmt)
downloaded = True
break
except Exception as e:
log_error(e, client)
continue
# Fallback sin player_client (a veces da formatos que los otros bloquean)
if not downloaded:
opts = ytdlp_opts_base("web")
opts.pop("extractor_args", None) # sin client
# ... intentar de nuevo
En ytdlp_opts_base(...) también se ajustan cosas como:
- User‑Agent y Accept‑Language para simular mejor el cliente elegido.
- Timeouts y retries para ser más resilientes.
Conclusión
Con FastAPI, yt‑dlp, ffmpeg y un poco de lógica para escoger el mejor stream, es posible ofrecer un servicio de conversión de vídeo a MP3 que funciona bien tanto en escritorio como en móvil, sin requerir instalaciones locales.
Si te interesa probarlo, visita y experimenta con tus propios enlaces. ¡Happy hacking!
Accept-Language
force_ipv4retries,fragment_retries,socket_timeoutcookiefile(si existe, para sortear algunas restricciones)
Conversión a MP3 con ffmpeg
Una vez descargado el archivo (audio o vídeo + audio), lo pasamos por ffmpeg:
cmd = [
FFMPEG_BIN, "-y",
"-i", str(downloaded_path),
"-vn", # sin vídeo
"-c:a", "libmp3lame",
"-b:a", calidad_mp3, # "192k", "128k" o "64k"
"-ar", "44100",
"-ac", "2",
str(final_mp3),
]
safe_run_ffmpeg(cmd)
Algunos detalles
safe_run_ffmpeges un wrapper que limita el tiempo de ejecución y captura errores.- Siempre usamos 44100 Hz y 2 canales por compatibilidad, aunque para voz pura se podría optimizar a mono.
- Guardamos el tamaño final en MB y el bitrate para poder generar estadísticas agregadas (sin datos personales).
Descarga por streaming y limpieza
El endpoint de descarga es GET /descargar/{filename} y envía el archivo como StreamingResponse en chunks:
def iterfile(path: Path, chunk_size: int = 1024 * 1024):
"""Yield the file in chunks."""
with path.open("rb") as f:
while chunk := f.read(chunk_size):
yield chunk
return StreamingResponse(
iterfile(file_path),
media_type="audio/mpeg",
headers={
"Content‑Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0, private",
},
)
Detalles importantes
- El archivo original descargado se elimina en un bloque
finally. - Los MP3 generados permanecen en disco, pero un proceso de limpieza los borra automáticamente cada 24 h.
- El mismo patrón se usa tanto para la parte de YouTube como para el resto de herramientas de la web, incluido el conversor de audio general:
👉
Seguridad, límites y aspectos legales
Al ser un servicio público, hay varios puntos a cuidar:
- reCAPTCHA en la ruta de URL → MP3 para frenar abuso automatizado.
- Validación estricta de URLs (solo
http(s)://, normalización) para evitar que el servicio actúe como proxy abierto. - Logs mínimos pero útiles:
request_id, IP, user‑agent, bitrate, tamaño; nada de contenido privado. - Privacidad: archivos temporales y finales se eliminan de forma periódica.
- Derechos de autor: el formulario y los textos dejan claro que la opción de enlace (YouTube u otros) es solo para contenido propio o con permiso.
Si solo quieres experimentar con el flujo técnico, puedes ignorar la parte de YouTube y probar con vídeo local en la misma herramienta:
👉
