<Project-20 YT-DLP> 视频下载工具 yt-dlp/yt-dlp 加个页面 可下载视频、视频字幕、自动粘贴URL 自动更新 yt-dlp on 12Apr25

介绍 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
import socket

app = Flask(__name__)

# Configure maximum content length (1GB)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024

# Create fixed temp directory
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)

# Store download information
DOWNLOADS = {}

# Create log queue
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:
            # Filter out Werkzeug's regular access logs
            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

            # Clean message format
            msg = self.format(record)
            if record.name == 'app':
                # Remove "INFO:app:" etc. prefix
                msg = msg.split(' - ')[-1]
            
            log_entry = {
                'timestamp': datetime.fromtimestamp(record.created).isoformat(),
                'message': msg,
                'level': record.levelname.lower(),
                'logger': record.name
            }
            
            # Remove oldest log if queue is full
            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}")

# Configure log format
log_formatter = logging.Formatter('%(message)s')

# Configure queue handler
queue_handler = QueueHandler(log_queue)
queue_handler.setFormatter(log_formatter)

# Configure console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(log_formatter)

# Configure Flask logger
app.logger.handlers = []
app.logger.addHandler(queue_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(logging.INFO)

# Werkzeug logger only outputs errors
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = []
werkzeug_logger.addHandler(console_handler)
werkzeug_logger.setLevel(logging.WARNING)

# Language code mappings
LANGUAGE_CODES = {
    'English': 'en',
    'English (Auto-generated)': 'en',
    'Simplified Chinese': 'zh-Hans',
    'Simplified Chinese (Auto-generated)': 'zh-Hans',
    'Traditional Chinese': 'zh-Hant',
    'Traditional Chinese (Auto-generated)': 'zh-Hant'
}

def get_language_display(lang):
    lang_map = {
        'en': 'English',
        'zh': 'Chinese',
        'zh-Hans': 'Simplified Chinese',
        'zh-Hant': 'Traditional Chinese',
        'zh-CN': 'Simplified Chinese',
        'zh-TW': 'Traditional Chinese'
    }
    return lang_map.get(lang, lang)

def get_video_info(url):
    """Get video information including available formats and subtitles"""
    ydl_opts = {
        'quiet': True,
        'no_warnings': True,
        'format': None,
        'youtube_include_dash_manifest': True,
        'writesubtitles': True,
        'allsubtitles': True,
        'writeautomaticsub': True,
        'format_sort': [
            'res:2160',
            'res:1440',
            'res:1080',
            'res:720',
            'res:480',
            'fps:60',
            'fps',
            'vcodec:h264',
            'vcodec: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

            # Process video formats
            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")
                    
                    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)
            
            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)

            # Process subtitles
            subtitles = []
            seen_languages = set()
            allowed_languages = {'en', 'zh', 'zh-Hans', 'zh-Hant', 'zh-CN', 'zh-TW'}
            
            # Process regular subtitles
            for lang, subs in info.get('subtitles', {}).items():
                if lang in allowed_languages:
                    display_lang = get_language_display(lang)
                    if display_lang not in seen_languages:
                        seen_languages.add(display_lang)
                        if subs:
                            subtitles.append({
                                'language': display_lang,
                                'language_code': lang,
                                'format': subs[0].get('ext', ''),
                                'url': subs[0].get('url', ''),
                                'auto_generated': False
                            })
            
            # Process auto-generated subtitles
            for lang, subs in info.get('automatic_captions', {}).items():
                if lang in allowed_languages:
                    display_lang = f'{get_language_display(lang)} (Auto-generated)'
                    if display_lang not in seen_languages:
                        seen_languages.add(display_lang)
                        if subs:
                            subtitles.append({
                                'language': display_lang,
                                'language_code': lang,
                                'format': subs[0].get('ext', ''),
                                'url': subs[0].get('url', ''),
                                'auto_generated': True
                            })

            app.logger.info(f"Found {len(subtitles)} unique subtitle tracks (Chinese and English only)")
            
            return {
                'title': info.get('title', 'Unknown'),
                'duration': info.get('duration', 0),
                'thumbnail': info.get('thumbnail', ''),
                'formats': unique_formats,
                'subtitles': subtitles,
                'description': info.get('description', ''),
                'channel': info.get('channel', 'Unknown'),
                'view_count': info.get('view_count', 0),
            }
        except Exception as e:
            app.logger.error(f"Failed to get video info: {str(e)}")
            return {'error': str(e)}

def cleanup_old_files():
    """Clean up temporary files older than 10 minutes"""
    current_time = time.time()
    for token, info in list(DOWNLOADS.items()):
        if current_time - info['timestamp'] > 600:
            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"Failed to clean up file: {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()
            
            if percent != 'N/A' and float(percent.rstrip('%')) % 5 < 1:
                app.logger.info(f"Download progress: {percent} | Speed: {speed} | ETA: {eta}")
        except Exception:
            pass
    elif d['status'] == 'finished':
        app.logger.info("Download complete, processing file...")

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/api/info', methods=['POST'])
def get_info():
    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_subtitle', methods=['POST'])
def download_subtitle():
    url = request.json.get('url')
    display_language = request.json.get('language')
    
    lang = LANGUAGE_CODES.get(display_language)
    
    if not url or not lang:
        app.logger.error(f'Missing URL or invalid language: {display_language}')
        return jsonify({'error': 'URL and valid language are required'}), 400
        
    try:
        cleanup_old_files()
        
        temp_file = os.path.join(TEMP_DIR, f'subtitle_{time.time_ns()}')
        app.logger.info(f"Creating subtitle temp file: {os.path.basename(temp_file)}")
        
        ydl_opts = {
            'quiet': True,
            'writesubtitles': True,
            'writeautomaticsub': True,
            'subtitleslangs': [lang],
            'skip_download': True,
            'outtmpl': temp_file
        }
        
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(url, download=True)
            subtitle_file = f"{temp_file}.{lang}.vtt"
            
            if not os.path.exists(subtitle_file):
                return jsonify({'error': f'No subtitles found for language: {lang}'}), 404
                
            download_token = os.urandom(16).hex()
            DOWNLOADS[download_token] = {
                'file_path': subtitle_file,
                'filename': f"{secure_filename(info['title'])}.{lang}.vtt",
                'timestamp': time.time()
            }
            
            return jsonify({
                'status': 'success',
                'download_token': download_token,
                'filename': f"{info['title']}.{lang}.vtt"
            })
            
    except Exception as e:
        app.logger.error(f"Subtitle download failed: {str(e)}")
        return jsonify({'error': str(e)}), 500

@app.route('/api/download', methods=['POST'])
def download_video():
    url = request.json.get('url')
    format_id = request.json.get('format_id')
    
    if not url or not format_id:
        app.logger.error('Missing URL or format 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"Creating temp file: {os.path.basename(temp_file)}")
        
        ydl_opts = {
            'format': f'{format_id}+bestaudio[ext=m4a]/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("Starting video download...")
        
        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"Download complete: {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"Download failed: {str(e)}")
        return jsonify({'error': str(e)}), 500

@app.route('/api/get_file/<token>')
def get_file(token):
    """Get downloaded file API endpoint"""
    if token not in DOWNLOADS:
        app.logger.error("Invalid download token")
        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"File not found: {filename}")
        return 'File not found', 404
        
    try:
        filesize = os.path.getsize(file_path)
        filesize_mb = filesize / (1024 * 1024)
        app.logger.info(f"Starting file transfer: {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"File transfer failed: {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"Temp file cleaned up: {filename}")
            except Exception as e:
                app.logger.error(f"Failed to clean up file: {str(e)}")
                
        import threading
        threading.Timer(60, cleanup).start()

@app.route('/api/logs')
def get_logs():
    """Get logs API endpoint"""
    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"Failed to get logs: {str(e)}")
        return jsonify([])

def get_local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return '127.0.0.1'

if __name__ == '__main__':
    # Ensure temp directory exists
    os.makedirs(TEMP_DIR, exist_ok=True)
    # Clean up old files on startup
    cleanup_old_files()

    # Get local IP and set port
    local_ip = get_local_ip()
    port = 9012

    # Print access information
    print("\n" + "="*50)
    print("YouTube Downloader is running!")
    print("="*50)
    print("\nAccess URLs:")
    print("-"*20)
    print("Local computer:")
    print(f"→ http://localhost:{port}")
    print(f"→ http://127.0.0.1:{port}")
    print("\nFrom other computers on your network:")
    print(f"→ http://{local_ip}:{port}")
    print("\n" + "="*50 + "\n")

    # Run the application
    app.run(host='0.0.0.0', port=port, debug=True)

2. 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="/static/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" alt="Video thumbnail">
                <div class="video-details">
                    <h2 id="video-title"></h2>
                    <p>Duration: <span id="video-duration"></span></p>
                </div>
            </div>

            <div class="formats-container">
                <h3>Available Formats</h3>
                <div id="format-list"></div>
                
                <!-- 新增字幕部分 -->
                <div id="subtitle-section">
                    <h3>Available Subtitles</h3>
                    <div id="subtitle-list"></div>
                </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">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="/static/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;
}
#subtitle-list {
    margin-top: 20px;
    border-top: 1px solid #ddd;
    padding-top: 20px;
}

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

#subtitle-section {
    margin-top: 20px;
    padding-top: 20px;
    border-top: 1px solid #ddd;
}

#subtitle-list {
    display: grid;
    gap: 10px;
    margin-top: 10px;
}

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

.subtitle-item button {
    padding: 5px 10px;
    background-color: #28a745;
}

.subtitle-item button:hover {
    background-color: #218838;
}

4. 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');

    // YouTube URL validation pattern
    const urlPattern = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)[a-zA-Z0-9_-]+/;

    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');
    }

    function displaySubtitles(subtitles, url) {
        // Remove existing subtitle list if it exists
        const existingSubtitleList = document.getElementById('subtitle-list');
        if (existingSubtitleList) {
            existingSubtitleList.remove();
        }
    
        // Create a map to store unique subtitles by language
        const uniqueSubtitles = new Map();
        subtitles.forEach(sub => {
            if (!uniqueSubtitles.has(sub.language)) {
                uniqueSubtitles.set(sub.language, sub);
            }
        });
    
        const subtitleList = document.createElement('div');
        subtitleList.id = 'subtitle-list';
        subtitleList.innerHTML = `
            <h3>Available Subtitles</h3>
            ${Array.from(uniqueSubtitles.values()).map(sub => `
                <div class="subtitle-item">
                    <span>${sub.language}</span>
                    <button onclick="downloadSubtitle('${url}', '${sub.language}')">
                        Download
                    </button>
                </div>
            `).join('')}
        `;
        document.querySelector('.formats-container').appendChild(subtitleList);
    }

    async function tryPasteFromClipboard() {
        try {
            const clipboardText = await navigator.clipboard.readText();
            
            if (urlPattern.test(clipboardText)) {
                urlInput.value = clipboardText;
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'info',
                    message: 'YouTube URL automatically pasted from clipboard'
                });
                return true;
            } else if (clipboardText.trim()) {
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'warning',
                    message: 'Clipboard content is not a valid YouTube URL'
                });
            }
        } catch (err) {
            logger.addLogEntry({
                timestamp: new Date().toISOString(),
                level: 'warning',
                message: 'Could not access clipboard: ' + err.message
            });
        }
        return false;
    }

    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);
        }
    }

    async function downloadSubtitle(url, language) {
        try {
            logger.addLogEntry({
                timestamp: new Date().toISOString(),
                level: 'info',
                message: `Starting subtitle download for language: ${language}`
            });
            
            showStatus('Preparing subtitle download...');
            
            const response = await fetch('/api/download_subtitle', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ url, language })
            });
            
            const data = await response.json();
            
            if (response.ok && data.download_token) {
                window.location.href = `/api/get_file/${data.download_token}`;
                showStatus('Subtitle download started!');
            } else {
                const errorMessage = data.error || 'Subtitle download failed';
                logger.addLogEntry({
                    timestamp: new Date().toISOString(),
                    level: 'error',
                    message: 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 () => {
        // Clear existing video info
        formatList.innerHTML = '';
        videoTitle.textContent = '';
        videoDuration.textContent = '';
        thumbnail.src = '';
        videoInfo.classList.add('hidden');
        
        await tryPasteFromClipboard();
        
        const url = urlInput.value.trim();
        if (!url) {
            showStatus('Please enter a valid URL', true);
            return;
        }
        
        if (!urlPattern.test(url)) {
            showStatus('Please enter a valid YouTube 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}`
                });

                if (data.subtitles && data.subtitles.length > 0) {
                    displaySubtitles(data.subtitles, url);
                }
            } 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}`
            });
            console.error(error);
        }
    });

    urlInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') {
            fetchButton.click();
        }
    });

    window.downloadVideo = downloadVideo;
    window.downloadSubtitle = downloadSubtitle;
});

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

Docker 部署

1. Dockerfile

# Use Python 3.12 slim as base image for smaller size
FROM python:3.12-slim

# Set working directory
WORKDIR /app

# Install system dependencies including FFmpeg
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    ffmpeg \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Copy application files
COPY app.py ./
COPY static/css/style.css ./static/css/
COPY static/js/script.js ./static/js/
COPY templates/index.html ./templates/
COPY requirements.txt ./

# Copy SSL certificates
COPY cert.pem key.pem ./

# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Create directories
RUN mkdir -p /app/temp_downloads && \
    mkdir -p /app/config && \
    chmod 777 /app/temp_downloads /app/config

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

# Expose HTTPS port
EXPOSE 9012

# Create non-root user for security
RUN useradd -m appuser && \
    chown -R appuser:appuser /app && \
    chmod 600 /app/key.pem /app/cert.pem
# USER appuser

# Run the application with SSL
CMD ["python", "app.py"]



# How to upgrade the yt-dlp package
# python3 -m pip install -U --pre "yt-dlp[default]"

2. requirements.txt

Flask==3.0.0
yt-dlp==2023.11.16
Werkzeug==3.0.1
packaging==23.2
setuptools==69.0.2

如果你使用这个 .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 ,这个工具只是娱乐,或下载 民国 及以前的,其版权已经放弃的影像内容。

请尊重版权

on 2Dec.2024  updated 4 files. 曾加字幕下载,点击自动粘贴URL

on 6FEB.2025 added how to upgrade yt-dlp

on 12APR.2025 added crontab for auto upgrade yt-dlp

升级 yt-dlp

Option 1:

进入 container 后,运行以下命令:

python3 -m pip install -U --pre "yt-dlp[default]"

每次都要进入 container 去运行上面安装。

Option 2:

使用 crontab 自动运行

1. 准备必要安装包:cron、vi 

apt update && apt install cron vim

2. 添加环境变量

echo "export EDITOR=vi" >> ~/.bashrc

3. 制作运行脚本 /app/update_ytdlp.sh

vi /app/update_ytdlp.sh
#!/bin/bash
# Script to update yt-dlp weekly
echo "Starting yt-dlp update at $(date)"
python3 -m pip install -U --pre "yt-dlp[default]"
echo "Update completed at $(date)"
chmod +x /app/update_ytdlp.sh

4. 添加记录到 crontab

echo "0 3 * * 0 /app/update_ytdlp.sh >> /var/log/ytdlp_update.log 2>&1" > /tmp/root-crontab

每周日,凌晨3点运行脚本, 日志记录保存到:/var/log/ytdlp_update.log

可选 5. 新的 Dockerfile 支持 crontab   (这个是AI生成)

FROM python:3.12-slim

# Install system dependencies
RUN apt-get update && apt-get install -y \
    ffmpeg \
    cron \
    && rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Copy application files
COPY app.py index.html static/css/style.css static/js/script.js /app/
COPY update_ytdlp.sh /app/

# Make the update script executable
RUN chmod +x /app/update_ytdlp.sh

# Set up directory structure
RUN mkdir -p /app/static/css /app/static/js /app/temp_downloads

# Move files to correct locations
RUN mv style.css /app/static/css/ && \
    mv script.js /app/static/js/

# Install Python dependencies
RUN pip install --no-cache-dir flask yt-dlp werkzeug

# Set up crontab to run the update script every Sunday at 3 AM
RUN echo "0 3 * * 0 /app/update_ytdlp.sh >> /var/log/cron.log 2>&1" > /etc/cron.d/ytdlp-update && \
    chmod 0644 /etc/cron.d/ytdlp-update && \
    crontab /etc/cron.d/ytdlp-update

# Create a simple self-signed SSL certificate for HTTPS
RUN pip install pyopenssl && \
    python -c "from OpenSSL import crypto; \
    key = crypto.PKey(); \
    key.generate_key(crypto.TYPE_RSA, 2048); \
    cert = crypto.X509(); \
    cert.get_subject().CN = 'localhost'; \
    cert.set_serial_number(1000); \
    cert.gmtime_adj_notBefore(0); \
    cert.gmtime_adj_notAfter(10*365*24*60*60); \
    cert.set_issuer(cert.get_subject()); \
    cert.set_pubkey(key); \
    cert.sign(key, 'sha256'); \
    open('/app/cert.pem', 'wb').write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)); \
    open('/app/key.pem', 'wb').write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))"

# Create log file for cron
RUN touch /var/log/cron.log

# Expose the default port
EXPOSE 9012

# Create entrypoint script
RUN echo '#!/bin/bash\n\
# Start cron service\n\
service cron start\n\
echo "Cron service started"\n\
\n\
# Run the Flask application\n\
exec python /app/app.py\n\
' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh

# Set the entrypoint
ENTRYPOINT ["/app/entrypoint.sh"]

上面这个超出我的现有知识,仅供参考。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值