介绍 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/
' ) - 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
升级 yt-dlp
进入 container 后,运行以下命令:
python3 -m pip install -U --pre "yt-dlp[default]"
评论记录:
回复评论: