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.
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')