bili-dl 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ import os
2
+ import subprocess
3
+ import logging
4
+ import shutil
5
+ from typing import Dict, Optional, Tuple
6
+ from config.config_manager import get_config
7
+
8
+ class FFmpegIntegration:
9
+ def __init__(self):
10
+ self.logger = logging.getLogger(__name__)
11
+ self.ffmpeg_config = get_config('ffmpeg', {})
12
+ self.ffmpeg_path = self._find_ffmpeg()
13
+
14
+ def _find_ffmpeg(self) -> Optional[str]:
15
+ """查找ffmpeg可执行文件路径"""
16
+ # 首先检查配置中的路径
17
+ config_path = self.ffmpeg_config.get('path', 'auto')
18
+ if config_path != 'auto' and os.path.exists(config_path):
19
+ return config_path
20
+
21
+ # 检查系统PATH中的ffmpeg
22
+ ffmpeg_executable = 'ffmpeg.exe' if os.name == 'nt' else 'ffmpeg'
23
+ ffmpeg_path = shutil.which(ffmpeg_executable)
24
+
25
+ if ffmpeg_path:
26
+ return ffmpeg_path
27
+
28
+ # 检查常见安装路径(Windows)
29
+ if os.name == 'nt':
30
+ common_paths = [
31
+ r'C:\Program Files\ffmpeg\bin\ffmpeg.exe',
32
+ r'C:\ffmpeg\bin\ffmpeg.exe',
33
+ r'D:\Program Files\ffmpeg\bin\ffmpeg.exe',
34
+ ]
35
+ for path in common_paths:
36
+ if os.path.exists(path):
37
+ return path
38
+
39
+ self.logger.warning("未找到ffmpeg,请安装ffmpeg并添加到PATH环境变量")
40
+ return None
41
+
42
+ def check_ffmpeg_available(self) -> bool:
43
+ """检查ffmpeg是否可用"""
44
+ if not self.ffmpeg_path:
45
+ return False
46
+
47
+ try:
48
+ result = subprocess.run(
49
+ [self.ffmpeg_path, '-version'],
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=10
53
+ )
54
+ return result.returncode == 0
55
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
56
+ return False
57
+
58
+ def merge_video_audio(self, video_path: str, audio_path: str, output_path: str) -> Tuple[bool, str]:
59
+ """合并视频和音频文件"""
60
+ if not self.check_ffmpeg_available():
61
+ return False, "ffmpeg不可用"
62
+
63
+ if not os.path.exists(video_path) or not os.path.exists(audio_path):
64
+ return False, "输入文件不存在"
65
+
66
+ try:
67
+ # 构建ffmpeg命令
68
+ cmd = [
69
+ self.ffmpeg_path,
70
+ '-i', video_path, # 输入视频文件
71
+ '-i', audio_path, # 输入音频文件
72
+ '-c', 'copy', # 直接复制流,不重新编码
73
+ '-y', # 覆盖输出文件
74
+ output_path # 输出文件
75
+ ]
76
+
77
+ self.logger.info(f"执行ffmpeg命令: {' '.join(cmd)}")
78
+
79
+ # 执行ffmpeg命令
80
+ process = subprocess.Popen(
81
+ cmd,
82
+ stdout=subprocess.PIPE,
83
+ stderr=subprocess.PIPE
84
+ )
85
+
86
+ # 实时输出进度信息
87
+ while True:
88
+ output = process.stderr.readline()
89
+ if output == b'' and process.poll() is not None:
90
+ break
91
+ if output:
92
+ try:
93
+ # 解码输出,尝试utf-8,如果失败则使用系统默认编码
94
+ output_str = output.decode('utf-8', errors='ignore')
95
+ # 解析进度信息(如果有)
96
+ if 'time=' in output_str:
97
+ time_info = output_str.split('time=')[1].split(' ')[0]
98
+ print(f"\r合并进度: {time_info}", end='')
99
+ except UnicodeDecodeError:
100
+ # 如果utf-8解码失败,尝试其他编码
101
+ try:
102
+ output_str = output.decode('gbk', errors='ignore')
103
+ if 'time=' in output_str:
104
+ time_info = output_str.split('time=')[1].split(' ')[0]
105
+ print(f"\r合并进度: {time_info}", end='')
106
+ except:
107
+ pass
108
+
109
+ returncode = process.poll()
110
+
111
+ if returncode == 0:
112
+ print("\n音视频合并完成!")
113
+
114
+ # 检查是否需要删除临时文件
115
+ if self.ffmpeg_config.get('delete_temp_files', True):
116
+ try:
117
+ os.remove(video_path)
118
+ os.remove(audio_path)
119
+ self.logger.info("已删除临时文件")
120
+ except Exception as e:
121
+ self.logger.warning(f"删除临时文件失败: {e}")
122
+
123
+ return True, "合并成功"
124
+ else:
125
+ try:
126
+ error_output = process.stderr.read().decode('utf-8', errors='ignore')
127
+ except:
128
+ error_output = process.stderr.read().decode('gbk', errors='ignore')
129
+ return False, f"ffmpeg执行失败: {error_output}"
130
+
131
+ except Exception as e:
132
+ return False, f"合并过程异常: {str(e)}"
133
+
134
+ def convert_to_mp4(self, input_path: str, output_path: str) -> Tuple[bool, str]:
135
+ """转换视频格式到MP4"""
136
+ if not self.check_ffmpeg_available():
137
+ return False, "ffmpeg不可用"
138
+
139
+ if not os.path.exists(input_path):
140
+ return False, "输入文件不存在"
141
+
142
+ try:
143
+ cmd = [
144
+ self.ffmpeg_path,
145
+ '-i', input_path,
146
+ '-c', 'copy', # 直接复制,不重新编码
147
+ '-y',
148
+ output_path
149
+ ]
150
+
151
+ result = subprocess.run(
152
+ cmd,
153
+ capture_output=True,
154
+ text=True,
155
+ timeout=300 # 5分钟超时
156
+ )
157
+
158
+ if result.returncode == 0:
159
+ return True, "转换成功"
160
+ else:
161
+ return False, f"转换失败: {result.stderr}"
162
+
163
+ except subprocess.TimeoutExpired:
164
+ return False, "转换超时"
165
+ except Exception as e:
166
+ return False, f"转换过程异常: {str(e)}"
167
+
168
+ def get_video_info(self, video_path: str) -> Optional[Dict]:
169
+ """获取视频文件信息"""
170
+ if not self.check_ffmpeg_available():
171
+ return None
172
+
173
+ try:
174
+ cmd = [
175
+ self.ffmpeg_path,
176
+ '-i', video_path,
177
+ '-hide_banner'
178
+ ]
179
+
180
+ result = subprocess.run(
181
+ cmd,
182
+ capture_output=True,
183
+ text=True,
184
+ timeout=30
185
+ )
186
+
187
+ # 解析ffmpeg输出获取视频信息
188
+ info = {}
189
+ stderr = result.stderr
190
+
191
+ # 解析时长
192
+ if 'Duration:' in stderr:
193
+ duration_line = stderr.split('Duration:')[1].split(',')[0].strip()
194
+ info['duration'] = duration_line
195
+
196
+ # 解析视频流信息
197
+ if 'Video:' in stderr:
198
+ video_line = stderr.split('Video:')[1].split('\n')[0].strip()
199
+ info['video'] = video_line
200
+
201
+ # 解析音频流信息
202
+ if 'Audio:' in stderr:
203
+ audio_line = stderr.split('Audio:')[1].split('\n')[0].strip()
204
+ info['audio'] = audio_line
205
+
206
+ return info if info else None
207
+
208
+ except Exception as e:
209
+ self.logger.error(f"获取视频信息失败: {e}")
210
+ return None
211
+
212
+ # 全局ffmpeg集成实例
213
+ ffmpeg_integration = FFmpegIntegration()
214
+
215
+ def check_ffmpeg_available() -> bool:
216
+ """检查ffmpeg是否可用"""
217
+ return ffmpeg_integration.check_ffmpeg_available()
218
+
219
+ def merge_video_audio(video_path: str, audio_path: str, output_path: str) -> Tuple[bool, str]:
220
+ """合并视频和音频文件"""
221
+ return ffmpeg_integration.merge_video_audio(video_path, audio_path, output_path)
222
+
223
+ def convert_to_mp4(input_path: str, output_path: str) -> Tuple[bool, str]:
224
+ """转换视频格式到MP4"""
225
+ return ffmpeg_integration.convert_to_mp4(input_path, output_path)
226
+
227
+ def get_video_info(video_path: str) -> Optional[Dict]:
228
+ """获取视频文件信息"""
229
+ return ffmpeg_integration.get_video_info(video_path)
@@ -0,0 +1,261 @@
1
+ import requests
2
+ import json
3
+ import time
4
+ import logging
5
+ import qrcode
6
+ import io
7
+ import base64
8
+ import os
9
+ import sys
10
+ import subprocess
11
+ from typing import Dict, Optional, Tuple
12
+ from urllib.parse import urlencode
13
+
14
+ from config.config_manager import get_config, update_user_cookies, get_user_cookies
15
+
16
+ class LoginManager:
17
+ def __init__(self):
18
+ self.session = requests.Session()
19
+ self.session.headers.update({
20
+ 'User-Agent': get_config('network.user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'),
21
+ 'Referer': 'https://www.bilibili.com',
22
+ 'Origin': 'https://www.bilibili.com'
23
+ })
24
+ self.logger = logging.getLogger(__name__)
25
+
26
+ # 设置代理
27
+ proxy = get_config('network.proxy')
28
+ if proxy:
29
+ self.session.proxies = {'http': proxy, 'https': proxy}
30
+
31
+ def check_login_status(self) -> bool:
32
+ """检查当前登录状态"""
33
+ try:
34
+ url = "https://api.bilibili.com/x/web-interface/nav"
35
+ response = self.session.get(url)
36
+
37
+ if response.status_code == 200:
38
+ data = response.json()
39
+ return data.get('code') == 0 and data.get('data', {}).get('isLogin', False)
40
+ return False
41
+ except Exception as e:
42
+ self.logger.error(f"检查登录状态失败: {e}")
43
+ return False
44
+
45
+ def verify_cookie_availability(self) -> Tuple[bool, Optional[str]]:
46
+ """
47
+ 验证Cookie可用性并返回状态和消息
48
+
49
+ Returns:
50
+ Tuple[bool, Optional[str]]: (是否可用, 状态消息)
51
+ """
52
+ try:
53
+ # 测试获取用户信息(需要登录的API)
54
+ url = "https://api.bilibili.com/x/web-interface/nav"
55
+ response = self.session.get(url)
56
+
57
+ if response.status_code == 200:
58
+ data = response.json()
59
+ if data.get('code') == 0:
60
+ user_data = data.get('data', {})
61
+ if user_data.get('isLogin', False):
62
+ username = user_data.get('uname', '未知用户')
63
+ return True, f"Cookie有效 - 已登录用户: {username}"
64
+ else:
65
+ return False, "Cookie已失效,需要重新登录"
66
+ else:
67
+ return False, f"Cookie验证失败: {data.get('message', '未知错误')}"
68
+ else:
69
+ return False, f"HTTP请求失败: {response.status_code}"
70
+
71
+ except Exception as e:
72
+ self.logger.error(f"Cookie验证异常: {e}")
73
+ return False, f"Cookie验证异常: {str(e)}"
74
+
75
+ def get_qrcode_login_url(self) -> Tuple[Optional[str], Optional[str]]:
76
+ """获取二维码登录URL和oauthKey"""
77
+ try:
78
+ url = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
79
+ response = self.session.get(url)
80
+
81
+ if response.status_code == 200:
82
+ data = response.json()
83
+ if data.get('code') == 0:
84
+ qrcode_url = data['data']['url']
85
+ oauth_key = data['data']['qrcode_key']
86
+ return qrcode_url, oauth_key
87
+ return None, None
88
+ except Exception as e:
89
+ self.logger.error(f"获取二维码登录URL失败: {e}")
90
+ return None, None
91
+
92
+ def generate_qrcode_image(self, qrcode_url: str) -> Optional[str]:
93
+ """生成二维码图片文件并返回文件路径"""
94
+ try:
95
+ qr = qrcode.QRCode(
96
+ version=1,
97
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
98
+ box_size=10,
99
+ border=4,
100
+ )
101
+ qr.add_data(qrcode_url)
102
+ qr.make(fit=True)
103
+
104
+ img = qr.make_image(fill_color="black", back_color="white")
105
+
106
+ # 保存为文件
107
+ qrcode_file = "qrcode.png"
108
+ img.save(qrcode_file)
109
+
110
+ # 尝试自动打开图片
111
+ try:
112
+ if os.name == 'nt': # Windows
113
+ os.startfile(qrcode_file)
114
+ elif os.name == 'posix': # macOS or Linux
115
+ if sys.platform == 'darwin': # macOS
116
+ subprocess.run(['open', qrcode_file])
117
+ else: # Linux
118
+ subprocess.run(['xdg-open', qrcode_file])
119
+ except Exception:
120
+ self.logger.warning("无法自动打开二维码图片,请手动查看")
121
+
122
+ return qrcode_file
123
+ except Exception as e:
124
+ self.logger.error(f"生成二维码失败: {e}")
125
+ return None
126
+
127
+ def check_qrcode_status(self, oauth_key: str) -> Tuple[bool, Optional[Dict]]:
128
+ """检查二维码扫描状态"""
129
+ try:
130
+ url = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
131
+ params = {'qrcode_key': oauth_key}
132
+ response = self.session.get(url, params=params)
133
+
134
+ if response.status_code == 200:
135
+ data = response.json()
136
+ code = data.get('code')
137
+
138
+ if code == 0: # 登录成功
139
+ # 提取Cookie
140
+ cookies = self._extract_cookies_from_response(response)
141
+ if cookies:
142
+ update_user_cookies(cookies)
143
+ return True, data
144
+ elif code == 86101: # 二维码未扫描
145
+ return False, None
146
+ elif code == 86090: # 二维码已扫描,等待确认
147
+ return False, None
148
+ elif code == 86038: # 二维码已过期
149
+ return False, {'expired': True}
150
+
151
+ return False, None
152
+ except Exception as e:
153
+ self.logger.error(f"检查二维码状态失败: {e}")
154
+ return False, None
155
+
156
+ def password_login(self, username: str, password: str) -> bool:
157
+ """账号密码登录(需要验证码处理,这里简化实现)"""
158
+ # 注意:B站密码登录需要处理验证码,这里只提供框架
159
+ self.logger.warning("密码登录功能需要处理验证码,建议使用二维码登录")
160
+ return False
161
+
162
+ def _extract_cookies_from_response(self, response: requests.Response) -> Dict[str, str]:
163
+ """从响应中提取Cookie"""
164
+ cookies = {}
165
+ for cookie in response.cookies:
166
+ if cookie.name in ['SESSDATA', 'bili_jct', 'DedeUserID', 'buvid3']:
167
+ cookies[cookie.name] = cookie.value
168
+ return cookies
169
+
170
+ def set_cookies(self, cookies: Dict[str, str]) -> bool:
171
+ """设置Cookie到session"""
172
+ try:
173
+ self.session.cookies.update(cookies)
174
+ # 同时更新配置中的Cookie
175
+ return update_user_cookies(cookies)
176
+ except Exception as e:
177
+ self.logger.error(f"设置Cookie失败: {e}")
178
+ return False
179
+
180
+ def get_session(self) -> requests.Session:
181
+ """获取当前session"""
182
+ return self.session
183
+
184
+ def qrcode_login(self, timeout: int = 120) -> bool:
185
+ """二维码登录流程"""
186
+ print("开始二维码登录...")
187
+
188
+ # 获取二维码
189
+ qrcode_url, oauth_key = self.get_qrcode_login_url()
190
+ if not qrcode_url or not oauth_key:
191
+ print("获取二维码失败")
192
+ return False
193
+
194
+ # 生成并显示二维码
195
+ qrcode_image = self.generate_qrcode_image(qrcode_url)
196
+ if qrcode_image:
197
+ print("请使用B站APP扫描二维码登录")
198
+ print(f"二维码数据: {qrcode_image[:100]}...") # 简化显示
199
+ else:
200
+ print(f"请手动打开链接: {qrcode_url}")
201
+
202
+ # 轮询登录状态
203
+ start_time = time.time()
204
+ while time.time() - start_time < timeout:
205
+ success, status_data = self.check_qrcode_status(oauth_key)
206
+
207
+ if success:
208
+ print("登录成功!")
209
+ return True
210
+ elif status_data and status_data.get('expired'):
211
+ print("二维码已过期,重新生成...")
212
+ return self.qrcode_login(timeout)
213
+
214
+ print("等待扫描..." if not status_data else "已扫描,等待确认...")
215
+ time.sleep(3)
216
+
217
+ print("登录超时")
218
+ return False
219
+
220
+ # 全局登录管理器实例
221
+ login_manager = LoginManager()
222
+
223
+ def init_login() -> bool:
224
+ """初始化登录状态"""
225
+ # 首先尝试使用配置中的Cookie
226
+ cookies = get_user_cookies()
227
+ if cookies:
228
+ login_manager.set_cookies(cookies)
229
+ if login_manager.check_login_status():
230
+ print("使用保存的Cookie登录成功")
231
+ return True
232
+ else:
233
+ print("保存的Cookie已失效")
234
+
235
+ # 如果需要自动登录
236
+ login_method = get_config('user.login_method', 'qrcode')
237
+ if login_method == 'qrcode':
238
+ success = login_manager.qrcode_login()
239
+ if success:
240
+ # 登录成功后,确保session包含最新的Cookie
241
+ cookies = get_user_cookies()
242
+ if cookies:
243
+ login_manager.set_cookies(cookies)
244
+ return success
245
+ elif login_method == 'password':
246
+ username = get_config('user.username')
247
+ password = get_config('user.password')
248
+ if username and password:
249
+ success = login_manager.password_login(username, password)
250
+ if success:
251
+ # 登录成功后,确保session包含最新的Cookie
252
+ cookies = get_user_cookies()
253
+ if cookies:
254
+ login_manager.set_cookies(cookies)
255
+ return success
256
+
257
+ return False
258
+
259
+ def get_session() -> requests.Session:
260
+ """获取登录session"""
261
+ return login_manager.get_session()
@@ -0,0 +1,207 @@
1
+ import requests
2
+ import os
3
+ import time
4
+ import threading
5
+ import logging
6
+ from typing import Dict, Any, Optional, Callable
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+
9
+ from config.config_manager import get_config
10
+ from modules.login_manager import get_session
11
+
12
+ class StreamDownloader:
13
+ def __init__(self):
14
+ self.session = get_session()
15
+ self.logger = logging.getLogger(__name__)
16
+ self.download_config = get_config('download', {})
17
+
18
+ def download_file(self, url: str, filename: str,
19
+ chunk_size: int = 8192,
20
+ progress_callback: Optional[Callable] = None) -> bool:
21
+ """下载单个文件"""
22
+ try:
23
+ # 检查文件是否已存在(支持断点续传)
24
+ file_size = 0
25
+ if os.path.exists(filename):
26
+ file_size = os.path.getsize(filename)
27
+ self.logger.info(f"文件已存在,尝试断点续传: {filename} ({file_size} bytes)")
28
+
29
+ headers = {}
30
+ if file_size > 0:
31
+ headers['Range'] = f'bytes={file_size}-'
32
+
33
+ response = self.session.get(url, headers=headers, stream=True, timeout=self.download_config.get('timeout', 30))
34
+
35
+ if response.status_code in [200, 206]: # 200 OK or 206 Partial Content
36
+ total_size = int(response.headers.get('content-length', 0)) + file_size
37
+ downloaded_size = file_size
38
+
39
+ mode = 'ab' if file_size > 0 else 'wb'
40
+ with open(filename, mode) as f:
41
+ for chunk in response.iter_content(chunk_size=chunk_size):
42
+ if chunk:
43
+ f.write(chunk)
44
+ downloaded_size += len(chunk)
45
+
46
+ # 调用进度回调
47
+ if progress_callback:
48
+ progress_callback(downloaded_size, total_size, filename)
49
+
50
+ self.logger.info(f"下载完成: {filename}")
51
+ return True
52
+ else:
53
+ self.logger.error(f"下载失败: HTTP {response.status_code}")
54
+ return False
55
+
56
+ except Exception as e:
57
+ self.logger.error(f"下载文件异常 {filename}: {e}")
58
+ return False
59
+
60
+ def download_with_retry(self, url: str, filename: str,
61
+ max_retries: int = 3,
62
+ progress_callback: Optional[Callable] = None) -> bool:
63
+ """带重试机制的下载"""
64
+ retries = 0
65
+ while retries < max_retries:
66
+ try:
67
+ if self.download_file(url, filename, progress_callback=progress_callback):
68
+ return True
69
+ except Exception as e:
70
+ self.logger.warning(f"下载尝试 {retries + 1} 失败: {e}")
71
+
72
+ retries += 1
73
+ if retries < max_retries:
74
+ wait_time = 2 ** retries # 指数退避
75
+ self.logger.info(f"等待 {wait_time} 秒后重试...")
76
+ time.sleep(wait_time)
77
+
78
+ self.logger.error(f"下载失败,已达到最大重试次数: {max_retries}")
79
+ return False
80
+
81
+ def download_multiple_files(self, file_list: list, max_workers: int = 4) -> Dict[str, bool]:
82
+ """多线程下载多个文件"""
83
+ results = {}
84
+
85
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
86
+ # 提交所有下载任务
87
+ future_to_file = {
88
+ executor.submit(
89
+ self.download_with_retry,
90
+ file_info['url'],
91
+ file_info['filename'],
92
+ self.download_config.get('retry_times', 3)
93
+ ): file_info['filename']
94
+ for file_info in file_list
95
+ }
96
+
97
+ # 收集结果
98
+ for future in as_completed(future_to_file):
99
+ filename = future_to_file[future]
100
+ try:
101
+ results[filename] = future.result()
102
+ except Exception as e:
103
+ self.logger.error(f"下载任务异常 {filename}: {e}")
104
+ results[filename] = False
105
+
106
+ return results
107
+
108
+ def download_video_stream(self, stream_data: Dict[str, Any], title: str,
109
+ bvid: str, output_base_dir: str = 'download') -> Dict[str, str]:
110
+ """下载视频流(支持DASH和MP4格式)"""
111
+ try:
112
+ # 创建BV号子目录
113
+ from utils.common_utils import truncate_title, get_bvid_directory, ensure_directory
114
+ output_dir = get_bvid_directory(bvid, output_base_dir)
115
+ ensure_directory(output_dir)
116
+
117
+ # 截断标题
118
+ truncated_title = truncate_title(title)
119
+ downloaded_files = {}
120
+
121
+ if 'dash' in stream_data:
122
+ # DASH格式:分别下载视频和音频
123
+ dash_data = stream_data['dash']
124
+
125
+ # 下载视频流
126
+ video_url = dash_data['video'][0]['baseUrl']
127
+ video_filename = os.path.join(output_dir, f"{truncated_title}_video.m4s")
128
+ downloaded_files['video'] = video_filename
129
+
130
+ print(f"下载视频流: {video_filename}")
131
+ if not self.download_with_retry(video_url, video_filename):
132
+ raise Exception("视频流下载失败")
133
+
134
+ # 下载音频流
135
+ audio_url = dash_data['audio'][0]['baseUrl']
136
+ audio_filename = os.path.join(output_dir, f"{truncated_title}_audio.m4s")
137
+ downloaded_files['audio'] = audio_filename
138
+
139
+ print(f"下载音频流: {audio_filename}")
140
+ if not self.download_with_retry(audio_url, audio_filename):
141
+ raise Exception("音频流下载失败")
142
+
143
+ elif 'durl' in stream_data:
144
+ # MP4格式:直接下载完整文件
145
+ durl = stream_data['durl'][0]
146
+ video_url = durl['url']
147
+ final_filename = os.path.join(output_dir, f"{truncated_title}.mp4")
148
+ downloaded_files['video'] = final_filename
149
+
150
+ print(f"下载MP4视频: {final_filename}")
151
+ if not self.download_with_retry(video_url, final_filename):
152
+ raise Exception("MP4视频下载失败")
153
+
154
+ else:
155
+ raise Exception("未知的视频流格式")
156
+
157
+ return downloaded_files
158
+
159
+ except Exception as e:
160
+ self.logger.error(f"下载视频流失败: {e}")
161
+ # 清理可能已下载的部分文件
162
+ for filename in downloaded_files.values():
163
+ if os.path.exists(filename):
164
+ try:
165
+ os.remove(filename)
166
+ except:
167
+ pass
168
+ raise
169
+
170
+ def create_progress_callback(self, total_files: int = 1) -> Callable:
171
+ """创建进度回调函数"""
172
+ lock = threading.Lock()
173
+ completed_files = 0
174
+
175
+ def progress_callback(downloaded: int, total: int, filename: str):
176
+ nonlocal completed_files
177
+ with lock:
178
+ if total > 0:
179
+ percentage = (downloaded / total) * 100
180
+ print(f"\r{filename}: {percentage:.1f}% ({downloaded}/{total} bytes)", end='')
181
+
182
+ if downloaded >= total:
183
+ completed_files += 1
184
+ print(f"\n文件下载完成 ({completed_files}/{total_files})")
185
+
186
+ return progress_callback
187
+
188
+ # 全局下载器实例
189
+ stream_downloader = StreamDownloader()
190
+
191
+ def download_file(url: str, filename: str, progress_callback: Optional[Callable] = None) -> bool:
192
+ """下载单个文件"""
193
+ return stream_downloader.download_file(url, filename, progress_callback=progress_callback)
194
+
195
+ def download_with_retry(url: str, filename: str, max_retries: int = 3,
196
+ progress_callback: Optional[Callable] = None) -> bool:
197
+ """带重试机制的下载"""
198
+ return stream_downloader.download_with_retry(url, filename, max_retries, progress_callback)
199
+
200
+ def download_video_stream(stream_data: Dict[str, Any], title: str, bvid: str,
201
+ output_base_dir: str = 'download') -> Dict[str, str]:
202
+ """下载视频流"""
203
+ return stream_downloader.download_video_stream(stream_data, title, bvid, output_base_dir)
204
+
205
+ def create_progress_callback(total_files: int = 1) -> Callable:
206
+ """创建进度回调函数"""
207
+ return stream_downloader.create_progress_callback(total_files)