Construyendo un extractor de audio (YouTube MP3) con FastAPI, yt-dlp y ffmpeg

Published: (January 8, 2026 at 06:05 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Cover image for Construyendo un extractor de audio (YouTube MP3) con FastAPI, yt‑dlp y ffmpeg

Mario

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í:

  1. El usuario envía un POST con:
    • source_url: enlace de YouTube (u otra plataforma compatible)
    • calidad_mp3: 192k, 128k o 64k
    • recaptcha_token: para evitar abuso/robots
  2. Validamos el reCAPTCHA y la URL.
  3. Usamos yt‑dlp para inspeccionar los formatos disponibles y elegir el mejor stream de audio.
  4. Descargamos solo el audio (o un stream progresivo de vídeo + audio si no queda otra).
  5. Pasamos ese archivo a ffmpeg para convertirlo a MP3 con el bitrate solicitado.
  6. Devolvemos un JSON con:
    • download_url para un endpoint de descarga tipo /descargar/{filename}
  7. 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
  • 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): revisa url, manifest_url o fragment_base_url.
  • is_storyboard(f): descarta formatos con format_id tipo sb* o extensiones mhtml, 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_ipv4
  • retries, fragment_retries, socket_timeout
  • cookiefile (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_ffmpeg es 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:
👉

Back to Blog

Related posts

Read more »