介绍 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"]
上面这个超出我的现有知识,仅供参考。