Ethscriptions commited on
Commit
315962b
·
verified ·
1 Parent(s): a047f42

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +563 -0
app.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import yt_dlp
3
+ import os
4
+ import re
5
+ import json
6
+ from pathlib import Path
7
+ import tempfile
8
+ import shutil
9
+ from urllib.parse import urlparse, parse_qs
10
+ import threading
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ import time
13
+
14
+ SUPPORTED_PLATFORMS = {
15
+ "抖音": r'(https?://)?(v\.douyin\.com|www\.douyin\.com)',
16
+ "快手": r'(https?://)?(v\.kuaishou\.com|www\.kuaishou\.com)',
17
+ "哔哩哔哩": r'(https?://)?(www\.bilibili\.com|b23\.tv)',
18
+ "YouTube": r'(https?://)?(www\.youtube\.com|youtu\.be)',
19
+ "小红书": r'(https?://)?(www\.xiaohongshu\.com|xhslink\.com)',
20
+ "微博": r'(https?://)?(weibo\.com|t\.cn)',
21
+ "西瓜视频": r'(https?://)?(www\.ixigua\.com)',
22
+ "腾讯视频": r'(https?://)?(v\.qq\.com)'
23
+ }
24
+
25
+ def get_platform_from_url(url):
26
+ """
27
+ 自动识别URL所属平台
28
+ """
29
+ if not url:
30
+ return None
31
+
32
+ for platform, pattern in SUPPORTED_PLATFORMS.items():
33
+ if re.search(pattern, url):
34
+ return platform
35
+ return None
36
+
37
+ def get_platform_config(url, format_id=None):
38
+ """
39
+ 根据URL返回对的配置
40
+ """
41
+ platform = get_platform_from_url(url)
42
+ if not platform:
43
+ return None
44
+
45
+ # 基础配置
46
+ base_config = {
47
+ 'format': format_id if format_id else 'best',
48
+ 'merge_output_format': 'mp4',
49
+ # 网络相关设置
50
+ 'socket_timeout': 10, # 减少超时时间
51
+ 'retries': 2, # 减少重试次数
52
+ 'fragment_retries': 2,
53
+ 'retry_sleep': 2, # 减少重试等待时间
54
+ 'concurrent_fragment_downloads': 8,
55
+ }
56
+
57
+ configs = {
58
+ "抖音": {
59
+ **base_config,
60
+ 'format': format_id if format_id else 'best',
61
+ },
62
+ "快手": {
63
+ **base_config,
64
+ 'format': format_id if format_id else 'best',
65
+ },
66
+ "哔哩哔哩": {
67
+ **base_config,
68
+ 'format': format_id if format_id else 'bestvideo+bestaudio/best',
69
+ # B站特定设置
70
+ 'concurrent_fragment_downloads': 16,
71
+ 'file_access_retries': 2,
72
+ 'extractor_retries': 2,
73
+ 'fragment_retries': 2,
74
+ 'retry_sleep': 2,
75
+ },
76
+ "YouTube": {
77
+ **base_config,
78
+ 'format': format_id if format_id else 'bestvideo+bestaudio/best',
79
+ },
80
+ "小红书": {
81
+ **base_config,
82
+ 'format': format_id if format_id else 'best',
83
+ },
84
+ "微博": {
85
+ **base_config,
86
+ 'format': format_id if format_id else 'best',
87
+ },
88
+ "西瓜视频": {
89
+ **base_config,
90
+ 'format': format_id if format_id else 'best',
91
+ },
92
+ "腾讯视频": {
93
+ **base_config,
94
+ 'format': format_id if format_id else 'best',
95
+ }
96
+ }
97
+
98
+ return configs.get(platform)
99
+
100
+ def validate_url(url):
101
+ """
102
+ 验证URL是否符合支持的平台格式
103
+ """
104
+ if not url:
105
+ return False, "请输入视频链接"
106
+
107
+ platform = get_platform_from_url(url)
108
+ if not platform:
109
+ return False, "不支持的平台或链接格式不正确"
110
+
111
+ return True, f"识别为{platform}平台"
112
+
113
+ def format_filesize(bytes):
114
+ """
115
+ 格式化文件大小显示
116
+ """
117
+ if not bytes:
118
+ return "未知大小"
119
+
120
+ for unit in ['B', 'KB', 'MB', 'GB']:
121
+ if bytes < 1024:
122
+ return f"{bytes:.1f} {unit}"
123
+ bytes /= 1024
124
+ return f"{bytes:.1f} TB"
125
+
126
+ def parse_video_info(url):
127
+ """
128
+ 解析视频信息
129
+ """
130
+ try:
131
+ # 验证URL
132
+ is_valid, message = validate_url(url)
133
+ if not is_valid:
134
+ return {"status": "error", "message": message}
135
+
136
+ # 获取平台特定配置
137
+ ydl_opts = get_platform_config(url)
138
+ if not ydl_opts:
139
+ return {"status": "error", "message": "不支持的平台"}
140
+
141
+ ydl_opts.update({
142
+ 'quiet': True,
143
+ 'no_warnings': True,
144
+ })
145
+
146
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
147
+ info = ydl.extract_info(url, download=False)
148
+ if not info:
149
+ return {"status": "error", "message": "无法获取视频信息"}
150
+
151
+ # 获取可用的格式
152
+ formats = []
153
+ seen_resolutions = set() # 用于去重
154
+ if 'formats' in info:
155
+ # 过滤和排序格式
156
+ video_formats = []
157
+ for f in info['formats']:
158
+ # 过滤音频格式和没有视频编码的格式
159
+ if f.get('vcodec') == 'none' or not f.get('vcodec'):
160
+ continue
161
+
162
+ # 获取分辨率
163
+ width = f.get('width', 0)
164
+ height = f.get('height', 0)
165
+ resolution = f.get('resolution', 'unknown')
166
+ if width and height:
167
+ resolution = f"{width}x{height}"
168
+
169
+ # 获取格式说明
170
+ format_note = f.get('format_note', '')
171
+ if not format_note and resolution != 'unknown':
172
+ if height:
173
+ format_note = f"{height}p"
174
+
175
+ # 创建唯一标识用于去重
176
+ resolution_key = f"{height}_{width}" if height and width else resolution
177
+
178
+ # 如果这个分辨率已经存在,跳过
179
+ if resolution_key in seen_resolutions:
180
+ continue
181
+ seen_resolutions.add(resolution_key)
182
+
183
+ # 创建格式信息
184
+ format_info = {
185
+ 'format_id': f.get('format_id', ''),
186
+ 'ext': f.get('ext', ''),
187
+ 'resolution': resolution,
188
+ 'format_note': format_note,
189
+ 'quality': height or 0 # 用于排序
190
+ }
191
+ video_formats.append(format_info)
192
+
193
+ # 按质量排序
194
+ video_formats.sort(key=lambda x: x['quality'], reverse=True)
195
+ formats = video_formats
196
+
197
+ # 获取��览图
198
+ thumbnail = info.get('thumbnail', '')
199
+ if not thumbnail and 'thumbnails' in info:
200
+ thumbnails = info['thumbnails']
201
+ if thumbnails:
202
+ thumbnail = thumbnails[-1]['url']
203
+
204
+ platform = get_platform_from_url(url)
205
+ return {
206
+ "status": "success",
207
+ "message": "解析成功",
208
+ "platform": platform,
209
+ "title": info.get('title', '未知标题'),
210
+ "duration": info.get('duration', 0),
211
+ "formats": formats,
212
+ "thumbnail": thumbnail,
213
+ "description": info.get('description', ''),
214
+ "webpage_url": info.get('webpage_url', url),
215
+ }
216
+
217
+ except Exception as e:
218
+ return {"status": "error", "message": f"解析失败: {str(e)}"}
219
+
220
+ class DownloadProgress:
221
+ def __init__(self):
222
+ self.progress = 0
223
+ self.status = "准备下载"
224
+ self.lock = threading.Lock()
225
+
226
+ def update(self, d):
227
+ with self.lock:
228
+ if d.get('status') == 'downloading':
229
+ total = d.get('total_bytes')
230
+ downloaded = d.get('downloaded_bytes')
231
+ if total and downloaded:
232
+ self.progress = (downloaded / total) * 100
233
+ self.status = f"下载中: {d.get('_percent_str', '0%')} of {d.get('_total_bytes_str', 'unknown')}"
234
+ elif d.get('status') == 'finished':
235
+ self.progress = 100
236
+ self.status = "下载完成,正在处理..."
237
+
238
+ def get_downloads_dir():
239
+ """
240
+ 获取用户的下载目录
241
+ """
242
+ # 获取用户主目录
243
+ home = str(Path.home())
244
+ # 获取下载目录
245
+ downloads_dir = os.path.join(home, "Downloads")
246
+ # 如果下载目录不存在,则创建
247
+ if not os.path.exists(downloads_dir):
248
+ downloads_dir = home
249
+ return downloads_dir
250
+
251
+ def clean_filename(title, platform):
252
+ """
253
+ 清理并格式化文件名
254
+ """
255
+ # 移除非法字符
256
+ illegal_chars = r'[<>:"/\\|?*\n\r\t]'
257
+ clean_title = re.sub(illegal_chars, '', title)
258
+
259
+ # 移除多余的空格和特殊符号
260
+ clean_title = re.sub(r'\s+', ' ', clean_title).strip()
261
+ clean_title = re.sub(r'[,.,。!!@#$%^&*()()+=\[\]{};:]+', '', clean_title)
262
+
263
+ # 移除表情符号
264
+ clean_title = re.sub(r'[\U0001F300-\U0001F9FF]', '', clean_title)
265
+
266
+ # 添加平台标识
267
+ platform_suffix = {
268
+ "抖音": "抖音",
269
+ "快手": "快手",
270
+ "哔哩哔哩": "B站",
271
+ "YouTube": "YT",
272
+ "小红书": "XHS",
273
+ "微博": "微博",
274
+ "西瓜视频": "西瓜",
275
+ "腾讯视频": "腾讯"
276
+ }
277
+
278
+ # 限制标题长度(考虑到平台标识的长度)
279
+ max_length = 50
280
+ if len(clean_title) > max_length:
281
+ clean_title = clean_title[:max_length-3] + '...'
282
+
283
+ # 添加时间戳和平台标识
284
+ timestamp = time.strftime("%Y%m%d", time.localtime())
285
+ suffix = platform_suffix.get(platform, "视频")
286
+
287
+ # 最终文件名格式:标题_时间_平台.mp4
288
+ final_name = f"{clean_title}_{timestamp}_{suffix}"
289
+
290
+ return final_name
291
+
292
+ def download_single_video(url, format_id, progress_tracker):
293
+ """
294
+ 下载单个视频
295
+ """
296
+ try:
297
+ # 创建临时目录
298
+ temp_dir = tempfile.mkdtemp()
299
+
300
+ # 获取平台信息
301
+ platform = get_platform_from_url(url)
302
+ if not platform:
303
+ shutil.rmtree(temp_dir, ignore_errors=True)
304
+ return {"status": "error", "message": "不支持的平台"}
305
+
306
+ # 获取视频信息
307
+ with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
308
+ info = ydl.extract_info(url, download=False)
309
+ # 清理并格式化文件名
310
+ clean_title = clean_filename(info.get('title', 'video'), platform)
311
+
312
+ ydl_opts = get_platform_config(url, format_id)
313
+ if not ydl_opts:
314
+ shutil.rmtree(temp_dir, ignore_errors=True)
315
+ return {"status": "error", "message": "不支持的平台"}
316
+
317
+ # 更新下载配置
318
+ ydl_opts.update({
319
+ 'quiet': False,
320
+ 'no_warnings': False,
321
+ 'extract_flat': False,
322
+ 'paths': {'home': temp_dir},
323
+ 'progress_hooks': [progress_tracker.update],
324
+ 'outtmpl': clean_title + '.%(ext)s', # 不使用绝对路径
325
+ 'ignoreerrors': True, # 忽略部分错误继续下载
326
+ 'noprogress': False, # 显示进度
327
+ 'continuedl': True, # 支持断点续传
328
+ 'retries': float('inf'), # 无限重试
329
+ 'fragment_retries': float('inf'), # 片段无限重试
330
+ 'skip_unavailable_fragments': True, # 跳过不可用片段
331
+ 'no_abort_on_error': True, # 发生错误时不中止
332
+ })
333
+
334
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
335
+ try:
336
+ info = ydl.extract_info(url, download=True)
337
+ if 'requested_downloads' in info:
338
+ file_path = info['requested_downloads'][0]['filepath']
339
+ else:
340
+ file_path = os.path.join(temp_dir, f"{clean_title}.mp4")
341
+
342
+ if os.path.exists(file_path):
343
+ # 检查文件大小
344
+ file_size = os.path.getsize(file_path)
345
+ if file_size == 0:
346
+ shutil.rmtree(temp_dir, ignore_errors=True)
347
+ return {"status": "error", "message": "下载的文件大小为0,可能下载失败"}
348
+
349
+ # 创建一个新的临时文件
350
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
351
+ temp_file.close()
352
+ shutil.copy2(file_path, temp_file.name)
353
+
354
+ # 清理原始临时目录
355
+ shutil.rmtree(temp_dir, ignore_errors=True)
356
+
357
+ return {
358
+ "status": "success",
359
+ "file_path": temp_file.name,
360
+ "title": clean_title,
361
+ "ext": "mp4"
362
+ }
363
+ else:
364
+ shutil.rmtree(temp_dir, ignore_errors=True)
365
+ return {"status": "error", "message": "下载文件不存在"}
366
+ except Exception as e:
367
+ error_msg = str(e)
368
+ # 如果是超时错误且进度不为0,继续下载
369
+ if ("timed out" in error_msg or "timeout" in error_msg) and progress_tracker.progress > 0:
370
+ return {
371
+ "status": "success",
372
+ "file_path": file_path if 'file_path' in locals() else None,
373
+ "title": clean_title,
374
+ "ext": "mp4"
375
+ }
376
+ shutil.rmtree(temp_dir, ignore_errors=True)
377
+ return {"status": "error", "message": f"下载过程中出错: {error_msg}"}
378
+
379
+ except Exception as e:
380
+ if 'temp_dir' in locals():
381
+ shutil.rmtree(temp_dir, ignore_errors=True)
382
+ return {"status": "error", "message": str(e)}
383
+
384
+ def download_video(urls, format_id=None):
385
+ """
386
+ 下载视频并返回文件
387
+ """
388
+ if isinstance(urls, str):
389
+ urls = [url.strip() for url in urls.split('\n') if url.strip()]
390
+
391
+ if not urls:
392
+ return "请输入至少一个视频链接", None, 0, "未开始下载"
393
+
394
+ progress_tracker = DownloadProgress()
395
+ result = download_single_video(urls[0], format_id, progress_tracker)
396
+
397
+ if result["status"] == "success":
398
+ try:
399
+ # 返回文件路径供Gradio处理下载
400
+ return "下载成功,正在传输...", result["file_path"], 100, "下载完成"
401
+ except Exception as e:
402
+ return f"文件处理失败: {str(e)}", None, 0, "下载失败"
403
+ else:
404
+ return f"下载失败: {result.get('message', '未知错误')}", None, 0, "下载失败"
405
+
406
+ # 创建Gradio界面
407
+ with gr.Blocks(title="视频下载工具", theme=gr.themes.Soft()) as demo:
408
+ # 存储视频信息的状态变量
409
+ video_info_state = gr.State({})
410
+
411
+ with gr.Column(elem_id="header"):
412
+ gr.Markdown("""
413
+ # 🎥 视频下载工具
414
+
415
+ 一键下载各大平台视频,支持以下平台:
416
+ """)
417
+
418
+ with gr.Row():
419
+ for platform in SUPPORTED_PLATFORMS.keys():
420
+ gr.Markdown(f"<span class='platform-badge'>{platform}</span>", elem_classes="platform")
421
+
422
+ with gr.Row():
423
+ with gr.Column(scale=2):
424
+ # 输入部分
425
+ url_input = gr.Textbox(
426
+ label="视频链接",
427
+ placeholder="请输入视频链接,支持批量下载(每行一个链接)...",
428
+ lines=3,
429
+ info="支持多个平台的视频链接,自动识别平台类型"
430
+ )
431
+ parse_btn = gr.Button("解析视频", variant="secondary", size="lg")
432
+
433
+ # 视频信息显示(使用Accordion组件)
434
+ with gr.Accordion("视频详细信息", open=False, visible=False) as video_info_accordion:
435
+ video_info = gr.JSON(show_label=False)
436
+
437
+ format_choice = gr.Dropdown(
438
+ label="选择清晰度",
439
+ choices=[],
440
+ interactive=True,
441
+ visible=False
442
+ )
443
+
444
+ download_btn = gr.Button("开始下载", variant="primary", size="lg", interactive=False)
445
+
446
+ with gr.Column(scale=3):
447
+ # 预览和输出部分
448
+ with gr.Row():
449
+ preview_image = gr.Image(label="视频预览", visible=False)
450
+ with gr.Row():
451
+ progress = gr.Slider(
452
+ minimum=0,
453
+ maximum=100,
454
+ value=0,
455
+ label="下载进度",
456
+ interactive=False
457
+ )
458
+ status = gr.Textbox(
459
+ label="状态信息",
460
+ value="等待开始下载...",
461
+ interactive=False
462
+ )
463
+ # 使用File组件来处理下载
464
+ output_file = gr.File(label="下载文件")
465
+
466
+ # 添加自定义CSS
467
+ gr.Markdown("""
468
+ <style>
469
+ #header {
470
+ text-align: center;
471
+ margin-bottom: 2rem;
472
+ }
473
+ .platform-badge {
474
+ display: inline-block;
475
+ padding: 0.5rem 1rem;
476
+ margin: 0.5rem;
477
+ border-radius: 2rem;
478
+ background-color: #2196F3;
479
+ color: white;
480
+ font-weight: bold;
481
+ }
482
+ .gradio-container {
483
+ max-width: 1200px !important;
484
+ }
485
+ .contain {
486
+ margin: 0 auto;
487
+ padding: 2rem;
488
+ }
489
+ .download-link {
490
+ display: inline-block;
491
+ padding: 0.8rem 1.5rem;
492
+ background-color: #4CAF50;
493
+ color: white;
494
+ text-decoration: none;
495
+ border-radius: 0.5rem;
496
+ margin-top: 1rem;
497
+ font-weight: bold;
498
+ transition: background-color 0.3s;
499
+ }
500
+ .download-link:hover {
501
+ background-color: #45a049;
502
+ }
503
+ </style>
504
+ """)
505
+
506
+ def update_video_info(url):
507
+ """更新视频信息"""
508
+ # 只解析第一个链接
509
+ first_url = url.split('\n')[0].strip()
510
+ info = parse_video_info(first_url)
511
+
512
+ if info["status"] == "success":
513
+ # 准备清晰度选项
514
+ format_choices = []
515
+ for fmt in info["formats"]:
516
+ # 构建格式标签
517
+ label_parts = []
518
+ if fmt['format_note']:
519
+ label_parts.append(fmt['format_note'])
520
+ if fmt['resolution'] != 'unknown':
521
+ label_parts.append(fmt['resolution'])
522
+
523
+ label = " - ".join(filter(None, label_parts))
524
+ if not label:
525
+ label = f"格式 {fmt['format_id']}"
526
+
527
+ format_choices.append((label, fmt['format_id']))
528
+
529
+ return [
530
+ gr.update(visible=True, value=info), # video_info
531
+ gr.update(visible=True, choices=format_choices, value=format_choices[0][1] if format_choices else None), # format_choice
532
+ gr.update(interactive=True), # download_btn
533
+ gr.update(visible=True, value=info["thumbnail"]), # preview_image
534
+ f"解析成功: {info['title']} ({info['platform']})", # status
535
+ gr.update(visible=True) # video_info_accordion
536
+ ]
537
+ else:
538
+ return [
539
+ gr.update(visible=False), # video_info
540
+ gr.update(visible=False), # format_choice
541
+ gr.update(interactive=False), # download_btn
542
+ gr.update(visible=False), # preview_image
543
+ info["message"], # status
544
+ gr.update(visible=False) # video_info_accordion
545
+ ]
546
+
547
+ # 绑定解析按钮事件
548
+ parse_btn.click(
549
+ fn=update_video_info,
550
+ inputs=[url_input],
551
+ outputs=[video_info, format_choice, download_btn, preview_image, status, video_info_accordion]
552
+ )
553
+
554
+ # 绑定下载按钮事件
555
+ download_btn.click(
556
+ fn=download_video,
557
+ inputs=[url_input, format_choice],
558
+ outputs=[status, output_file, progress, status]
559
+ )
560
+
561
+ # 启���应用
562
+ if __name__ == "__main__":
563
+ demo.launch()