FastAPI, yt-dlp 및 ffmpeg를 사용한 오디오 추출기 (YouTube MP3) 만들기

발행: (2026년 1월 9일 오전 08:05 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

FastAPI, yt‑dlp 및 ffmpeg를 사용한 오디오 추출기 (YouTube MP3) 구축 커버 이미지

Mario

소개

비디오에서 오디오를 추출해 팟캐스트처럼 듣거나 수업을 복습하는 것은 매우 일반적인 사용 사례입니다. 하지만 다음과 같이 하고 싶을 때 문제가 발생합니다:

  • 컴퓨터에 별다른 프로그램을 설치하지 않고
  • 모바일에서

이 포스트에서는 우리가 사용하고 있는 오디오 추출기가 내부적으로 어떻게 구성되어 있는지 이야기합니다

👉

그리고 도구

👉

전체 코드를 설명하지는 않지만 핵심 아이디어는 FastAPI, yt‑dlp, ffmpeg, reCAPTCHA와 YouTube(Shorts 포함)에서 비교적 잘 동작하도록 하는 몇 가지 트릭입니다.

일반 흐름: URL에서 다운로드 가능한 MP3로

Endpoint “URL → MP3” 흐름은 다음과 같습니다:

  1. 사용자가 POST 요청을 보냅니다:
    • source_url: YouTube(또는 기타 호환 플랫폼) 링크
    • calidad_mp3: 192k, 128k 또는 64k
    • recaptcha_token: 남용/봇 방지를 위해
  2. reCAPTCHA와 URL을 검증합니다.
  3. yt‑dlp를 사용해 사용 가능한 포맷을 검사하고 가장 좋은 오디오 스트림을 선택합니다.
  4. 오디오만 다운로드합니다(다른 방법이 없을 경우 비디오 + 오디오 프로그레시브 스트림을 다운로드).
  5. 해당 파일을 ffmpeg에 전달해 요청된 비트레이트로 MP3로 변환합니다.
  6. 다음과 같은 JSON을 반환합니다:
    • download_url: /descargar/{filename} 형태의 다운로드 엔드포인트용
  7. 임시 파일을 정리합니다.

사용자 입장에서는 이 모든 것이 매우 간단한 폼으로 제공됩니다.

👉

HTTP 레이어: FastAPI + reCAPTCHA

웹 부분은 FastAPI의 클래식 엔드포인트이며 폼을 사용합니다:

@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
    ...

주요 사항

  • reCAPTCHA: 검증은 yt‑dlp를 사용해 CPU/네트워크를 소모하기 전에 수행됩니다.
  • URL 정규화: normalize_youtube_url(...) 함수를 사용하여:
    • https://를 강제합니다
    • YouTube URL에서 불필요한 파라미터를 제거합니다
  • request id (rid): 각 요청은 로그에 UUID를 포함하며, 특정 비디오와 관련된 문제를 디버깅하는 데 큰 도움이 됩니다.

Source:

yt‑dlp: 오디오 스트림을 올바르게 선택하기

yt‑dlp는 다양한 포맷 정보를 많이 반환합니다. 단순히 "format": "bestaudio/best"를 사용하는 대신 다음을 고려했습니다:

  • 직접 URL이 없는 포맷(스토리보드, 썸네일 등)을 피하기
  • 오디오 전용(예: m4a/140)이 있으면 우선 선택하기
  • 빈값에 가까운 포맷은 무시하기
  • 드물게 발생하는 경우를 대비해 fallback 로 진행형(비디오 + 오디오) 스트림을 사용하기

포맷 선택 함수의 아이디어는 다음과 같은 (단순화된) 의사코드와 같습니다:

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)]

    # 매우 일반적인 YouTube의 m4a / id 140이 있으면 우선 선택
    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)

    # 그 다음으로 합리적인 오디오‑전용 포맷
    if audio_only:
        return best_by_abr(audio_only)

    # Fallback: 진행형 비디오+오디오 스트림 (ffmpeg로 오디오를 추출)
    progressive = [f for f in fmts if is_progressive_with_audio(f)]
    if progressive:
        return best_by_abr_or_tbr(progressive)

    # 최후의 수단
    return "bestaudio/best"

추가 필터 (실제 구현)

  • has_url(f): url, manifest_url 또는 fragment_base_url을 확인합니다.
  • is_storyboard(f): format_idsb* 형태이거나 확장자가 mhtml, 이미지 등인 포맷을 제외합니다.

이러한 방법을 통해 “유효한 포맷이 없음” 상황을 크게 줄이고, 대역폭 사용을 절감할 수 있습니다.

player_client 사용하기: Android, iOS 및 웹

yt‑dlp의 흥미로운 점은 다음 인수입니다:

"extractor_args": {"youtube": {"player_client": [player_client]}}

YouTube는 당신이 다음 중 하나인 척 할 때 동일하게 동작하지 않습니다:

  • Android 클라이언트
  • iOS 클라이언트
  • 웹 플레이어

실제로, 특정 콘텐츠(특히 Shorts)의 경우 일부 클라이언트가 다른 클라이언트보다 더 잘 작동합니다. 엔드포인트는 다음과 같은 작업을 수행합니다:

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

ytdlp_opts_base(...)에서는 다음과 같은 항목도 조정됩니다:

  • 선택한 클라이언트를 더 잘 시뮬레이션하기 위한 User‑AgentAccept‑Language
  • 더 탄력적으로 만들기 위한 Timeoutsretries

결론

FastAPI, yt‑dlp, ffmpeg와 최적 스트림을 선택하는 약간의 로직을 사용하면, 데스크톱과 모바일 모두에서 잘 작동하고 로컬 설치가 필요 없는 비디오를 MP3로 변환하는 서비스를 제공할 수 있습니다.

시도해보고 싶다면, 방문하고 직접 링크를 실험해 보세요. Happy hacking!

Accept-Language

  • force_ipv4
  • retries, fragment_retries, socket_timeout
  • cookiefile (존재하는 경우, 일부 제한을 우회하기 위해)

Source:

ffmpeg를 사용한 MP3 변환

파일(오디오 또는 비디오 + 오디오)을 다운로드한 뒤 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)

몇 가지 세부 사항

  • safe_run_ffmpeg는 실행 시간을 제한하고 오류를 캡처하는 wrapper입니다.
  • 호환성을 위해 항상 44100 Hz2채널을 사용하지만, 순수 음성의 경우 모노로 최적화할 수 있습니다.
  • 최종 파일 크기를 MB 단위와 비트레이트로 저장하여 (개인 정보 없이) 집계 통계를 생성합니다.

스트리밍 다운로드 및 정리

다운로드 엔드포인트는 GET /descargar/{filename}이며 파일을 StreamingResponse청크 단위로 전송합니다:

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",
    },
)

중요한 세부 사항

  • 다운로드된 원본 파일은 finally 블록에서 삭제됩니다.
  • 생성된 MP3 파일은 디스크에 남아 있지만, 정리 프로세스가 24 h마다 자동으로 삭제합니다.
  • 동일한 패턴이 YouTube 부분과 웹의 다른 도구들, 일반 오디오 변환기에도 사용됩니다:

👉

보안, 제한 및 법적 측면

공공 서비스이므로 주의해야 할 여러 사항이 있습니다:

  • reCAPTCHA 를 URL → MP3 경로에 적용하여 자동 남용을 방지합니다.
  • URL에 대한 엄격한 검증 (http(s)://만 허용, 정규화)으로 서비스가 열린 프록시가 되는 것을 방지합니다.
  • 최소하지만 유용한 로그: request_id, IP, user‑agent, bitrate, 크기; 개인 콘텐츠는 포함되지 않습니다.
  • 프라이버시: 임시 및 최종 파일을 주기적으로 삭제합니다.
  • 저작권: 양식과 텍스트에서 링크 옵션(YouTube 등)은 본인 소유이거나 허가받은 콘텐츠에만 사용하도록 명시합니다.

기술 흐름을 실험하고 싶다면 YouTube 부분을 무시하고 동일한 도구에서 로컬 비디오를 사용해 볼 수 있습니다:
👉

Back to Blog

관련 글

더 보기 »