自学内容网 自学内容网

<Project-20 YT-DLP> 给视频网站下载工具 yt-dlp/yt-dlp 加个页面 python web

介绍 yt-dlp

Github 项目:https://github.com/yt-dlp/yt-dlp

A feature-rich command-line audio/video downloader

一个功能丰富的视频与音频命令行下载器

原因与功能

之前我用的 cobalt 因为它不再提供Client Web功能,只能去它的官网使用。 翻 reddit 找到这个 YT-DLP,但它是个命令行工具,考虑参数大多很少用到,给它加个web 壳子,又可以放到docker里面运行。

在网页填入url,只列出含有视频+音频的文件。点下载后,文件可以保存在本地。命令的运行输出也在页面上显示。占用端口: 9012

YT-DLP 程序

代码在 Claude AI 帮助下完成,前端全靠它,Nice~ 

界面

目录结构

20.YT-DLP/
├── Dockerfile
├── app.py
├── static/
│   ├── css/
│   │   └── style.css
│   └── js/
│       └── script.js
├── templates/
│   └── index.html
└── temp_downloads/
 

完整代码

1. app.py

# app.py
from flask import Flask, render_template, request, jsonify, send_file
import yt_dlp
import os
import shutil
from werkzeug.utils import secure_filename
import time
import logging
import queue
from datetime import datetime
import sys

app = Flask(__name__)

# 创建固定的临时目录
TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_downloads')
if not os.path.exists(TEMP_DIR):
    os.makedirs(TEMP_DIR)

# 存储下载信息的字典
DOWNLOADS = {}

# 创建日志队列
log_queue = queue.Queue(maxsize=1000)

class QueueHandler(logging.Handler):
    def __init__(self, log_queue):
        super().__init__()
        self.log_queue = log_queue

    def emit(self, record):
        try:
            # 过滤掉 Werkzeug 的常规访问日志
            if record.name == 'werkzeug' and any(x in record.getMessage() for x in [
                '127.0.0.1',
                'GET /api/logs',
                'GET /static/',
                '"GET / HTTP/1.1"'
            ]):
                return

            # 清理消息格式
            msg = self.format(record)
            if record.name == 'app':
                # 移除 "INFO:app:" 等前缀
                msg = msg.split(' - ')[-1]
            
            log_entry = {
                'timestamp': datetime.fromtimestamp(record.created).isoformat(),
                'message': msg,
                'level': record.levelname.lower(),
                'logger': record.name
            }
            
            # 如果队列满了,移除最旧的日志
            if self.log_queue.full():
                try:
                    self.log_queue.get_nowait()
                except queue.Empty:
                    pass
                    
            self.log_queue.put(log_entry)
        except Exception as e:
            print(f"Error in QueueHandler: {e}")

# 配置日志格式
log_formatter = logging.Formatter('%(message)s')

# 配置队列处理器
queue_handler = QueueHandler(log_queue)
queue_handler.setFormatter(log_formatter)

# 配置控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(log_formatter)

# 配置 Flask 日志
app.logger.handlers = []
app.logger.addHandler(queue_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(logging.INFO)

# Werkzeug 日志只输出错误
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = []
werkzeug_logger.addHandler(console_handler)
werkzeug_logger.setLevel(logging.WARNING)

def cleanup_old_files():
    """清理超过10分钟的临时文件"""
    current_time = time.time()
    for token, info in list(DOWNLOADS.items()):
        if current_time - info['timestamp'] > 600:  # 10分钟
            try:
                file_path = info['file_path']
                if os.path.exists(file_path):
                    os.remove(file_path)
                del DOWNLOADS[token]
            except Exception as e:
                app.logger.error(f"清理文件失败: {str(e)}")

def get_video_info(url):
    """获取视频信息,包括可用的格式"""
    ydl_opts = {
        'quiet': True,
        'no_warnings': True,
        'format': None,
        'youtube_include_dash_manifest': True,
        'format_sort': [
            'res:2160',  # 4K
            'res:1440',  # 2K
            'res:1080',  # 1080p
            'res:720',   # 720p
            'res:480',   # 480p
            'fps:60',    # 优先60fps
            'fps',       # 然后是其他fps
            'vcodec:h264',  # 优先H.264编码
            'vcodec:vp9',   # 然后是VP9
            'acodec'      # 最后是音频编码
        ]
    }
    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        try:
            info = ydl.extract_info(url, download=False)
            formats = []
            
            def safe_number(value, default=0):
                try:
                    return float(value or default)
                except (TypeError, ValueError):
                    return default

            # 处理视频格式
            for f in info.get('formats', []):
                vcodec = f.get('vcodec', 'none')
                acodec = f.get('acodec', 'none')
                has_video = vcodec != 'none'
                has_audio = acodec != 'none'
                
                height = safe_number(f.get('height', 0))
                width = safe_number(f.get('width', 0))
                fps = safe_number(f.get('fps', 0))
                tbr = safe_number(f.get('tbr', 0))
                
                if has_video:  # 只处理包含视频的格式
                    format_notes = []
                    
                    # 添加分辨率标签
                    if height >= 2160:
                        format_notes.append("4K")
                    elif height >= 1440:
                        format_notes.append("2K")
                    
                    # 详细的分辨率信息
                    if height and width:
                        format_notes.append(f"{width:.0f}x{height:.0f}p")
                    
                    # FPS信息
                    if fps > 0:
                        format_notes.append(f"{fps:.0f}fps")
                    
                    # 编码信息
                    if vcodec != 'none':
                        codec_name = {
                            'avc1': 'H.264',
                            'vp9': 'VP9',
                            'av01': 'AV1'
                        }.get(vcodec.split('.')[0], vcodec)
                        format_notes.append(f"Video: {codec_name}")
                    
                    # 比特率信息
                    if tbr > 0:
                        format_notes.append(f"{tbr:.0f}kbps")
                    
                    # 音频信息
                    if has_audio and acodec != 'none':
                        format_notes.append(f"Audio: {acodec}")
                    
                    format_data = {
                        'format_id': f.get('format_id', ''),
                        'ext': f.get('ext', ''),
                        'filesize': f.get('filesize', 0),
                        'format_note': ' - '.join(format_notes),
                        'vcodec': vcodec,
                        'acodec': acodec,
                        'height': height,
                        'width': width,
                        'fps': fps,
                        'resolution_sort': height * 1000 + fps
                    }
                    
                    if format_data['format_id']:
                        formats.append(format_data)
            
            # 按分辨率和FPS排序
            formats.sort(key=lambda x: x['resolution_sort'], reverse=True)
            
            # 移除重复的格式
            seen_resolutions = set()
            unique_formats = []
            for fmt in formats:
                res_key = f"{fmt['height']:.0f}p-{fmt['fps']:.0f}fps"
                if res_key not in seen_resolutions:
                    seen_resolutions.add(res_key)
                    unique_formats.append(fmt)
            
            return {
                'title': info.get('title', 'Unknown'),
                'duration': info.get('duration', 0),
                'thumbnail': info.get('thumbnail', ''),
                'formats': unique_formats,
                'description': info.get('description', ''),
                'channel': info.get('channel', 'Unknown'),
                'view_count': info.get('view_count', 0),
            }
        except Exception as e:
            app.logger.error(f"获取视频信息失败: {str(e)}")
            return {'error': str(e)}

def log_progress(d):
    if d['status'] == 'downloading':
        try:
            percent = d.get('_percent_str', 'N/A').strip()
            speed = d.get('_speed_str', 'N/A').strip()
            eta = d.get('_eta_str', 'N/A').strip()
            
            # 每5%记录一次进度
            if percent != 'N/A' and float(percent.rstrip('%')) % 5 < 1:
                app.logger.info(f"下载进度: {percent} | 速度: {speed} | 剩余时间: {eta}")
        except Exception:
            pass
    elif d['status'] == 'finished':
        app.logger.info("下载完成,开始处理文件...")

@app.route('/')
def index():
    """渲染主页"""
    return render_template('index.html')

@app.route('/api/info', methods=['POST'])
def get_info():
    """获取视频信息的API端点"""
    url = request.json.get('url')
    if not url:
        return jsonify({'error': 'URL is required'}), 400
    info = get_video_info(url)
    return jsonify(info)

@app.route('/api/download', methods=['POST'])
def download_video():
    """下载视频的API端点"""
    url = request.json.get('url')
    format_id = request.json.get('format_id')
    
    if not url or not format_id:
        app.logger.error('缺少URL或格式ID')
        return jsonify({'error': 'URL and format_id are required'}), 400
    
    try:
        cleanup_old_files()
        
        temp_file = os.path.join(TEMP_DIR, f'download_{time.time_ns()}')
        app.logger.info(f"创建临时文件: {os.path.basename(temp_file)}")
        
        ydl_opts = {
            'format': f'{format_id}+bestaudio/best',
            'outtmpl': temp_file + '.%(ext)s',
            'quiet': True,
            'merge_output_format': 'mp4',
            'postprocessors': [{
                'key': 'FFmpegVideoConvertor',
                'preferedformat': 'mp4',
            }],
            'prefer_ffmpeg': True,
            'keepvideo': False,
            'progress_hooks': [log_progress],
        }
        
        app.logger.info("开始下载视频...")
        
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(url, download=True)
            final_file = ydl.prepare_filename(info)
            filename = secure_filename(info['title'] + '.mp4')
            
            filesize = os.path.getsize(final_file)
            filesize_mb = filesize / (1024 * 1024)
            
            app.logger.info(f"下载完成: {filename} ({filesize_mb:.1f}MB)")
            
            download_token = os.urandom(16).hex()
            DOWNLOADS[download_token] = {
                'file_path': final_file,
                'filename': filename,
                'timestamp': time.time()
            }
            
            return jsonify({
                'status': 'success',
                'download_token': download_token,
                'filename': filename
            })
            
    except Exception as e:
        app.logger.error(f"下载失败: {str(e)}")
        return jsonify({'error': str(e)}), 500

@app.route('/api/get_file/<token>')
def get_file(token):
    """获取下载文件的API端点"""
    if token not in DOWNLOADS:
        app.logger.error("无效的下载令牌")
        return 'Invalid or expired download token', 400
        
    download_info = DOWNLOADS[token]
    file_path = download_info['file_path']
    filename = download_info['filename']
    
    if not os.path.exists(file_path):
        app.logger.error(f"文件未找到: {filename}")
        return 'File not found', 404
        
    try:
        filesize = os.path.getsize(file_path)
        filesize_mb = filesize / (1024 * 1024)
        app.logger.info(f"开始发送: {filename} ({filesize_mb:.1f}MB)")
        
        return send_file(
            file_path,
            as_attachment=True,
            download_name=filename,
            mimetype='video/mp4'
        )
    except Exception as e:
        app.logger.error(f"发送文件失败: {str(e)}")
        return str(e), 500
    finally:
        def cleanup():
            try:
                if token in DOWNLOADS:
                    os.remove(file_path)
                    del DOWNLOADS[token]
                    app.logger.info(f"临时文件已清理: {filename}")
            except Exception as e:
                app.logger.error(f"清理文件失败: {str(e)}")
                
        import threading
        threading.Timer(60, cleanup).start()

@app.route('/api/logs')
def get_logs():
    """获取日志的API端点"""
    logs = []
    temp_queue = queue.Queue()
    
    try:
        while not log_queue.empty():
            log = log_queue.get_nowait()
            logs.append(log)
            temp_queue.put(log)
        
        while not temp_queue.empty():
            log_queue.put(temp_queue.get_nowait())
            
        return jsonify(sorted(logs, key=lambda x: x['timestamp'], reverse=True))
    except Exception as e:
        app.logger.error(f"获取日志失败: {str(e)}")
        return jsonify([])

if __name__ == '__main__':
    # 确保临时目录存在
    os.makedirs(TEMP_DIR, exist_ok=True)
    # 启动时清理旧文件
    cleanup_old_files()
    # 运行应用
    app.run(host='0.0.0.0', port=9012, debug=True)

2. index.html

<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>YouTube Video Downloader</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <div class="container">
        <h1>YouTube Video Downloader</h1>
        <div class="input-group">
            <input type="text" id="url-input" placeholder="Enter YouTube URL">
            <button id="fetch-info">Get Video Info</button>
        </div>

        <div id="video-info" class="hidden">
            <div class="info-container">
                <img id="thumbnail" src="" alt="Video thumbnail">
                <div class="video-details">
                    <h2 id="video-title"></h2>
                    <p id="video-duration"></p>
                </div>
            </div>

            <div class="formats-container">
                <h3>Available Formats</h3>
                <div id="format-list"></div>
            </div>
        </div>

        <div id="status" class="hidden"></div>

        <!-- 日志显示区域 -->
        <div class="log-container">
            <div class="log-header">
                <h3>Operation Logs</h3>
                <button id="clear-logs" title="Clear logs">Clear</button>
                <label class="auto-scroll">
                    <input type="checkbox" id="auto-scroll" checked>
                    Auto-scroll
                </label>
            </div>
            <div id="log-display"></div>
        </div>
    </div>

    <script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>

3. style.css

有了 AI 后, style 产生得太简单

/* static/css/style.css */
body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 20px;
    background-color: #f5f5f5;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    background-color: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h1 {
    text-align: center;
    color: #333;
    margin-bottom: 20px;
}

.input-group {
    display: flex;
    gap: 10px;
    margin-bottom: 20px;
}

input[type="text"] {
    flex: 1;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 16px;
}

button {
    padding: 10px 20px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 16px;
}

button:hover {
    background-color: #0056b3;
}

.hidden {
    display: none;
}

.info-container {
    display: flex;
    gap: 20px;
    margin-bottom: 20px;
    padding: 15px;
    background-color: #f8f9fa;
    border-radius: 4px;
}

#thumbnail {
    max-width: 200px;
    border-radius: 4px;
}

.video-details {
    flex: 1;
}

.video-details h2 {
    margin: 0 0 10px 0;
    color: #333;
}

.formats-container {
    border-top: 1px solid #ddd;
    padding-top: 20px;
}

#format-list {
    display: grid;
    gap: 10px;
}

.format-item {
    padding: 10px;
    background-color: #f8f9fa;
    border-radius: 4px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

#status {
    margin: 20px 0;
    padding: 10px;
    border-radius: 4px;
    text-align: center;
}

#status.success {
    background-color: #d4edda;
    color: #155724;
}

#status.error {
    background-color: #f8d7da;
    color: #721c24;
}

/* 日志容器样式 */
.log-container {
    margin-top: 20px;
    border: 1px solid #ddd;
    border-radius: 4px;
    background-color: #1e1e1e;
}

.log-header {
    padding: 10px;
    background-color: #2d2d2d;
    border-bottom: 1px solid #444;
    display: flex;
    align-items: center;
    gap: 10px;
}

.log-header h3 {
    margin: 0;
    flex-grow: 1;
    color: #fff;
}

.auto-scroll {
    display: flex;
    align-items: center;
    gap: 5px;
    font-size: 14px;
    color: #fff;
}

#clear-logs {
    padding: 5px 10px;
    background-color: #6c757d;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

#clear-logs:hover {
    background-color: #5a6268;
}

#log-display {
    height: 300px;
    overflow-y: auto;
    padding: 10px;
    font-family: 'Consolas', 'Monaco', monospace;
    font-size: 13px;
    line-height: 1.4;
    background-color: #1e1e1e;
    color: #d4d4d4;
}

.log-entry {
    margin: 2px 0;
    padding: 2px 5px;
    border-radius: 2px;
    white-space: pre-wrap;
    word-wrap: break-word;
}

.log-timestamp {
    color: #888;
    margin-right: 8px;
    font-size: 0.9em;
}

.log-info {
    color: #89d4ff;
}

.log-error {
    color: #ff8989;
}

.log-warning {
    color: #ffd700;
}

/* 滚动条样式 */
#log-display::-webkit-scrollbar {
    width: 8px;
}

#log-display::-webkit-scrollbar-track {
    background: #2d2d2d;
}

#log-display::-webkit-scrollbar-thumb {
    background: #888;
    border-radius: 4px;
}

#log-display::-webkit-scrollbar-thumb:hover {
    background: #555;
}

4. script.js

// static/js/script.js
document.addEventListener('DOMContentLoaded', function() {
    const urlInput = document.getElementById('url-input');
    const fetchButton = document.getElementById('fetch-info');
    const videoInfo = document.getElementById('video-info');
    const thumbnail = document.getElementById('thumbnail');
    const videoTitle = document.getElementById('video-title');
    const videoDuration = document.getElementById('video-duration');
    const formatList = document.getElementById('format-list');
    const status = document.getElementById('status');

    // 日志系统
    class Logger {
        constructor() {
            this.logDisplay = document.getElementById('log-display');
            this.autoScrollCheckbox = document.getElementById('auto-scroll');
            this.clearLogsButton = document.getElementById('clear-logs');
            this.lastLogTimestamp = null;
            this.setupEventListeners();
        }

        setupEventListeners() {
            this.clearLogsButton.addEventListener('click', () => this.clearLogs());
            this.startLogPolling();
        }

        formatTimestamp(isoString) {
            const date = new Date(isoString);
            return date.toLocaleTimeString('en-US', { 
                hour12: false,
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                fractionalSecondDigits: 3
            });
        }

        addLogEntry(entry) {
            const logEntry = document.createElement('div');
            logEntry.classList.add('log-entry');
            
            if (entry.level === 'error') {
                logEntry.classList.add('log-error');
            } else if (entry.level === 'warning') {
                logEntry.classList.add('log-warning');
            } else {
                logEntry.classList.add('log-info');
            }

            const timestamp = document.createElement('span');
            timestamp.classList.add('log-timestamp');
            timestamp.textContent = this.formatTimestamp(entry.timestamp);
            
            const message = document.createElement('span');
            message.classList.add('log-message');
            message.textContent = entry.message;

            logEntry.appendChild(timestamp);
            logEntry.appendChild(message);
            
            this.logDisplay.appendChild(logEntry);

            if (this.autoScrollCheckbox.checked) {
                this.scrollToBottom();
            }
        }

        clearLogs() {
            this.logDisplay.innerHTML = '';
            this.lastLogTimestamp = null;
        }

        scrollToBottom() {
            this.logDisplay.scrollTop = this.logDisplay.scrollHeight;
        }

        async fetchLogs() {
            try {
                const response = await fetch('/api/logs');
                const logs = await response.json();
                
                const newLogs = this.lastLogTimestamp 
                    ? logs.filter(log => log.timestamp > this.lastLogTimestamp)
                    : logs;

                if (newLogs.length > 0) {
                    newLogs.forEach(log => this.addLogEntry(log));
                    this.lastLogTimestamp = logs[0].timestamp;
                }
            } catch (error) {
                console.error('Failed to fetch logs:', error);
            }
        }

        startLogPolling() {
            setInterval(() => this.fetchLogs(), 500);
        }
    }

    // 初始化日志系统
    const logger = new Logger();

    function formatDuration(seconds) {
        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const remainingSeconds = seconds % 60;
        
        if (hours > 0) {
            return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
        }
        return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
    }

    function formatFileSize(bytes) {
        if (!bytes) return 'Unknown size';
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(1024));
        return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
    }

    function showStatus(message, isError = false) {
        status.textContent = message;
        status.className = isError ? 'error' : 'success';
        status.classList.remove('hidden');
    }

    async function downloadVideo(url, formatId) {
        try {
            logger.addLogEntry({
                timestamp: new Date().toISOString(),
                level: 'info',
                message: `Starting download preparation for format: ${formatId}`
            });
            
            showStatus('Preparing download...');
            
            const response = await fetch('/api/download', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ url, format_id: formatId })
            });
            
            const data = await response.json();
            
            if (response.ok && data.download_token) {
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'success',
                    message: `Download token received: ${data.download_token}`
                });
                
                showStatus('Starting download...');
                
                const iframe = document.createElement('iframe');
                iframe.style.display = 'none';
                iframe.src = `/api/get_file/${data.download_token}`;
                
                iframe.onload = () => {
                    logger.addLogEntry({
                        timestamp: new Date().toISOString(),
                        level: 'success',
                        message: `Download started for: ${data.filename}`
                    });
                    showStatus('Download started! Check your browser downloads.');
                    setTimeout(() => document.body.removeChild(iframe), 5000);
                };
                
                iframe.onerror = () => {
                    logger.addLogEntry({
                        timestamp: new Date().toISOString(),
                        level: 'error',
                        message: 'Download failed to start'
                    });
                    showStatus('Download failed. Please try again.', true);
                    document.body.removeChild(iframe);
                };
                
                document.body.appendChild(iframe);
            } else {
                const errorMessage = data.error || 'Download failed';
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'error',
                    message: `Download failed: ${errorMessage}`
                });
                showStatus(errorMessage, true);
            }
        } catch (error) {
            logger.addLogEntry({
                timestamp: new Date().toISOString(),
                level: 'error',
                message: `Network error: ${error.message}`
            });
            showStatus('Network error occurred', true);
            console.error(error);
        }
    }

    fetchButton.addEventListener('click', async () => {
        const url = urlInput.value.trim();
        
        if (!url) {
            showStatus('Please enter a valid URL', true);
            return;
        }

        showStatus('Fetching video information...');

        try {
            const response = await fetch('/api/info', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ url })
            });
            
            const data = await response.json();
            
            if (response.ok) {
                thumbnail.src = data.thumbnail;
                videoTitle.textContent = data.title;
                videoDuration.textContent = formatDuration(data.duration);
                
                formatList.innerHTML = data.formats
                    .filter(format => format.format_id && format.ext)
                    .map(format => `
                        <div class="format-item">
                            <span>${format.format_note} (${format.ext}) - ${formatFileSize(format.filesize)}</span>
                            <button onclick="downloadVideo('${url}', '${format.format_id}')">Download</button>
                        </div>
                    `)
                    .join('');
                
                    videoInfo.classList.remove('hidden');
                    status.classList.add('hidden');
                    
                    logger.addLogEntry({
                        timestamp: new Date().toISOString(),
                        level: 'info',
                        message: `Video information retrieved: ${data.title}`
                    });
                } else {
                    showStatus(data.error || 'Failed to fetch video info', true);
                    logger.addLogEntry({
                        timestamp: new Date().toISOString(),
                        level: 'error',
                        message: `Failed to fetch video info: ${data.error || 'Unknown error'}`
                    });
                }
            } catch (error) {
                showStatus('Network error occurred', true);
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'error',
                    message: `Network error: ${error.message}`
                });
            }
        });
    
        window.downloadVideo = downloadVideo;
    
        // 支持回车键触发获取视频信息
        urlInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                fetchButton.click();
            }
        });
    });

以上文件放到相应目录,库文件参考 requirements.txt 即可。

Docker 部署

1. Dockerfile

FROM python:3.11-slim

WORKDIR /app

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    ffmpeg \
    && rm -rf /var/lib/apt/lists/*

COPY app.py ./
COPY static/css/style.css ./static/css/
COPY static/js/script.js ./static/js/
COPY templates/index.html ./templates/

RUN pip install --no-cache-dir \
    flask \
    yt-dlp \
    werkzeug

RUN mkdir -p /app/temp_downloads && \
    chmod 777 /app/temp_downloads

ENV FLASK_APP=app.py
ENV PYTHONUNBUFFERED=1
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_RUN_PORT=9012

EXPOSE 9012

CMD ["python", "-c", "from app import app; app.run(host='0.0.0.0', port=9012)"]

2. requirements.txt

flask
Werkzeug==3.0.1
yt-dlp==2024.3.10
gunicorn==21.2.0

如果你使用这个 .txt, 可以去掉版本号。我指定版本号,是因我 NAS 的 wheel files 存有多个版本

3. 创建 Image 与 Container

# docker build -t yt-dlp .
# docker run -d -p 9012:9012 --name yt-dlp_container yt-dlp

我使用了与 Github 上面项目的相同名字,只是为了方便,字少。

注:在 docker 命令中没有 加入 --restart always, 要编辑一下容器自己添加。

总结:

yt-dlp 是一个功能超强的工具,可以用 cookie file获取身份认证来下载视频,或通过 Mozila 浏览器直接获得 cookie 内容(只是说明上这么说,我没试过)。 Douyin 有 bug 不能下载 , 其它网站没有试。

我有订阅 youtube ,这个工具只是娱乐,或下载 民国 及以前的,其版权已经放弃的影像内容。

请尊重版权


原文地址:https://blog.csdn.net/davenian/article/details/143587587

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!