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.
- bili_dl-1.0.0.dist-info/METADATA +257 -0
- bili_dl-1.0.0.dist-info/RECORD +14 -0
- bili_dl-1.0.0.dist-info/WHEEL +5 -0
- bili_dl-1.0.0.dist-info/entry_points.txt +2 -0
- bili_dl-1.0.0.dist-info/licenses/LICENSE +21 -0
- bili_dl-1.0.0.dist-info/top_level.txt +4 -0
- config/config.json +34 -0
- config/config_manager.py +154 -0
- main.py +91 -0
- modules/ffmpeg_integration.py +229 -0
- modules/login_manager.py +261 -0
- modules/stream_downloader.py +207 -0
- modules/video_info.py +315 -0
- utils/common_utils.py +199 -0
modules/video_info.py
ADDED
@@ -0,0 +1,315 @@
|
|
1
|
+
import requests
|
2
|
+
import json
|
3
|
+
import time
|
4
|
+
import hashlib
|
5
|
+
import logging
|
6
|
+
from typing import Dict, Any, Optional, List
|
7
|
+
from urllib.parse import urlencode
|
8
|
+
|
9
|
+
from config.config_manager import get_config
|
10
|
+
from modules.login_manager import get_session
|
11
|
+
|
12
|
+
class VideoInfoManager:
|
13
|
+
def __init__(self):
|
14
|
+
self.session = get_session()
|
15
|
+
self.logger = logging.getLogger(__name__)
|
16
|
+
self.wbi_salt = "" # WBI签名盐值
|
17
|
+
|
18
|
+
def get_video_info(self, bvid: str) -> Dict[str, Any]:
|
19
|
+
"""获取视频基本信息"""
|
20
|
+
try:
|
21
|
+
url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
|
22
|
+
response = self.session.get(url)
|
23
|
+
|
24
|
+
if response.status_code == 200:
|
25
|
+
data = response.json()
|
26
|
+
if data.get('code') == 0:
|
27
|
+
return data['data']
|
28
|
+
else:
|
29
|
+
raise Exception(f"获取视频信息失败: {data.get('message', '未知错误')}")
|
30
|
+
else:
|
31
|
+
raise Exception(f"HTTP请求失败: {response.status_code}")
|
32
|
+
except Exception as e:
|
33
|
+
self.logger.error(f"获取视频信息异常: {e}")
|
34
|
+
raise
|
35
|
+
|
36
|
+
def get_wbi_salt(self) -> Optional[str]:
|
37
|
+
"""获取WBI签名盐值"""
|
38
|
+
try:
|
39
|
+
url = "https://api.bilibili.com/x/web-interface/nav"
|
40
|
+
response = self.session.get(url)
|
41
|
+
|
42
|
+
if response.status_code == 200:
|
43
|
+
data = response.json()
|
44
|
+
if data.get('code') == 0:
|
45
|
+
wbi_img = data['data']['wbi_img']
|
46
|
+
# 从URL中提取盐值
|
47
|
+
if 'key1' in wbi_img['img_url'] and 'key2' in wbi_img['sub_url']:
|
48
|
+
key1 = wbi_img['img_url'].split('key1=')[1].split('&')[0]
|
49
|
+
key2 = wbi_img['sub_url'].split('key2=')[1].split('&')[0]
|
50
|
+
return key1 + key2
|
51
|
+
return None
|
52
|
+
except Exception as e:
|
53
|
+
self.logger.error(f"获取WBI盐值失败: {e}")
|
54
|
+
return None
|
55
|
+
|
56
|
+
def wbi_sign(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
57
|
+
"""WBI签名算法"""
|
58
|
+
if not self.wbi_salt:
|
59
|
+
self.wbi_salt = self.get_wbi_salt() or ""
|
60
|
+
|
61
|
+
# 添加时间戳
|
62
|
+
params['wts'] = int(time.time())
|
63
|
+
|
64
|
+
# 参数排序并过滤
|
65
|
+
filtered_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
66
|
+
sorted_params = dict(sorted(filtered_params.items()))
|
67
|
+
|
68
|
+
# 生成签名
|
69
|
+
query_string = urlencode(sorted_params)
|
70
|
+
sign_string = query_string + self.wbi_salt
|
71
|
+
w_rid = hashlib.md5(sign_string.encode()).hexdigest()
|
72
|
+
|
73
|
+
# 添加签名到参数
|
74
|
+
signed_params = sorted_params.copy()
|
75
|
+
signed_params['w_rid'] = w_rid
|
76
|
+
return signed_params
|
77
|
+
|
78
|
+
def get_video_stream_url(self, bvid: str, cid: int, quality: int = 80, fnval: int = 4048) -> Dict[str, Any]:
|
79
|
+
"""
|
80
|
+
获取视频流地址
|
81
|
+
quality: 清晰度代码
|
82
|
+
fnval: 视频格式标识 (4048 = 所有DASH格式)
|
83
|
+
"""
|
84
|
+
try:
|
85
|
+
# 构建参数
|
86
|
+
params = {
|
87
|
+
'bvid': bvid,
|
88
|
+
'cid': cid,
|
89
|
+
'qn': quality,
|
90
|
+
'fnval': fnval,
|
91
|
+
'fnver': 0,
|
92
|
+
'fourk': 1,
|
93
|
+
'otype': 'json',
|
94
|
+
'platform': 'pc'
|
95
|
+
}
|
96
|
+
|
97
|
+
# WBI签名
|
98
|
+
signed_params = self.wbi_sign(params)
|
99
|
+
|
100
|
+
url = "https://api.bilibili.com/x/player/wbi/playurl"
|
101
|
+
response = self.session.get(url, params=signed_params)
|
102
|
+
|
103
|
+
if response.status_code == 200:
|
104
|
+
data = response.json()
|
105
|
+
if data.get('code') == 0:
|
106
|
+
return data['data']
|
107
|
+
else:
|
108
|
+
raise Exception(f"获取视频流失败: {data.get('message', '未知错误')}")
|
109
|
+
else:
|
110
|
+
raise Exception(f"HTTP请求失败: {response.status_code}")
|
111
|
+
except Exception as e:
|
112
|
+
self.logger.error(f"获取视频流异常: {e}")
|
113
|
+
raise
|
114
|
+
|
115
|
+
def get_highest_quality_available(self, stream_data: Dict[str, Any]) -> int:
|
116
|
+
"""根据账号权限获取可用的最高清晰度"""
|
117
|
+
try:
|
118
|
+
if 'accept_quality' in stream_data:
|
119
|
+
available_qualities = stream_data['accept_quality']
|
120
|
+
|
121
|
+
# 清晰度优先级(从高到低)
|
122
|
+
quality_priority = [
|
123
|
+
127, # 8K超高清
|
124
|
+
126, # 杜比视界
|
125
|
+
125, # HDR真彩色
|
126
|
+
120, # 4K超清
|
127
|
+
116, # 1080P60高帧率
|
128
|
+
112, # 1080P+高码率
|
129
|
+
100, # 智能修复
|
130
|
+
80, # 1080P高清
|
131
|
+
74, # 720P60高帧率
|
132
|
+
64, # 720P高清
|
133
|
+
32, # 480P清晰
|
134
|
+
16, # 360P流畅
|
135
|
+
6 # 240P极速
|
136
|
+
]
|
137
|
+
|
138
|
+
# 找到账号权限内可用的最高清晰度
|
139
|
+
for quality in quality_priority:
|
140
|
+
if quality in available_qualities:
|
141
|
+
return quality
|
142
|
+
|
143
|
+
# 如果没有找到优先级的清晰度,返回列表中的最高清晰度
|
144
|
+
return max(available_qualities) if available_qualities else 64
|
145
|
+
|
146
|
+
return 64 # 默认返回720P
|
147
|
+
except Exception as e:
|
148
|
+
self.logger.error(f"获取最高清晰度失败: {e}")
|
149
|
+
return 64
|
150
|
+
|
151
|
+
def get_video_quality_list(self, bvid: str, cid: int) -> List[Dict[str, Any]]:
|
152
|
+
"""获取视频可用的清晰度列表"""
|
153
|
+
try:
|
154
|
+
stream_data = self.get_video_stream_url(bvid, cid)
|
155
|
+
|
156
|
+
if 'accept_description' in stream_data and 'accept_quality' in stream_data:
|
157
|
+
qualities = []
|
158
|
+
for desc, quality in zip(stream_data['accept_description'], stream_data['accept_quality']):
|
159
|
+
qualities.append({
|
160
|
+
'quality': quality,
|
161
|
+
'description': desc,
|
162
|
+
'selected': False
|
163
|
+
})
|
164
|
+
return qualities
|
165
|
+
return []
|
166
|
+
except Exception as e:
|
167
|
+
self.logger.error(f"获取清晰度列表失败: {e}")
|
168
|
+
return []
|
169
|
+
|
170
|
+
def get_video_download_info(self, bvid: str) -> Dict[str, Any]:
|
171
|
+
"""获取视频下载信息(整合视频信息和流信息)"""
|
172
|
+
try:
|
173
|
+
# 获取视频基本信息
|
174
|
+
video_info = self.get_video_info(bvid)
|
175
|
+
title = video_info['title']
|
176
|
+
cid = video_info['cid']
|
177
|
+
|
178
|
+
# 获取流信息
|
179
|
+
stream_data = self.get_video_stream_url(bvid, cid)
|
180
|
+
|
181
|
+
# 获取最高清晰度
|
182
|
+
best_quality = self.get_highest_quality_available(stream_data)
|
183
|
+
|
184
|
+
# 使用最高清晰度重新获取流信息
|
185
|
+
if best_quality != 80:
|
186
|
+
stream_data = self.get_video_stream_url(bvid, cid, quality=best_quality)
|
187
|
+
|
188
|
+
return {
|
189
|
+
'video_info': video_info,
|
190
|
+
'stream_data': stream_data,
|
191
|
+
'best_quality': best_quality,
|
192
|
+
'title': title,
|
193
|
+
'cid': cid,
|
194
|
+
'bvid': bvid
|
195
|
+
}
|
196
|
+
except Exception as e:
|
197
|
+
self.logger.error(f"获取视频下载信息失败: {e}")
|
198
|
+
raise
|
199
|
+
|
200
|
+
def get_video_download_info_with_fallback(self, bvid: str, ignore_login: bool = False) -> Dict[str, Any]:
|
201
|
+
"""
|
202
|
+
获取视频下载信息,支持登录状态降级
|
203
|
+
|
204
|
+
Args:
|
205
|
+
bvid: 视频BV号
|
206
|
+
ignore_login: 是否忽略登录状态,强制使用访客模式
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
视频下载信息,包含降级后的清晰度信息
|
210
|
+
"""
|
211
|
+
try:
|
212
|
+
# 获取视频基本信息(不需要登录)
|
213
|
+
video_info = self.get_video_info(bvid)
|
214
|
+
title = video_info['title']
|
215
|
+
cid = video_info['cid']
|
216
|
+
|
217
|
+
# 清晰度降级策略(从高到低)
|
218
|
+
fallback_qualities = [
|
219
|
+
127, # 8K超高清
|
220
|
+
126, # 杜比视界
|
221
|
+
125, # HDR真彩色
|
222
|
+
120, # 4K超清
|
223
|
+
116, # 1080P60高帧率
|
224
|
+
112, # 1080P+高码率
|
225
|
+
100, # 智能修复
|
226
|
+
80, # 1080P高清
|
227
|
+
74, # 720P60高帧率
|
228
|
+
64, # 720P高清
|
229
|
+
32, # 480P清晰
|
230
|
+
16, # 360P流畅
|
231
|
+
6 # 240P极速
|
232
|
+
]
|
233
|
+
|
234
|
+
last_exception = None
|
235
|
+
used_quality = None
|
236
|
+
|
237
|
+
# 尝试从最高清晰度开始降级
|
238
|
+
for quality in fallback_qualities:
|
239
|
+
try:
|
240
|
+
# 获取流信息
|
241
|
+
stream_data = self.get_video_stream_url(bvid, cid, quality=quality)
|
242
|
+
|
243
|
+
# 检查是否成功获取到流信息
|
244
|
+
if stream_data and 'dash' in stream_data or 'durl' in stream_data:
|
245
|
+
used_quality = quality
|
246
|
+
break
|
247
|
+
|
248
|
+
except Exception as e:
|
249
|
+
last_exception = e
|
250
|
+
self.logger.debug(f"清晰度 {quality} 获取失败: {e}")
|
251
|
+
continue
|
252
|
+
|
253
|
+
# 如果所有清晰度都失败,抛出最后一个异常
|
254
|
+
if used_quality is None and last_exception:
|
255
|
+
raise last_exception
|
256
|
+
|
257
|
+
# 如果使用了降级后的清晰度,重新获取流信息确保一致性
|
258
|
+
if used_quality != 80:
|
259
|
+
stream_data = self.get_video_stream_url(bvid, cid, quality=used_quality)
|
260
|
+
|
261
|
+
return {
|
262
|
+
'video_info': video_info,
|
263
|
+
'stream_data': stream_data,
|
264
|
+
'best_quality': used_quality or 64, # 默认720P
|
265
|
+
'title': title,
|
266
|
+
'cid': cid,
|
267
|
+
'bvid': bvid,
|
268
|
+
'is_fallback': used_quality != self.get_highest_quality_available(stream_data) if used_quality else False
|
269
|
+
}
|
270
|
+
|
271
|
+
except Exception as e:
|
272
|
+
self.logger.error(f"获取视频下载信息(降级模式)失败: {e}")
|
273
|
+
|
274
|
+
# 如果忽略登录状态,尝试使用最低清晰度
|
275
|
+
if ignore_login:
|
276
|
+
try:
|
277
|
+
# 使用最低清晰度(360P)作为最后手段
|
278
|
+
stream_data = self.get_video_stream_url(bvid, cid, quality=16)
|
279
|
+
return {
|
280
|
+
'video_info': video_info,
|
281
|
+
'stream_data': stream_data,
|
282
|
+
'best_quality': 16,
|
283
|
+
'title': title,
|
284
|
+
'cid': cid,
|
285
|
+
'bvid': bvid,
|
286
|
+
'is_fallback': True
|
287
|
+
}
|
288
|
+
except Exception:
|
289
|
+
# 如果连最低清晰度都失败,抛出原始异常
|
290
|
+
raise e
|
291
|
+
else:
|
292
|
+
raise
|
293
|
+
|
294
|
+
# 全局视频信息管理器实例
|
295
|
+
video_info_manager = VideoInfoManager()
|
296
|
+
|
297
|
+
def get_video_info(bvid: str) -> Dict[str, Any]:
|
298
|
+
"""获取视频信息"""
|
299
|
+
return video_info_manager.get_video_info(bvid)
|
300
|
+
|
301
|
+
def get_video_stream_url(bvid: str, cid: int, quality: int = 80, fnval: int = 4048) -> Dict[str, Any]:
|
302
|
+
"""获取视频流地址"""
|
303
|
+
return video_info_manager.get_video_stream_url(bvid, cid, quality, fnval)
|
304
|
+
|
305
|
+
def get_highest_quality_available(stream_data: Dict[str, Any]) -> int:
|
306
|
+
"""获取最高可用清晰度"""
|
307
|
+
return video_info_manager.get_highest_quality_available(stream_data)
|
308
|
+
|
309
|
+
def get_video_download_info(bvid: str) -> Dict[str, Any]:
|
310
|
+
"""获取视频下载信息"""
|
311
|
+
return video_info_manager.get_video_download_info(bvid)
|
312
|
+
|
313
|
+
def get_video_download_info_with_fallback(bvid: str, ignore_login: bool = False) -> Dict[str, Any]:
|
314
|
+
"""获取视频下载信息,支持登录状态降级"""
|
315
|
+
return video_info_manager.get_video_download_info_with_fallback(bvid, ignore_login)
|
utils/common_utils.py
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
import os
|
2
|
+
import re
|
3
|
+
import logging
|
4
|
+
import json
|
5
|
+
from typing import Dict, Any, Optional, List
|
6
|
+
from pathlib import Path
|
7
|
+
|
8
|
+
def setup_logging(level: str = "INFO", log_file: Optional[str] = None) -> None:
|
9
|
+
"""设置日志配置"""
|
10
|
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
11
|
+
|
12
|
+
# 基础配置
|
13
|
+
logging.basicConfig(
|
14
|
+
level=log_level,
|
15
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
16
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
17
|
+
)
|
18
|
+
|
19
|
+
# 文件日志(如果指定)
|
20
|
+
if log_file:
|
21
|
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
22
|
+
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
23
|
+
logging.getLogger().addHandler(file_handler)
|
24
|
+
|
25
|
+
def sanitize_filename(filename: str, max_length: int = 100) -> str:
|
26
|
+
"""清理文件名,移除非法字符"""
|
27
|
+
# 移除非法字符
|
28
|
+
sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
29
|
+
# 移除连续的下划线
|
30
|
+
sanitized = re.sub(r'_+', '_', sanitized)
|
31
|
+
# 移除首尾的下划线和空格
|
32
|
+
sanitized = sanitized.strip(' _')
|
33
|
+
# 限制长度
|
34
|
+
if len(sanitized) > max_length:
|
35
|
+
sanitized = sanitized[:max_length].rstrip(' _')
|
36
|
+
# 确保不为空
|
37
|
+
if not sanitized:
|
38
|
+
sanitized = "unnamed"
|
39
|
+
return sanitized
|
40
|
+
|
41
|
+
def truncate_title(title: str, max_words: int = 8) -> str:
|
42
|
+
"""截断标题,保留前N个字,其余用省略号"""
|
43
|
+
if len(title) <= max_words:
|
44
|
+
return title
|
45
|
+
return title[:max_words] + "..."
|
46
|
+
|
47
|
+
def get_bvid_directory(bvid: str, base_dir: str = "download") -> str:
|
48
|
+
"""获取BV号对应的下载目录"""
|
49
|
+
return os.path.join(base_dir, bvid)
|
50
|
+
|
51
|
+
def format_file_size(size_bytes: int) -> str:
|
52
|
+
"""格式化文件大小"""
|
53
|
+
if size_bytes == 0:
|
54
|
+
return "0B"
|
55
|
+
|
56
|
+
size_names = ["B", "KB", "MB", "GB", "TB"]
|
57
|
+
i = 0
|
58
|
+
size = float(size_bytes)
|
59
|
+
|
60
|
+
while size >= 1024 and i < len(size_names) - 1:
|
61
|
+
size /= 1024
|
62
|
+
i += 1
|
63
|
+
|
64
|
+
return f"{size:.2f} {size_names[i]}"
|
65
|
+
|
66
|
+
def format_duration(seconds: int) -> str:
|
67
|
+
"""格式化时间持续时间"""
|
68
|
+
hours = seconds // 3600
|
69
|
+
minutes = (seconds % 3600) // 60
|
70
|
+
seconds = seconds % 60
|
71
|
+
|
72
|
+
if hours > 0:
|
73
|
+
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
74
|
+
else:
|
75
|
+
return f"{minutes}:{seconds:02d}"
|
76
|
+
|
77
|
+
def ensure_directory(directory: str) -> bool:
|
78
|
+
"""确保目录存在"""
|
79
|
+
try:
|
80
|
+
Path(directory).mkdir(parents=True, exist_ok=True)
|
81
|
+
return True
|
82
|
+
except Exception as e:
|
83
|
+
logging.error(f"创建目录失败 {directory}: {e}")
|
84
|
+
return False
|
85
|
+
|
86
|
+
def read_json_file(file_path: str) -> Optional[Dict[str, Any]]:
|
87
|
+
"""读取JSON文件"""
|
88
|
+
try:
|
89
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
90
|
+
return json.load(f)
|
91
|
+
except Exception as e:
|
92
|
+
logging.error(f"读取JSON文件失败 {file_path}: {e}")
|
93
|
+
return None
|
94
|
+
|
95
|
+
def write_json_file(file_path: str, data: Dict[str, Any], indent: int = 2) -> bool:
|
96
|
+
"""写入JSON文件"""
|
97
|
+
try:
|
98
|
+
ensure_directory(os.path.dirname(file_path))
|
99
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
100
|
+
json.dump(data, f, indent=indent, ensure_ascii=False)
|
101
|
+
return True
|
102
|
+
except Exception as e:
|
103
|
+
logging.error(f"写入JSON文件失败 {file_path}: {e}")
|
104
|
+
return False
|
105
|
+
|
106
|
+
def parse_bvid(input_str: str) -> Optional[str]:
|
107
|
+
"""解析BV号"""
|
108
|
+
# 匹配BV号格式
|
109
|
+
bv_match = re.search(r'(BV[0-9A-Za-z]{10})', input_str)
|
110
|
+
if bv_match:
|
111
|
+
return bv_match.group(1)
|
112
|
+
|
113
|
+
# 匹配URL中的BV号
|
114
|
+
url_match = re.search(r'bilibili\.com/video/(BV[0-9A-Za-z]{10})', input_str)
|
115
|
+
if url_match:
|
116
|
+
return url_match.group(1)
|
117
|
+
|
118
|
+
return None
|
119
|
+
|
120
|
+
def parse_av_id(input_str: str) -> Optional[str]:
|
121
|
+
"""解析AV号"""
|
122
|
+
# 匹配AV号格式
|
123
|
+
av_match = re.search(r'(av\d+)', input_str, re.IGNORECASE)
|
124
|
+
if av_match:
|
125
|
+
return av_match.group(1).lower()
|
126
|
+
|
127
|
+
# 匹配URL中的AV号
|
128
|
+
url_match = re.search(r'bilibili\.com/video/(av\d+)', input_str, re.IGNORECASE)
|
129
|
+
if url_match:
|
130
|
+
return url_match.group(1).lower()
|
131
|
+
|
132
|
+
return None
|
133
|
+
|
134
|
+
def is_valid_url(url: str) -> bool:
|
135
|
+
"""检查是否为有效的URL"""
|
136
|
+
url_pattern = re.compile(
|
137
|
+
r'^(https?://)?' # http:// or https://
|
138
|
+
r'(([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+)|' # domain...
|
139
|
+
r'localhost|' # localhost...
|
140
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
141
|
+
r'(?::\d+)?' # optional port
|
142
|
+
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
143
|
+
|
144
|
+
return bool(url_pattern.match(url))
|
145
|
+
|
146
|
+
def human_readable_speed(speed_bytes_per_sec: float) -> str:
|
147
|
+
"""格式化下载速度"""
|
148
|
+
if speed_bytes_per_sec <= 0:
|
149
|
+
return "0 B/s"
|
150
|
+
|
151
|
+
units = ["B/s", "KB/s", "MB/s", "GB/s"]
|
152
|
+
speed = speed_bytes_per_sec
|
153
|
+
unit_index = 0
|
154
|
+
|
155
|
+
while speed >= 1024 and unit_index < len(units) - 1:
|
156
|
+
speed /= 1024
|
157
|
+
unit_index += 1
|
158
|
+
|
159
|
+
return f"{speed:.2f} {units[unit_index]}"
|
160
|
+
|
161
|
+
def progress_bar(current: int, total: int, length: int = 50) -> str:
|
162
|
+
"""生成进度条字符串"""
|
163
|
+
if total <= 0:
|
164
|
+
return "[{}] 0%".format(" " * length)
|
165
|
+
|
166
|
+
percent = current / total
|
167
|
+
filled_length = int(length * percent)
|
168
|
+
bar = "█" * filled_length + " " * (length - filled_length)
|
169
|
+
return "[{}] {:.1f}%".format(bar, percent * 100)
|
170
|
+
|
171
|
+
def get_file_extension(url: str) -> str:
|
172
|
+
"""从URL获取文件扩展名"""
|
173
|
+
# 移除查询参数
|
174
|
+
clean_url = url.split('?')[0]
|
175
|
+
# 获取扩展名
|
176
|
+
ext = os.path.splitext(clean_url)[1].lower()
|
177
|
+
return ext if ext else '.bin'
|
178
|
+
|
179
|
+
def chunked_download_info(total_size: int, chunk_size: int = 1024 * 1024) -> List[Dict[str, int]]:
|
180
|
+
"""生成分块下载信息"""
|
181
|
+
chunks = []
|
182
|
+
start = 0
|
183
|
+
|
184
|
+
while start < total_size:
|
185
|
+
end = min(start + chunk_size - 1, total_size - 1)
|
186
|
+
chunks.append({'start': start, 'end': end, 'size': end - start + 1})
|
187
|
+
start = end + 1
|
188
|
+
|
189
|
+
return chunks
|
190
|
+
|
191
|
+
def validate_cookies(cookies: Dict[str, str]) -> bool:
|
192
|
+
"""验证Cookie是否有效"""
|
193
|
+
required_cookies = ['SESSDATA', 'bili_jct', 'DedeUserID']
|
194
|
+
return all(cookie in cookies and cookies[cookie] for cookie in required_cookies)
|
195
|
+
|
196
|
+
def format_timestamp(timestamp: int) -> str:
|
197
|
+
"""格式化时间戳"""
|
198
|
+
from datetime import datetime
|
199
|
+
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|