Flask 환경에서 yt-dlp와 FFmpeg 연동해보기

초록자두·2026년 1월 18일

개발환경구축

목록 보기
10/13

0-0. 서론


본 글은 개인 학습 및 기술 정리를 목적으로 작성되었으며,
외부 배포, 서비스 운영, 상업적 이용을 의도하지 않는다.
모든 미디어 콘텐츠의 저작권은 원 저작권자에게 있으며,
본 글은 로컬 환경에서의 개인적인 실험 기록이다.


0. 완성본



1. 문제상황 발생.


나는, 유튜브를 상당히 즐겨보는데, 오프라인 모드나 유튜브 프리미엄 뮤직을 통한 다운로드 기능이 확실히 좋았다. 그러면서도 출근길에 항상 생각한 것이 "나만을 위한 음원 추출 프로그램, 혹은 영상 추출 프로그램이 있으면 얼마나 좋을까?" 라고 생각한 적이 많았다. 기왕 이렇게 생각한 것, 한번 만들어 보기로 결심했다.


2. 준비물


  1. python (3.9 버전)
  2. yt-pip, FFmpeg (ffmpeg.exe)
  3. YouTube Cookies (youtube_cookies.txt)
  4. test.html

나는 python 으로 만들것이다. flask 와, yt-dlp 을 이용하여 추출 프로그램을 한번 만들고자 한다. 이 프로그램을 만들면서 가장 힘들었던 점이 바로 유튜브의 보안 정책이었다. 로컬로는 돌아가지만 Rander 등 공용 도메인을 사용하면 막아버리는 경우도 많았다. 일단 배포나 서비스까지는 가지 말고, 개인적인 용도로만 사용하기 위해 로컬로만 운영하도록 하기로 결정했다. 랜더 등에서 서비스 할 경우에는 유튜브 보안과 싸워야 했기 때문에 (403, 503 등..) 정말 쉽지 않았다. 실제로 공개 다운로드 서비스들은 법적·기술적 리스크가 크기 때문에, 개인 학습 용도로 로컬 환경에서만 실험하는 방향을 선택했다. (무엇보다도 불법이기도 하고..)


  • 수집하기(yt-pip) - 내가 입력한 URL을 분석해 유튜브 서버에서 영상과 소리 조각을 따로따로 가져온다.
  • 요리하기(FFmpeg) - 요리(FFmpeg): 따로 노는 영상과 소리를 하나로 합친다.
  • 서빙하기(Flask) - FFmpeg 가 끝나면 브라우저로 전달한다.

3. 폴더구조와 라이브러리 설치하기


project/
├ youtube_audio_downloader.py
├ ffmpeg.exe
├ youtube_cookies.txt
├ templates/
│ └ test.html

pip install flask yt-dlp


이 파이썬을 사용하기 위해서, 위와 같은 라이브러리가 필요하다. 터미널에서 설치를 해주자. 나는 개인적으로 가상환경에서 구현하였으므로 파이썬 인터프리터를 통해 가상환경에서 실행하는걸 추천한다. 우리는 ffmpeg를 사용하므로 반드시 파이썬 내부에 해당 exe 프로그램이 있어야 한다. ffmpeg가 없다면 다운로드 해야한다.


ffmpeg 공식사이트 로 들어가, exe 파일을 설치 후, 반드시 파이썬 안의 폴더에 집어넣자.


4. [중요] youtube_cookies.txt 파일을 추출하기


일단 무로그인 상태에서 코드를 실행시키려면 오류를 뱉는 경우가 많았다. 로그인 환경에서의 요청 조건을 동일하게 맞추기 위해 쿠키를 사용했다.



  1. 구글 앱스토어에 접속한다.
  2. Get cookies.txt LOCALLY 를 검색해 확장 프로그램을 다운받는다.
  3. 유튜브 사이트에 접속하여 로그인한다. (시크릿모드로 하면 더욱 좋다)
  4. 오른쪽 상단 확장 프로그램 아이콘에서 방금 설치한 Get cookies.txt를 클릭한다.
  5. Export 또는 'youtube.com cookies' 버튼을 눌러 내보내기를 한다.
  6. 그 후 받은 파일 이름을 youtube_cookies.txt 로 변경한다.

쿠키 파일은 절대 남에게 공유해서는 안된다. 쿠키 안에는 유튜브 로그인 세션이 남아있으므로 이 파일을 누군가에게 주면, 그 사람이 내 계정으로 로그인한 것과 다름없는 상황이 된다. 그러니 개인적인 용도로만 사용해야만 한다.


5. 백엔드 코드 작성하기


보안정책으로 인해 전체적인 백엔드 코드를 다 보여줄 순 없지만 (저작권위험) 방향성에 대해서 서술한다. 단순히 다운로드만 하는 게 아니라, 여러 파일을 하나로 묶어주는 ZIP 압축 기능과 한글 깨짐을 방지하는 인코딩 처리를 통해 실제 사용자가 편하게 느낄 수 있도록 로직을 설계한다.



from flask import Flask, render_template, request, send_file, jsonify
import yt_dlp
import subprocess
import os
import re
import zipfile
from datetime import datetime

app = Flask(__name__)

# 경로 설정
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DOWNLOAD_FOLDER = os.path.join(BASE_DIR, "downloads")
COOKIE_PATH = os.path.join(BASE_DIR, "youtube_cookies.txt")
FFMPEG_PATH = os.path.join(BASE_DIR, "ffmpeg.exe")  # 윈도우 환경 기준

os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)

def sanitize_filename(filename): # 파일 이름을 정리해주는 함수 만들기
def extract_media_logic(url, ext_choice, sub_folder, download_mode="audio"): 
# 영상을 추출하는 함수 만들기
# 1. 고화질(720p) 전략 설정
    if download_mode == "video":
        target_format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best"
    else:
        target_format = "bestaudio/best"

... 생략

@app.route("/")
def index():
    return render_template("test.html")
# 플라스크를 이용한, html 연결해주기

# 웹과 파이썬을 만나는 접점 만들어주기

@app.route("/download", methods=["POST"])
def download():
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    work_dir = os.path.join(DOWNLOAD_FOLDER, timestamp)
    os.makedirs(work_dir, exist_ok=True)

    try:
        urls = [u for u in request.form.getlist("urls[]") if u.strip()]
        ext_choice = request.form.get("format", "mp3")
        download_mode = request.form.get("mode", "audio")

        if not urls:
            return jsonify({"success": False, "message": "유효한 링크가 없습니다."}), 400

        results = []
        for url in urls:
            results.append(extract_media_logic(url, ext_choice, work_dir, download_mode))

        from urllib.parse import quote

        # 파일이 한 개일 때
        if len(results) == 1:
            path, name = results[0]
            response = send_file(path, as_attachment=True)
            response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(name)}"
            return response

        # 파일이 여러 개일 때 (ZIP 묶기)
        prefix = "영상팩" if download_mode == "video" else "음원팩"
        zip_name = f"{prefix}_{timestamp}.zip"
        zip_path = os.path.join(DOWNLOAD_FOLDER, zip_name)

        with zipfile.ZipFile(zip_path, "w") as z:
            for path, name in results:
                z.write(path, name)

        response = send_file(zip_path, as_attachment=True)
        response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(zip_name)}"
        return response

    except Exception as e:
        return jsonify({"success": False, "message": str(e)}), 500
        
# 플라스크 실행용
if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8080))
    app.run(host="0.0.0.0", port=port)

그 외, 백엔드 로직은 요즘 GPT가 잘 나와있으니 참고해서 만들면 된다.


6. HTML 파일 작성하기



<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Multi Converter - 초록자두</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
        body { font-family: 'Noto Sans KR', sans-serif; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); }
        .glass { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.6); }

        /* 라디오 버튼 선택 강조 */
        input[name="format"]:checked + label,
        input[name="mode"]:checked + label {
            border-color: #ef4444;
            background-color: #fef2f2;
            color: #b91c1c;
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(239, 68, 68, 0.1);
        }

        /* 추출 시작하기 버튼 커스텀 효과 */
        .btn-glow {
            position: relative;
            transition: all 0.3s ease;
            overflow: hidden;
        }
        .btn-glow:hover {
            box-shadow: 0 0 20px rgba(239, 68, 68, 0.4);
            transform: translateY(-3px);
        }
        .btn-glow:active {
            transform: translateY(0);
        }

        /* 입력창 애니메이션 */
        .input-animate { animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); }
        @keyframes slideIn {
            from { opacity: 0; transform: translateX(-10px); }
            to { opacity: 1; transform: translateX(0); }
        }
    </style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">

    <div class="glass p-8 rounded-[2.5rem] shadow-2xl w-full max-w-lg border border-white">
        <div class="text-center mb-10">
            <div class="relative inline-block">
                <div class="bg-gradient-to-tr from-red-500 to-pink-500 w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-4 shadow-xl rotate-6 transition-transform hover:rotate-0 duration-500">
                    <i class="fa-brands fa-youtube text-white text-4xl"></i>
                </div>
                <span class="absolute -top-1 -right-1 flex h-4 w-4">
                    <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
                    <span class="relative inline-flex rounded-full h-4 w-4 bg-red-500"></span>
                </span>
            </div>
            <h1 class="text-3xl font-black text-gray-800 tracking-tight">Multi Converter</h1>
            <p class="text-gray-500 text-sm mt-2 font-medium">단 몇 초 만에 고화질 영상과 음원을!</p>
        </div>

        <form id="download-form" onsubmit="startDownload(event)" class="space-y-8">
            <div class="space-y-3">
                <div class="flex items-center justify-between px-1">
                    <label class="text-sm font-bold text-gray-700 flex items-center">
                        <i class="fa-solid fa-link mr-2 text-red-500"></i> 유튜브 주소
                    </label>
                    <div class="flex gap-1.5">
                        <button type="button" onclick="addInputs(4)" class="text-[11px] bg-gray-100 hover:bg-gray-200 text-gray-500 font-bold px-3 py-1.5 rounded-full transition">다중 +4</button>
                        <button type="button" onclick="addInputs(1)" class="text-[11px] bg-red-50 hover:bg-red-100 text-red-600 font-bold px-3 py-1.5 rounded-full transition">추가 +1</button>
                    </div>
                </div>

                <div id="input-container" class="space-y-3">
                    <div class="relative input-animate group">
                        <span class="absolute left-4 top-3.5 text-red-400 text-xs font-bold group-focus-within:scale-110 transition-transform">1</span>
                        <input type="url" name="urls[]" placeholder="여기에 유튜브 주소를 붙여넣으세요"
                            class="w-full pl-9 pr-4 py-3.5 rounded-2xl border border-gray-100 bg-gray-50 focus:bg-white focus:ring-4 focus:ring-red-50 focus:border-red-400 outline-none transition-all text-sm shadow-inner" required>
                    </div>
                </div>
            </div>

            <div class="space-y-3">
                <label class="block text-sm font-bold text-gray-700 px-1">
                    <i class="fa-solid fa-sliders mr-2 text-red-500"></i> 추출 모드
                </label>
                <div class="grid grid-cols-2 gap-4">
                    <input type="radio" id="mode_audio" name="mode" value="audio" class="hidden" checked onclick="toggleFormat(true)">
                    <label for="mode_audio" class="flex flex-col items-center justify-center p-4 rounded-2xl border-2 border-transparent bg-gray-50 cursor-pointer transition-all duration-300">
                        <i class="fa-solid fa-music mb-2 text-xl text-red-500"></i>
                        <span class="text-sm font-bold tracking-tight">음원 추출</span>
                    </label>

                    <input type="radio" id="mode_video" name="mode" value="video" class="hidden" onclick="toggleFormat(false)">
                    <label for="mode_video" class="flex flex-col items-center justify-center p-4 rounded-2xl border-2 border-transparent bg-gray-50 cursor-pointer transition-all duration-300">
                        <i class="fa-solid fa-video mb-2 text-xl text-blue-500"></i>
                        <span class="text-sm font-bold tracking-tight">영상 저장 (720p)</span>
                    </label>
                </div>
            </div>

            <div id="format-selection-area" class="space-y-3">
                <label class="block text-sm font-bold text-gray-700 px-1">
                    <i class="fa-solid fa-file-export mr-2 text-red-500"></i> 상세 포맷
                </label>
                <div class="grid grid-cols-1 gap-2.5">
                    <input type="radio" id="fmt_mp3" name="format" value="mp3" class="hidden" checked>
                    <label for="fmt_mp3" class="flex items-center p-3.5 rounded-2xl border-2 border-transparent bg-gray-50 cursor-pointer transition-all duration-300">
                        <div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center mr-3">
                            <span class="text-blue-600 font-bold text-xs">MP3</span>
                        </div>
                        <div>
                            <p class="text-sm font-bold text-gray-800">고음질 오디오</p>
                            <p class="text-[11px] text-gray-400">가장 널리 사용되는 표준 포맷</p>
                        </div>
                    </label>

                    <input type="radio" id="fmt_m4a" name="format" value="m4a" class="hidden">
                    <label for="fmt_m4a" class="flex items-center p-3.5 rounded-2xl border-2 border-transparent bg-gray-50 cursor-pointer transition-all duration-300">
                        <div class="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center mr-3">
                            <span class="text-purple-600 font-bold text-xs">M4A</span>
                        </div>
                        <div>
                            <p class="text-sm font-bold text-gray-800">애플 최적화</p>
                            <p class="text-[11px] text-gray-400">아이폰 및 애플 기기 권장</p>
                        </div>
                    </label>
                </div>
            </div>

            <button type="submit" id="submit-btn"
                class="btn-glow w-full bg-gradient-to-r from-red-500 via-red-600 to-pink-600 text-white font-black py-5 rounded-[1.5rem] shadow-xl shadow-red-200 flex items-center justify-center text-lg gap-3">
                <i class="fa-solid fa-wand-magic-sparkles animate-pulse"></i>
                <span>무료 추출 시작하기</span>
            </button>
        </form>

        <div id="status" class="hidden mt-10 p-8 bg-white rounded-[2.5rem] border border-gray-50 text-center shadow-2xl">
            <div class="relative w-20 h-20 mx-auto mb-6">
                <div class="absolute inset-0 border-4 border-red-100 rounded-full"></div>
                <div class="absolute inset-0 border-4 border-red-500 rounded-full border-t-transparent animate-spin"></div>
                <div class="absolute inset-0 flex items-center justify-center text-red-500">
                    <i class="fa-solid fa-bolt text-2xl"></i>
                </div>
            </div>
            <p class="text-xl font-black text-gray-800 mb-2" id="status-title">추출 준비 중...</p>
            <p class="text-sm text-gray-400 font-medium">유튜브 서버에서 데이터를 안전하게 가져오고 있습니다.</p>
        </div>

        <div id="error-box" class="hidden mt-8 p-6 bg-red-50 border border-red-100 rounded-[2rem] text-center">
            <i class="fa-solid fa-triangle-exclamation text-4xl text-red-400 mb-3"></i>
            <p id="error-summary" class="text-red-700 font-bold text-sm mb-4"></p>
            <button type="button" onclick="resetUI()" class="bg-white text-red-600 font-bold px-6 py-2.5 rounded-full shadow-sm hover:shadow-md transition text-xs">다시 시도</button>
        </div>

        <div class="mt-10 pt-6 border-t border-gray-100 text-center">
            <p class="text-xs text-gray-400 font-medium tracking-wide italic">Designed with ❤️ for <span class="text-red-400 font-bold">초록자두</span></p>
        </div>
    </div>

    <script>
    let inputCount = 1;
    const MAX_INPUTS = 5;

    function toggleFormat(isAudio) {
        const formatArea = document.getElementById('format-selection-area');
        const statusTitle = document.getElementById('status-title');
        formatArea.style.display = isAudio ? 'block' : 'none';
        statusTitle.innerText = isAudio ? '✨ 음원을 열심히 굽고 있어요!' : '🎬 영상을 고화질로 준비하고 있어요!';
    }

    function addInputs(count) {
        const container = document.getElementById('input-container');
        if (inputCount >= MAX_INPUTS) return;
        const available = MAX_INPUTS - inputCount;
        const toAdd = Math.min(count, available);

        for (let i = 0; i < toAdd; i++) {
            inputCount++;
            const newDiv = document.createElement('div');
            newDiv.className = 'relative input-animate group';
            newDiv.innerHTML = `
                <span class="absolute left-4 top-3.5 text-gray-300 text-xs font-bold group-focus-within:text-red-400 transition-colors">${inputCount}</span>
                <input type="url" name="urls[]" placeholder="추가 링크를 입력하세요"
                    class="w-full pl-9 pr-12 py-3.5 rounded-2xl border border-gray-100 bg-gray-50 focus:bg-white focus:ring-4 focus:ring-red-50 focus:border-red-400 outline-none transition-all text-sm shadow-inner">
                <button type="button" class="absolute right-4 top-3.5 text-gray-300 hover:text-red-500 transition-colors">
                    <i class="fa-solid fa-circle-xmark"></i>
                </button>
            `;
            container.appendChild(newDiv);
        }
    }

    function removeInput(button) {
        button.parentElement.remove();
        inputCount--;
        updateNumbers();
    }

    function updateNumbers() {
        document.querySelectorAll('#input-container span').forEach((span, idx) => { span.textContent = idx + 1; });
    }

    function resetUI() {
        document.getElementById('error-box').classList.add('hidden');
        document.getElementById('status').classList.add('hidden');
        document.getElementById('download-form').classList.remove('hidden');
        document.getElementById('submit-btn').disabled = false;
    }

    async function startDownload(event) {
        event.preventDefault();
        const form = document.getElementById('download-form');
        const formData = new FormData(form);
        const statusDiv = document.getElementById('status');
        const errorBox = document.getElementById('error-box');
        const btn = document.getElementById('submit-btn');

        form.classList.add('hidden');
        statusDiv.classList.remove('hidden');
        btn.disabled = true;

        try {
            const response = await fetch('/download', { method: 'POST', body: formData });
            if (response.ok) {
                const blob = await response.blob();
                const url = window.URL.createObjectURL(blob);
                const a = document.createElement('a');

                const contentDisposition = response.headers.get('Content-Disposition');
                let fileName = (formData.get('mode') === 'video') ? 'video_pack.zip' : 'audio_pack.zip';

                if (contentDisposition) {
                    const fileNameMatch = contentDisposition.match(/filename\*?=['"]?(?:UTF-8'')?([^;'\n]*)['"]?/i);
                    if (fileNameMatch && fileNameMatch[1]) fileName = decodeURIComponent(fileNameMatch[1]);
                }

                a.href = url;
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                a.remove();
                setTimeout(resetUI, 3000);
            } else {
                const errorData = await response.json();
                throw new Error(errorData.message || '추출 중 문제가 발생했습니다.');
            }
        } catch (error) {
            statusDiv.classList.add('hidden');
            errorBox.classList.remove('hidden');
            document.getElementById('error-summary').innerText = error.message;
        }
    }
    </script>
</body>
</html>

7. 마무리


  • 파일 이름의 정제: 유튜브 제목에 포함된 윈도우 금지 문자(\ / : * ? " < > |)를 sanitize_filename 함수로 자동 제거하여 프로그램 멈춤 현상을 방지한다.
  • 고화질 영상의 오디오 코덱 이슈: 고화질 영상 추출 시 발생하는 Opus 코덱의 호환성 문제를 해결하기 위해, FFmpeg를 연동하여 강제로 AAC 코덱으로 변환하는 로직을 구현했다.
  • 사용자 중심의 UX: 여러개의 URL을 처리할 수 있도록 만든다.
  • 다운로드가 완료되면 여러 파일을 일일이 받을 필요 없이, 자동으로 ZIP 압축하여 패키지 형태로 전달하도록 만들었다.
  • 한글 파일명이 깨지지 않도록 UTF-8 인코딩 처리를 완료했다.
profile
교육받고 열심히 해보려고 노력중인 30대 후반 아재

0개의 댓글