DEV Community

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

Posted on

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

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 viene 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
👉 convertiraudioamp3.com
y en la herramienta de
👉 vídeo a MP3

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:
  2. source_url: enlace de YouTube (u otra plataforma compatible)
  3. calidad_mp3: 192k, 128k o 64k
  4. recaptcha_token: para evitar abuso/robots

  5. Validamos el reCAPTCHA y la URL.

  6. Usamos yt-dlp para inspeccionar los formatos disponibles y elegir el mejor stream de audio.

  7. Descargamos solo el audio (o un stream progresivo de vídeo+audio si no queda otra).

  8. Pasamos ese archivo a ffmpeg para convertirlo a MP3 con el bitrate solicitado.

  9. Devolvemos un JSON con:

  10. download_url para un endpoint de descarga tipo /descargar/{filename}

  11. Limpiamos archivos temporales.

A nivel de usuario, todo eso es un formulario muy simple en
👉 convertiraudioamp3.com/convertir_video_a_mp3

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
    ...
Enter fullscreen mode Exit fullscreen mode

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 da 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"
Enter fullscreen mode Exit fullscreen mode

En la implementación real se añaden filtros extra:

  • 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 (y 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
    ...
Enter fullscreen mode Exit fullscreen mode

En ytdlp_opts_base(...) también se ajustan cosas como:

  • User-Agent y 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)```
{% endraw %}


Algunos detalles:

- safe_run_ffmpeg es un wrapper que limita tiempo de ejecución y captura errores.
- Usamos siempre 44100 Hz y 2 canales por compatibilidad, aunque para voz pura se podría optimizar a mono.
- Guardamos tamaño final en MB y bitrate para poder hacer estadísticas agregadas (sin datos personales).

## Descarga por streaming y limpieza

El endpoint de descarga es un GET /descargar/{filename} que envía el archivo como StreamingResponse en chunks, algo como:
{% raw %}


```python
def iterfile(path: Path, chunk_size: int = 1024 * 1024):
    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",
    },
)
Enter fullscreen mode Exit fullscreen mode

Y, muy importante:

  • El archivo descargado original se borra en un finally.
  • Los MP3 generados viven en disco, pero hay un proceso de limpieza que va eliminando todo de forma automática cada 24 h.

Ese mismo patrón lo usamos no solo para la parte de YouTube, sino para el resto de herramientas en la web, incluído el conversor de audio general:
👉 https://convertiraudioamp3.com/

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 (http(s):// y normalización) para no dejar que esto se convierta en un 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 trastear con el flujo técnico, puedes ignorar la parte de YouTube y probar con vídeo local en la misma herramienta de
👉 vídeo a MP3

subiendo un MP4/MOV/MKV desde tu disco.

Top comments (0)