首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

<Project-20 YT-DLP> 给视频网站下载工具 yt-dlp/yt-dlp 加个页面 python web v1.1 added subtitle , auto paste

  • 25-02-19 10:20
  • 2417
  • 7180
blog.csdn.net

介绍 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~ 

界面

目录结构

  1. 20.YT-DLP/
  2. ├── Dockerfile
  3. ├── app.py
  4. ├── static/
  5. │ ├── css/
  6. │ │ └── style.css
  7. │ └── js/
  8. │ └── script.js
  9. ├── templates/
  10. │ └── index.html
  11. └── temp_downloads/

完整代码

1. app.py

  1. # app.py
  2. from flask import Flask, render_template, request, jsonify, send_file
  3. import yt_dlp
  4. import os
  5. import shutil
  6. from werkzeug.utils import secure_filename
  7. import time
  8. import logging
  9. import queue
  10. from datetime import datetime
  11. import sys
  12. import socket
  13. app = Flask(__name__)
  14. # Configure maximum content length (1GB)
  15. app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024
  16. # Create fixed temp directory
  17. TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_downloads')
  18. if not os.path.exists(TEMP_DIR):
  19. os.makedirs(TEMP_DIR)
  20. # Store download information
  21. DOWNLOADS = {}
  22. # Create log queue
  23. log_queue = queue.Queue(maxsize=1000)
  24. class QueueHandler(logging.Handler):
  25. def __init__(self, log_queue):
  26. super().__init__()
  27. self.log_queue = log_queue
  28. def emit(self, record):
  29. try:
  30. # Filter out Werkzeug's regular access logs
  31. if record.name == 'werkzeug' and any(x in record.getMessage() for x in [
  32. '127.0.0.1',
  33. 'GET /api/logs',
  34. 'GET /static/',
  35. '"GET / HTTP/1.1"'
  36. ]):
  37. return
  38. # Clean message format
  39. msg = self.format(record)
  40. if record.name == 'app':
  41. # Remove "INFO:app:" etc. prefix
  42. msg = msg.split(' - ')[-1]
  43. log_entry = {
  44. 'timestamp': datetime.fromtimestamp(record.created).isoformat(),
  45. 'message': msg,
  46. 'level': record.levelname.lower(),
  47. 'logger': record.name
  48. }
  49. # Remove oldest log if queue is full
  50. if self.log_queue.full():
  51. try:
  52. self.log_queue.get_nowait()
  53. except queue.Empty:
  54. pass
  55. self.log_queue.put(log_entry)
  56. except Exception as e:
  57. print(f"Error in QueueHandler: {e}")
  58. # Configure log format
  59. log_formatter = logging.Formatter('%(message)s')
  60. # Configure queue handler
  61. queue_handler = QueueHandler(log_queue)
  62. queue_handler.setFormatter(log_formatter)
  63. # Configure console handler
  64. console_handler = logging.StreamHandler(sys.stdout)
  65. console_handler.setFormatter(log_formatter)
  66. # Configure Flask logger
  67. app.logger.handlers = []
  68. app.logger.addHandler(queue_handler)
  69. app.logger.addHandler(console_handler)
  70. app.logger.setLevel(logging.INFO)
  71. # Werkzeug logger only outputs errors
  72. werkzeug_logger = logging.getLogger('werkzeug')
  73. werkzeug_logger.handlers = []
  74. werkzeug_logger.addHandler(console_handler)
  75. werkzeug_logger.setLevel(logging.WARNING)
  76. # Language code mappings
  77. LANGUAGE_CODES = {
  78. 'English': 'en',
  79. 'English (Auto-generated)': 'en',
  80. 'Simplified Chinese': 'zh-Hans',
  81. 'Simplified Chinese (Auto-generated)': 'zh-Hans',
  82. 'Traditional Chinese': 'zh-Hant',
  83. 'Traditional Chinese (Auto-generated)': 'zh-Hant'
  84. }
  85. def get_language_display(lang):
  86. lang_map = {
  87. 'en': 'English',
  88. 'zh': 'Chinese',
  89. 'zh-Hans': 'Simplified Chinese',
  90. 'zh-Hant': 'Traditional Chinese',
  91. 'zh-CN': 'Simplified Chinese',
  92. 'zh-TW': 'Traditional Chinese'
  93. }
  94. return lang_map.get(lang, lang)
  95. def get_video_info(url):
  96. """Get video information including available formats and subtitles"""
  97. ydl_opts = {
  98. 'quiet': True,
  99. 'no_warnings': True,
  100. 'format': None,
  101. 'youtube_include_dash_manifest': True,
  102. 'writesubtitles': True,
  103. 'allsubtitles': True,
  104. 'writeautomaticsub': True,
  105. 'format_sort': [
  106. 'res:2160',
  107. 'res:1440',
  108. 'res:1080',
  109. 'res:720',
  110. 'res:480',
  111. 'fps:60',
  112. 'fps',
  113. 'vcodec:h264',
  114. 'vcodec:vp9',
  115. 'acodec'
  116. ]
  117. }
  118. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  119. try:
  120. info = ydl.extract_info(url, download=False)
  121. formats = []
  122. def safe_number(value, default=0):
  123. try:
  124. return float(value or default)
  125. except (TypeError, ValueError):
  126. return default
  127. # Process video formats
  128. for f in info.get('formats', []):
  129. vcodec = f.get('vcodec', 'none')
  130. acodec = f.get('acodec', 'none')
  131. has_video = vcodec != 'none'
  132. has_audio = acodec != 'none'
  133. height = safe_number(f.get('height', 0))
  134. width = safe_number(f.get('width', 0))
  135. fps = safe_number(f.get('fps', 0))
  136. tbr = safe_number(f.get('tbr', 0))
  137. if has_video:
  138. format_notes = []
  139. if height >= 2160:
  140. format_notes.append("4K")
  141. elif height >= 1440:
  142. format_notes.append("2K")
  143. if height and width:
  144. format_notes.append(f"{width:.0f}x{height:.0f}p")
  145. if fps > 0:
  146. format_notes.append(f"{fps:.0f}fps")
  147. if vcodec != 'none':
  148. codec_name = {
  149. 'avc1': 'H.264',
  150. 'vp9': 'VP9',
  151. 'av01': 'AV1'
  152. }.get(vcodec.split('.')[0], vcodec)
  153. format_notes.append(f"Video: {codec_name}")
  154. if tbr > 0:
  155. format_notes.append(f"{tbr:.0f}kbps")
  156. if has_audio and acodec != 'none':
  157. format_notes.append(f"Audio: {acodec}")
  158. format_data = {
  159. 'format_id': f.get('format_id', ''),
  160. 'ext': f.get('ext', ''),
  161. 'filesize': f.get('filesize', 0),
  162. 'format_note': ' - '.join(format_notes),
  163. 'vcodec': vcodec,
  164. 'acodec': acodec,
  165. 'height': height,
  166. 'width': width,
  167. 'fps': fps,
  168. 'resolution_sort': height * 1000 + fps
  169. }
  170. if format_data['format_id']:
  171. formats.append(format_data)
  172. formats.sort(key=lambda x: x['resolution_sort'], reverse=True)
  173. seen_resolutions = set()
  174. unique_formats = []
  175. for fmt in formats:
  176. res_key = f"{fmt['height']:.0f}p-{fmt['fps']:.0f}fps"
  177. if res_key not in seen_resolutions:
  178. seen_resolutions.add(res_key)
  179. unique_formats.append(fmt)
  180. # Process subtitles
  181. subtitles = []
  182. seen_languages = set()
  183. allowed_languages = {'en', 'zh', 'zh-Hans', 'zh-Hant', 'zh-CN', 'zh-TW'}
  184. # Process regular subtitles
  185. for lang, subs in info.get('subtitles', {}).items():
  186. if lang in allowed_languages:
  187. display_lang = get_language_display(lang)
  188. if display_lang not in seen_languages:
  189. seen_languages.add(display_lang)
  190. if subs:
  191. subtitles.append({
  192. 'language': display_lang,
  193. 'language_code': lang,
  194. 'format': subs[0].get('ext', ''),
  195. 'url': subs[0].get('url', ''),
  196. 'auto_generated': False
  197. })
  198. # Process auto-generated subtitles
  199. for lang, subs in info.get('automatic_captions', {}).items():
  200. if lang in allowed_languages:
  201. display_lang = f'{get_language_display(lang)} (Auto-generated)'
  202. if display_lang not in seen_languages:
  203. seen_languages.add(display_lang)
  204. if subs:
  205. subtitles.append({
  206. 'language': display_lang,
  207. 'language_code': lang,
  208. 'format': subs[0].get('ext', ''),
  209. 'url': subs[0].get('url', ''),
  210. 'auto_generated': True
  211. })
  212. app.logger.info(f"Found {len(subtitles)} unique subtitle tracks (Chinese and English only)")
  213. return {
  214. 'title': info.get('title', 'Unknown'),
  215. 'duration': info.get('duration', 0),
  216. 'thumbnail': info.get('thumbnail', ''),
  217. 'formats': unique_formats,
  218. 'subtitles': subtitles,
  219. 'description': info.get('description', ''),
  220. 'channel': info.get('channel', 'Unknown'),
  221. 'view_count': info.get('view_count', 0),
  222. }
  223. except Exception as e:
  224. app.logger.error(f"Failed to get video info: {str(e)}")
  225. return {'error': str(e)}
  226. def cleanup_old_files():
  227. """Clean up temporary files older than 10 minutes"""
  228. current_time = time.time()
  229. for token, info in list(DOWNLOADS.items()):
  230. if current_time - info['timestamp'] > 600:
  231. try:
  232. file_path = info['file_path']
  233. if os.path.exists(file_path):
  234. os.remove(file_path)
  235. del DOWNLOADS[token]
  236. except Exception as e:
  237. app.logger.error(f"Failed to clean up file: {str(e)}")
  238. def log_progress(d):
  239. if d['status'] == 'downloading':
  240. try:
  241. percent = d.get('_percent_str', 'N/A').strip()
  242. speed = d.get('_speed_str', 'N/A').strip()
  243. eta = d.get('_eta_str', 'N/A').strip()
  244. if percent != 'N/A' and float(percent.rstrip('%')) % 5 < 1:
  245. app.logger.info(f"Download progress: {percent} | Speed: {speed} | ETA: {eta}")
  246. except Exception:
  247. pass
  248. elif d['status'] == 'finished':
  249. app.logger.info("Download complete, processing file...")
  250. @app.route('/')
  251. def index():
  252. return render_template('index.html')
  253. @app.route('/api/info', methods=['POST'])
  254. def get_info():
  255. url = request.json.get('url')
  256. if not url:
  257. return jsonify({'error': 'URL is required'}), 400
  258. info = get_video_info(url)
  259. return jsonify(info)
  260. @app.route('/api/download_subtitle', methods=['POST'])
  261. def download_subtitle():
  262. url = request.json.get('url')
  263. display_language = request.json.get('language')
  264. lang = LANGUAGE_CODES.get(display_language)
  265. if not url or not lang:
  266. app.logger.error(f'Missing URL or invalid language: {display_language}')
  267. return jsonify({'error': 'URL and valid language are required'}), 400
  268. try:
  269. cleanup_old_files()
  270. temp_file = os.path.join(TEMP_DIR, f'subtitle_{time.time_ns()}')
  271. app.logger.info(f"Creating subtitle temp file: {os.path.basename(temp_file)}")
  272. ydl_opts = {
  273. 'quiet': True,
  274. 'writesubtitles': True,
  275. 'writeautomaticsub': True,
  276. 'subtitleslangs': [lang],
  277. 'skip_download': True,
  278. 'outtmpl': temp_file
  279. }
  280. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  281. info = ydl.extract_info(url, download=True)
  282. subtitle_file = f"{temp_file}.{lang}.vtt"
  283. if not os.path.exists(subtitle_file):
  284. return jsonify({'error': f'No subtitles found for language: {lang}'}), 404
  285. download_token = os.urandom(16).hex()
  286. DOWNLOADS[download_token] = {
  287. 'file_path': subtitle_file,
  288. 'filename': f"{secure_filename(info['title'])}.{lang}.vtt",
  289. 'timestamp': time.time()
  290. }
  291. return jsonify({
  292. 'status': 'success',
  293. 'download_token': download_token,
  294. 'filename': f"{info['title']}.{lang}.vtt"
  295. })
  296. except Exception as e:
  297. app.logger.error(f"Subtitle download failed: {str(e)}")
  298. return jsonify({'error': str(e)}), 500
  299. @app.route('/api/download', methods=['POST'])
  300. def download_video():
  301. url = request.json.get('url')
  302. format_id = request.json.get('format_id')
  303. if not url or not format_id:
  304. app.logger.error('Missing URL or format ID')
  305. return jsonify({'error': 'URL and format_id are required'}), 400
  306. try:
  307. cleanup_old_files()
  308. temp_file = os.path.join(TEMP_DIR, f'download_{time.time_ns()}')
  309. app.logger.info(f"Creating temp file: {os.path.basename(temp_file)}")
  310. ydl_opts = {
  311. 'format': f'{format_id}+bestaudio[ext=m4a]/best',
  312. 'outtmpl': temp_file + '.%(ext)s',
  313. 'quiet': True,
  314. 'merge_output_format': 'mp4',
  315. 'postprocessors': [{
  316. 'key': 'FFmpegVideoConvertor',
  317. 'preferedformat': 'mp4',
  318. }],
  319. 'prefer_ffmpeg': True,
  320. 'keepvideo': False,
  321. 'progress_hooks': [log_progress],
  322. }
  323. app.logger.info("Starting video download...")
  324. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  325. info = ydl.extract_info(url, download=True)
  326. final_file = ydl.prepare_filename(info)
  327. filename = secure_filename(info['title'] + '.mp4')
  328. filesize = os.path.getsize(final_file)
  329. filesize_mb = filesize / (1024 * 1024)
  330. app.logger.info(f"Download complete: {filename} ({filesize_mb:.1f}MB)")
  331. download_token = os.urandom(16).hex()
  332. DOWNLOADS[download_token] = {
  333. 'file_path': final_file,
  334. 'filename': filename,
  335. 'timestamp': time.time()
  336. }
  337. return jsonify({
  338. 'status': 'success',
  339. 'download_token': download_token,
  340. 'filename': filename
  341. })
  342. except Exception as e:
  343. app.logger.error(f"Download failed: {str(e)}")
  344. return jsonify({'error': str(e)}), 500
  345. @app.route('/api/get_file/')
  346. def get_file(token):
  347. """Get downloaded file API endpoint"""
  348. if token not in DOWNLOADS:
  349. app.logger.error("Invalid download token")
  350. return 'Invalid or expired download token', 400
  351. download_info = DOWNLOADS[token]
  352. file_path = download_info['file_path']
  353. filename = download_info['filename']
  354. if not os.path.exists(file_path):
  355. app.logger.error(f"File not found: {filename}")
  356. return 'File not found', 404
  357. try:
  358. filesize = os.path.getsize(file_path)
  359. filesize_mb = filesize / (1024 * 1024)
  360. app.logger.info(f"Starting file transfer: {filename} ({filesize_mb:.1f}MB)")
  361. return send_file(
  362. file_path,
  363. as_attachment=True,
  364. download_name=filename,
  365. mimetype='video/mp4'
  366. )
  367. except Exception as e:
  368. app.logger.error(f"File transfer failed: {str(e)}")
  369. return str(e), 500
  370. finally:
  371. def cleanup():
  372. try:
  373. if token in DOWNLOADS:
  374. os.remove(file_path)
  375. del DOWNLOADS[token]
  376. app.logger.info(f"Temp file cleaned up: {filename}")
  377. except Exception as e:
  378. app.logger.error(f"Failed to clean up file: {str(e)}")
  379. import threading
  380. threading.Timer(60, cleanup).start()
  381. @app.route('/api/logs')
  382. def get_logs():
  383. """Get logs API endpoint"""
  384. logs = []
  385. temp_queue = queue.Queue()
  386. try:
  387. while not log_queue.empty():
  388. log = log_queue.get_nowait()
  389. logs.append(log)
  390. temp_queue.put(log)
  391. while not temp_queue.empty():
  392. log_queue.put(temp_queue.get_nowait())
  393. return jsonify(sorted(logs, key=lambda x: x['timestamp'], reverse=True))
  394. except Exception as e:
  395. app.logger.error(f"Failed to get logs: {str(e)}")
  396. return jsonify([])
  397. def get_local_ip():
  398. try:
  399. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  400. s.connect(('8.8.8.8', 80))
  401. ip = s.getsockname()[0]
  402. s.close()
  403. return ip
  404. except Exception:
  405. return '127.0.0.1'
  406. if __name__ == '__main__':
  407. # Ensure temp directory exists
  408. os.makedirs(TEMP_DIR, exist_ok=True)
  409. # Clean up old files on startup
  410. cleanup_old_files()
  411. # Get local IP and set port
  412. local_ip = get_local_ip()
  413. port = 9012
  414. # Print access information
  415. print("\n" + "="*50)
  416. print("YouTube Downloader is running!")
  417. print("="*50)
  418. print("\nAccess URLs:")
  419. print("-"*20)
  420. print("Local computer:")
  421. print(f"→ http://localhost:{port}")
  422. print(f"→ http://127.0.0.1:{port}")
  423. print("\nFrom other computers on your network:")
  424. print(f"→ http://{local_ip}:{port}")
  425. print("\n" + "="*50 + "\n")
  426. # Run the application
  427. app.run(host='0.0.0.0', port=port, debug=True)

2. index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>YouTube Video Downloader</title>
  7. <link rel="stylesheet" href="/static/css/style.css">
  8. </head>
  9. <body>
  10. <div class="container">
  11. <h1>YouTube Video Downloader</h1>
  12. <div class="input-group">
  13. <input type="text" id="url-input" placeholder="Enter YouTube URL">
  14. <button id="fetch-info">Get Video Info</button>
  15. </div>
  16. <div id="video-info" class="hidden">
  17. <div class="info-container">
  18. <img id="thumbnail" alt="Video thumbnail">
  19. <div class="video-details">
  20. <h2 id="video-title"></h2>
  21. <p>Duration: <span id="video-duration"></span></p>
  22. </div>
  23. </div>
  24. <div class="formats-container">
  25. <h3>Available Formats</h3>
  26. <div id="format-list"></div>
  27. <!-- 新增字幕部分 -->
  28. <div id="subtitle-section">
  29. <h3>Available Subtitles</h3>
  30. <div id="subtitle-list"></div>
  31. </div>
  32. </div>
  33. </div>
  34. <div id="status" class="hidden"></div>
  35. <div class="log-container">
  36. <div class="log-header">
  37. <h3>Operation Logs</h3>
  38. <button id="clear-logs">Clear</button>
  39. <label class="auto-scroll">
  40. <input type="checkbox" id="auto-scroll" checked>
  41. Auto-scroll
  42. </label>
  43. </div>
  44. <div id="log-display"></div>
  45. </div>
  46. </div>
  47. <script src="/static/js/script.js"></script>
  48. </body>
  49. </html>

3. style.css

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

  1. /* static/css/style.css */
  2. body {
  3. font-family: Arial, sans-serif;
  4. margin: 0;
  5. padding: 20px;
  6. background-color: #f5f5f5;
  7. }
  8. .container {
  9. max-width: 800px;
  10. margin: 0 auto;
  11. background-color: white;
  12. padding: 20px;
  13. border-radius: 8px;
  14. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  15. }
  16. h1 {
  17. text-align: center;
  18. color: #333;
  19. margin-bottom: 20px;
  20. }
  21. .input-group {
  22. display: flex;
  23. gap: 10px;
  24. margin-bottom: 20px;
  25. }
  26. input[type="text"] {
  27. flex: 1;
  28. padding: 10px;
  29. border: 1px solid #ddd;
  30. border-radius: 4px;
  31. font-size: 16px;
  32. }
  33. button {
  34. padding: 10px 20px;
  35. background-color: #007bff;
  36. color: white;
  37. border: none;
  38. border-radius: 4px;
  39. cursor: pointer;
  40. font-size: 16px;
  41. }
  42. button:hover {
  43. background-color: #0056b3;
  44. }
  45. .hidden {
  46. display: none;
  47. }
  48. .info-container {
  49. display: flex;
  50. gap: 20px;
  51. margin-bottom: 20px;
  52. padding: 15px;
  53. background-color: #f8f9fa;
  54. border-radius: 4px;
  55. }
  56. #thumbnail {
  57. max-width: 200px;
  58. border-radius: 4px;
  59. }
  60. .video-details {
  61. flex: 1;
  62. }
  63. .video-details h2 {
  64. margin: 0 0 10px 0;
  65. color: #333;
  66. }
  67. .formats-container {
  68. border-top: 1px solid #ddd;
  69. padding-top: 20px;
  70. }
  71. #format-list {
  72. display: grid;
  73. gap: 10px;
  74. }
  75. .format-item {
  76. padding: 10px;
  77. background-color: #f8f9fa;
  78. border-radius: 4px;
  79. display: flex;
  80. justify-content: space-between;
  81. align-items: center;
  82. }
  83. #status {
  84. margin: 20px 0;
  85. padding: 10px;
  86. border-radius: 4px;
  87. text-align: center;
  88. }
  89. #status.success {
  90. background-color: #d4edda;
  91. color: #155724;
  92. }
  93. #status.error {
  94. background-color: #f8d7da;
  95. color: #721c24;
  96. }
  97. /* 日志容器样式 */
  98. .log-container {
  99. margin-top: 20px;
  100. border: 1px solid #ddd;
  101. border-radius: 4px;
  102. background-color: #1e1e1e;
  103. }
  104. .log-header {
  105. padding: 10px;
  106. background-color: #2d2d2d;
  107. border-bottom: 1px solid #444;
  108. display: flex;
  109. align-items: center;
  110. gap: 10px;
  111. }
  112. .log-header h3 {
  113. margin: 0;
  114. flex-grow: 1;
  115. color: #fff;
  116. }
  117. .auto-scroll {
  118. display: flex;
  119. align-items: center;
  120. gap: 5px;
  121. font-size: 14px;
  122. color: #fff;
  123. }
  124. #clear-logs {
  125. padding: 5px 10px;
  126. background-color: #6c757d;
  127. color: white;
  128. border: none;
  129. border-radius: 4px;
  130. cursor: pointer;
  131. }
  132. #clear-logs:hover {
  133. background-color: #5a6268;
  134. }
  135. #log-display {
  136. height: 300px;
  137. overflow-y: auto;
  138. padding: 10px;
  139. font-family: 'Consolas', 'Monaco', monospace;
  140. font-size: 13px;
  141. line-height: 1.4;
  142. background-color: #1e1e1e;
  143. color: #d4d4d4;
  144. }
  145. .log-entry {
  146. margin: 2px 0;
  147. padding: 2px 5px;
  148. border-radius: 2px;
  149. white-space: pre-wrap;
  150. word-wrap: break-word;
  151. }
  152. .log-timestamp {
  153. color: #888;
  154. margin-right: 8px;
  155. font-size: 0.9em;
  156. }
  157. .log-info {
  158. color: #89d4ff;
  159. }
  160. .log-error {
  161. color: #ff8989;
  162. }
  163. .log-warning {
  164. color: #ffd700;
  165. }
  166. /* 滚动条样式 */
  167. #log-display::-webkit-scrollbar {
  168. width: 8px;
  169. }
  170. #log-display::-webkit-scrollbar-track {
  171. background: #2d2d2d;
  172. }
  173. #log-display::-webkit-scrollbar-thumb {
  174. background: #888;
  175. border-radius: 4px;
  176. }
  177. #log-display::-webkit-scrollbar-thumb:hover {
  178. background: #555;
  179. }
  180. #subtitle-list {
  181. margin-top: 20px;
  182. border-top: 1px solid #ddd;
  183. padding-top: 20px;
  184. }
  185. .subtitle-item {
  186. padding: 10px;
  187. background-color: #f8f9fa;
  188. border-radius: 4px;
  189. display: flex;
  190. justify-content: space-between;
  191. align-items: center;
  192. margin-bottom: 8px;
  193. }
  194. #subtitle-section {
  195. margin-top: 20px;
  196. padding-top: 20px;
  197. border-top: 1px solid #ddd;
  198. }
  199. #subtitle-list {
  200. display: grid;
  201. gap: 10px;
  202. margin-top: 10px;
  203. }
  204. .subtitle-item {
  205. padding: 10px;
  206. background-color: #f8f9fa;
  207. border-radius: 4px;
  208. display: flex;
  209. justify-content: space-between;
  210. align-items: center;
  211. }
  212. .subtitle-item button {
  213. padding: 5px 10px;
  214. background-color: #28a745;
  215. }
  216. .subtitle-item button:hover {
  217. background-color: #218838;
  218. }

4. script.js

  1. document.addEventListener('DOMContentLoaded', function() {
  2. const urlInput = document.getElementById('url-input');
  3. const fetchButton = document.getElementById('fetch-info');
  4. const videoInfo = document.getElementById('video-info');
  5. const thumbnail = document.getElementById('thumbnail');
  6. const videoTitle = document.getElementById('video-title');
  7. const videoDuration = document.getElementById('video-duration');
  8. const formatList = document.getElementById('format-list');
  9. const status = document.getElementById('status');
  10. // YouTube URL validation pattern
  11. const urlPattern = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)[a-zA-Z0-9_-]+/;
  12. class Logger {
  13. constructor() {
  14. this.logDisplay = document.getElementById('log-display');
  15. this.autoScrollCheckbox = document.getElementById('auto-scroll');
  16. this.clearLogsButton = document.getElementById('clear-logs');
  17. this.lastLogTimestamp = null;
  18. this.setupEventListeners();
  19. }
  20. setupEventListeners() {
  21. this.clearLogsButton.addEventListener('click', () => this.clearLogs());
  22. this.startLogPolling();
  23. }
  24. formatTimestamp(isoString) {
  25. const date = new Date(isoString);
  26. return date.toLocaleTimeString('en-US', {
  27. hour12: false,
  28. hour: '2-digit',
  29. minute: '2-digit',
  30. second: '2-digit',
  31. fractionalSecondDigits: 3
  32. });
  33. }
  34. addLogEntry(entry) {
  35. const logEntry = document.createElement('div');
  36. logEntry.classList.add('log-entry');
  37. if (entry.level === 'error') {
  38. logEntry.classList.add('log-error');
  39. } else if (entry.level === 'warning') {
  40. logEntry.classList.add('log-warning');
  41. } else {
  42. logEntry.classList.add('log-info');
  43. }
  44. const timestamp = document.createElement('span');
  45. timestamp.classList.add('log-timestamp');
  46. timestamp.textContent = this.formatTimestamp(entry.timestamp);
  47. const message = document.createElement('span');
  48. message.classList.add('log-message');
  49. message.textContent = entry.message;
  50. logEntry.appendChild(timestamp);
  51. logEntry.appendChild(message);
  52. this.logDisplay.appendChild(logEntry);
  53. if (this.autoScrollCheckbox.checked) {
  54. this.scrollToBottom();
  55. }
  56. }
  57. clearLogs() {
  58. this.logDisplay.innerHTML = '';
  59. this.lastLogTimestamp = null;
  60. }
  61. scrollToBottom() {
  62. this.logDisplay.scrollTop = this.logDisplay.scrollHeight;
  63. }
  64. async fetchLogs() {
  65. try {
  66. const response = await fetch('/api/logs');
  67. const logs = await response.json();
  68. const newLogs = this.lastLogTimestamp
  69. ? logs.filter(log => log.timestamp > this.lastLogTimestamp)
  70. : logs;
  71. if (newLogs.length > 0) {
  72. newLogs.forEach(log => this.addLogEntry(log));
  73. this.lastLogTimestamp = logs[0].timestamp;
  74. }
  75. } catch (error) {
  76. console.error('Failed to fetch logs:', error);
  77. }
  78. }
  79. startLogPolling() {
  80. setInterval(() => this.fetchLogs(), 500);
  81. }
  82. }
  83. const logger = new Logger();
  84. function formatDuration(seconds) {
  85. const hours = Math.floor(seconds / 3600);
  86. const minutes = Math.floor((seconds % 3600) / 60);
  87. const remainingSeconds = seconds % 60;
  88. if (hours > 0) {
  89. return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  90. }
  91. return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
  92. }
  93. function formatFileSize(bytes) {
  94. if (!bytes) return 'Unknown size';
  95. const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  96. const i = Math.floor(Math.log(bytes) / Math.log(1024));
  97. return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
  98. }
  99. function showStatus(message, isError = false) {
  100. status.textContent = message;
  101. status.className = isError ? 'error' : 'success';
  102. status.classList.remove('hidden');
  103. }
  104. function displaySubtitles(subtitles, url) {
  105. // Remove existing subtitle list if it exists
  106. const existingSubtitleList = document.getElementById('subtitle-list');
  107. if (existingSubtitleList) {
  108. existingSubtitleList.remove();
  109. }
  110. // Create a map to store unique subtitles by language
  111. const uniqueSubtitles = new Map();
  112. subtitles.forEach(sub => {
  113. if (!uniqueSubtitles.has(sub.language)) {
  114. uniqueSubtitles.set(sub.language, sub);
  115. }
  116. });
  117. const subtitleList = document.createElement('div');
  118. subtitleList.id = 'subtitle-list';
  119. subtitleList.innerHTML = `
  120. <h3>Available Subtitles</h3>
  121. ${Array.from(uniqueSubtitles.values()).map(sub => `
  122. <div class="subtitle-item">
  123. <span>${sub.language}</span>
  124. <button onclick="downloadSubtitle('${url}', '${sub.language}')">
  125. Download
  126. </button>
  127. </div>
  128. `).join('')}
  129. `;
  130. document.querySelector('.formats-container').appendChild(subtitleList);
  131. }
  132. async function tryPasteFromClipboard() {
  133. try {
  134. const clipboardText = await navigator.clipboard.readText();
  135. if (urlPattern.test(clipboardText)) {
  136. urlInput.value = clipboardText;
  137. logger.addLogEntry({
  138. timestamp: new Date().toISOString(),
  139. level: 'info',
  140. message: 'YouTube URL automatically pasted from clipboard'
  141. });
  142. return true;
  143. } else if (clipboardText.trim()) {
  144. logger.addLogEntry({
  145. timestamp: new Date().toISOString(),
  146. level: 'warning',
  147. message: 'Clipboard content is not a valid YouTube URL'
  148. });
  149. }
  150. } catch (err) {
  151. logger.addLogEntry({
  152. timestamp: new Date().toISOString(),
  153. level: 'warning',
  154. message: 'Could not access clipboard: ' + err.message
  155. });
  156. }
  157. return false;
  158. }
  159. async function downloadVideo(url, formatId) {
  160. try {
  161. logger.addLogEntry({
  162. timestamp: new Date().toISOString(),
  163. level: 'info',
  164. message: `Starting download preparation for format: ${formatId}`
  165. });
  166. showStatus('Preparing download...');
  167. const response = await fetch('/api/download', {
  168. method: 'POST',
  169. headers: {
  170. 'Content-Type': 'application/json',
  171. },
  172. body: JSON.stringify({ url, format_id: formatId })
  173. });
  174. const data = await response.json();
  175. if (response.ok && data.download_token) {
  176. logger.addLogEntry({
  177. timestamp: new Date().toISOString(),
  178. level: 'success',
  179. message: `Download token received: ${data.download_token}`
  180. });
  181. showStatus('Starting download...');
  182. const iframe = document.createElement('iframe');
  183. iframe.style.display = 'none';
  184. iframe.src = `/api/get_file/${data.download_token}`;
  185. iframe.onload = () => {
  186. logger.addLogEntry({
  187. timestamp: new Date().toISOString(),
  188. level: 'success',
  189. message: `Download started for: ${data.filename}`
  190. });
  191. showStatus('Download started! Check your browser downloads.');
  192. setTimeout(() => document.body.removeChild(iframe), 5000);
  193. };
  194. iframe.onerror = () => {
  195. logger.addLogEntry({
  196. timestamp: new Date().toISOString(),
  197. level: 'error',
  198. message: 'Download failed to start'
  199. });
  200. showStatus('Download failed. Please try again.', true);
  201. document.body.removeChild(iframe);
  202. };
  203. document.body.appendChild(iframe);
  204. } else {
  205. const errorMessage = data.error || 'Download failed';
  206. logger.addLogEntry({
  207. timestamp: new Date().toISOString(),
  208. level: 'error',
  209. message: `Download failed: ${errorMessage}`
  210. });
  211. showStatus(errorMessage, true);
  212. }
  213. } catch (error) {
  214. logger.addLogEntry({
  215. timestamp: new Date().toISOString(),
  216. level: 'error',
  217. message: `Network error: ${error.message}`
  218. });
  219. showStatus('Network error occurred', true);
  220. console.error(error);
  221. }
  222. }
  223. async function downloadSubtitle(url, language) {
  224. try {
  225. logger.addLogEntry({
  226. timestamp: new Date().toISOString(),
  227. level: 'info',
  228. message: `Starting subtitle download for language: ${language}`
  229. });
  230. showStatus('Preparing subtitle download...');
  231. const response = await fetch('/api/download_subtitle', {
  232. method: 'POST',
  233. headers: {
  234. 'Content-Type': 'application/json',
  235. },
  236. body: JSON.stringify({ url, language })
  237. });
  238. const data = await response.json();
  239. if (response.ok && data.download_token) {
  240. window.location.href = `/api/get_file/${data.download_token}`;
  241. showStatus('Subtitle download started!');
  242. } else {
  243. const errorMessage = data.error || 'Subtitle download failed';
  244. logger.addLogEntry({
  245. timestamp: new Date().toISOString(),
  246. level: 'error',
  247. message: errorMessage
  248. });
  249. showStatus(errorMessage, true);
  250. }
  251. } catch (error) {
  252. logger.addLogEntry({
  253. timestamp: new Date().toISOString(),
  254. level: 'error',
  255. message: `Network error: ${error.message}`
  256. });
  257. showStatus('Network error occurred', true);
  258. console.error(error);
  259. }
  260. }
  261. fetchButton.addEventListener('click', async () => {
  262. // Clear existing video info
  263. formatList.innerHTML = '';
  264. videoTitle.textContent = '';
  265. videoDuration.textContent = '';
  266. thumbnail.src = '';
  267. videoInfo.classList.add('hidden');
  268. await tryPasteFromClipboard();
  269. const url = urlInput.value.trim();
  270. if (!url) {
  271. showStatus('Please enter a valid URL', true);
  272. return;
  273. }
  274. if (!urlPattern.test(url)) {
  275. showStatus('Please enter a valid YouTube URL', true);
  276. return;
  277. }
  278. showStatus('Fetching video information...');
  279. try {
  280. const response = await fetch('/api/info', {
  281. method: 'POST',
  282. headers: {
  283. 'Content-Type': 'application/json',
  284. },
  285. body: JSON.stringify({ url })
  286. });
  287. const data = await response.json();
  288. if (response.ok) {
  289. thumbnail.src = data.thumbnail;
  290. videoTitle.textContent = data.title;
  291. videoDuration.textContent = formatDuration(data.duration);
  292. formatList.innerHTML = data.formats
  293. .filter(format => format.format_id && format.ext)
  294. .map(format => `
  295. <div class="format-item">
  296. <span>${format.format_note} (${format.ext}) - ${formatFileSize(format.filesize)}</span>
  297. <button onclick="downloadVideo('${url}', '${format.format_id}')">Download</button>
  298. </div>
  299. `)
  300. .join('');
  301. videoInfo.classList.remove('hidden');
  302. status.classList.add('hidden');
  303. logger.addLogEntry({
  304. timestamp: new Date().toISOString(),
  305. level: 'info',
  306. message: `Video information retrieved: ${data.title}`
  307. });
  308. if (data.subtitles && data.subtitles.length > 0) {
  309. displaySubtitles(data.subtitles, url);
  310. }
  311. } else {
  312. showStatus(data.error || 'Failed to fetch video info', true);
  313. logger.addLogEntry({
  314. timestamp: new Date().toISOString(),
  315. level: 'error',
  316. message: `Failed to fetch video info: ${data.error || 'Unknown error'}`
  317. });
  318. }
  319. } catch (error) {
  320. showStatus('Network error occurred', true);
  321. logger.addLogEntry({
  322. timestamp: new Date().toISOString(),
  323. level: 'error',
  324. message: `Network error: ${error.message}`
  325. });
  326. console.error(error);
  327. }
  328. });
  329. urlInput.addEventListener('keypress', (e) => {
  330. if (e.key === 'Enter') {
  331. fetchButton.click();
  332. }
  333. });
  334. window.downloadVideo = downloadVideo;
  335. window.downloadSubtitle = downloadSubtitle;
  336. });

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

Docker 部署

1. Dockerfile

  1. # Use Python 3.12 slim as base image for smaller size
  2. FROM python:3.12-slim
  3. # Set working directory
  4. WORKDIR /app
  5. # Install system dependencies including FFmpeg
  6. RUN apt-get update && \
  7. apt-get install -y --no-install-recommends \
  8. ffmpeg \
  9. build-essential \
  10. && rm -rf /var/lib/apt/lists/*
  11. # Copy application files
  12. COPY app.py ./
  13. COPY static/css/style.css ./static/css/
  14. COPY static/js/script.js ./static/js/
  15. COPY templates/index.html ./templates/
  16. COPY requirements.txt ./
  17. # Copy SSL certificates
  18. COPY cert.pem key.pem ./
  19. # Install Python dependencies
  20. RUN pip install --no-cache-dir -r requirements.txt
  21. # Create directories
  22. RUN mkdir -p /app/temp_downloads && \
  23. mkdir -p /app/config && \
  24. chmod 777 /app/temp_downloads /app/config
  25. # Environment variables
  26. ENV FLASK_APP=app.py
  27. ENV PYTHONUNBUFFERED=1
  28. ENV FLASK_RUN_HOST=0.0.0.0
  29. ENV FLASK_RUN_PORT=9012
  30. # Expose HTTPS port
  31. EXPOSE 9012
  32. # Create non-root user for security
  33. RUN useradd -m appuser && \
  34. chown -R appuser:appuser /app && \
  35. chmod 600 /app/key.pem /app/cert.pem
  36. # USER appuser
  37. # Run the application with SSL
  38. CMD ["python", "app.py"]
  39. # How to upgrade the yt-dlp package
  40. # python3 -m pip install -U --pre "yt-dlp[default]"

2. requirements.txt

  1. Flask==3.0.0
  2. yt-dlp==2023.11.16
  3. Werkzeug==3.0.1
  4. packaging==23.2
  5. setuptools==69.0.2

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

3. 创建 Image 与 Container

  1. # docker build -t yt-dlp .
  2. # docker run -d -p 9012:9012 --name yt-dlp_container yt-dlp
  3. 我使用了与 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]"

注:本文转载自blog.csdn.net的davenian的文章"https://blog.csdn.net/davenian/article/details/143587587"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top