bbdown 0.0.1__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.
Files changed (80) hide show
  1. bbdown/__init__.py +5 -0
  2. bbdown/__main__.py +5 -0
  3. bbdown/_version.py +3 -0
  4. bbdown/app_api.py +270 -0
  5. bbdown/archives.py +52 -0
  6. bbdown/bv.py +47 -0
  7. bbdown/cli.py +181 -0
  8. bbdown/config.py +63 -0
  9. bbdown/credentials.py +80 -0
  10. bbdown/danmaku.py +198 -0
  11. bbdown/doctor.py +132 -0
  12. bbdown/downloader.py +334 -0
  13. bbdown/external.py +215 -0
  14. bbdown/fetchers/__init__.py +29 -0
  15. bbdown/fetchers/bangumi.py +112 -0
  16. bbdown/fetchers/base.py +27 -0
  17. bbdown/fetchers/cheese.py +65 -0
  18. bbdown/fetchers/factory.py +56 -0
  19. bbdown/fetchers/favorites.py +125 -0
  20. bbdown/fetchers/intl_bangumi.py +140 -0
  21. bbdown/fetchers/media_list.py +151 -0
  22. bbdown/fetchers/normal.py +173 -0
  23. bbdown/fetchers/space.py +85 -0
  24. bbdown/filenames.py +264 -0
  25. bbdown/http.py +269 -0
  26. bbdown/input.py +213 -0
  27. bbdown/jsonutil.py +96 -0
  28. bbdown/login.py +232 -0
  29. bbdown/media_semantics.py +264 -0
  30. bbdown/models.py +66 -0
  31. bbdown/muxer.py +376 -0
  32. bbdown/options.py +260 -0
  33. bbdown/paths.py +51 -0
  34. bbdown/playurl.py +218 -0
  35. bbdown/proto/__init__.py +1 -0
  36. bbdown/proto/device.proto +19 -0
  37. bbdown/proto/device_pb2.py +37 -0
  38. bbdown/proto/device_pb2.pyi +37 -0
  39. bbdown/proto/dmviewreply.proto +104 -0
  40. bbdown/proto/dmviewreply_pb2.py +55 -0
  41. bbdown/proto/dmviewreply_pb2.pyi +189 -0
  42. bbdown/proto/dmviewreq.proto +10 -0
  43. bbdown/proto/dmviewreq_pb2.py +37 -0
  44. bbdown/proto/dmviewreq_pb2.pyi +19 -0
  45. bbdown/proto/fawkesreq.proto +8 -0
  46. bbdown/proto/fawkesreq_pb2.py +37 -0
  47. bbdown/proto/fawkesreq_pb2.pyi +15 -0
  48. bbdown/proto/locale.proto +12 -0
  49. bbdown/proto/locale_pb2.py +39 -0
  50. bbdown/proto/locale_pb2.pyi +23 -0
  51. bbdown/proto/metadata.proto +12 -0
  52. bbdown/proto/metadata_pb2.py +37 -0
  53. bbdown/proto/metadata_pb2.pyi +23 -0
  54. bbdown/proto/network.proto +25 -0
  55. bbdown/proto/network_pb2.py +41 -0
  56. bbdown/proto/network_pb2.pyi +46 -0
  57. bbdown/proto/playviewreply.proto +163 -0
  58. bbdown/proto/playviewreply_pb2.py +73 -0
  59. bbdown/proto/playviewreply_pb2.pyi +273 -0
  60. bbdown/proto/playviewreq.proto +25 -0
  61. bbdown/proto/playviewreq_pb2.py +39 -0
  62. bbdown/proto/playviewreq_pb2.pyi +48 -0
  63. bbdown/proto/restriction.proto +14 -0
  64. bbdown/proto/restriction_pb2.py +39 -0
  65. bbdown/proto/restriction_pb2.pyi +26 -0
  66. bbdown/py.typed +1 -0
  67. bbdown/selection.py +135 -0
  68. bbdown/server.py +477 -0
  69. bbdown/stream_parser.py +377 -0
  70. bbdown/subtitle_languages.py +162 -0
  71. bbdown/subtitles.py +277 -0
  72. bbdown/tracks.py +121 -0
  73. bbdown/wbi.py +84 -0
  74. bbdown/workflow.py +1297 -0
  75. bbdown-0.0.1.dist-info/METADATA +145 -0
  76. bbdown-0.0.1.dist-info/RECORD +80 -0
  77. bbdown-0.0.1.dist-info/WHEEL +4 -0
  78. bbdown-0.0.1.dist-info/entry_points.txt +5 -0
  79. bbdown-0.0.1.dist-info/licenses/LICENSE +23 -0
  80. bbdown-0.0.1.dist-info/licenses/NOTICE +8 -0
bbdown/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """BBDown Python 包。"""
2
+
3
+ from bbdown._version import __version__
4
+
5
+ __all__ = ["__version__"]
bbdown/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """支持通过 ``python -m bbdown`` 启动命令行。"""
2
+
3
+ from bbdown.cli import main
4
+
5
+ raise SystemExit(main())
bbdown/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """项目版本的单一源码。"""
2
+
3
+ __version__ = "0.0.1"
bbdown/app_api.py ADDED
@@ -0,0 +1,270 @@
1
+ """APP Proto2 播放请求、gRPC 帧与网页式 DASH 响应转换。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import gzip
7
+ import json
8
+ import os
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+ os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
14
+
15
+ from google.protobuf.message import Message
16
+
17
+ from bbdown.proto import (
18
+ device_pb2,
19
+ fawkesreq_pb2,
20
+ locale_pb2,
21
+ metadata_pb2,
22
+ network_pb2,
23
+ playviewreply_pb2,
24
+ playviewreq_pb2,
25
+ )
26
+
27
+ UGC_PLAY_URL = "https://grpc.biliapi.net/bilibili.app.playurl.v1.PlayURL/PlayView"
28
+ PGC_PLAY_URL = "https://app.bilibili.com/bilibili.pgc.gateway.player.v2.PlayURL/PlayView"
29
+ APP_BUILD = 7_320_200
30
+ APP_VERSION = "7.32.0"
31
+ APP_CHANNEL = "xiaomi_cn_tv.danmaku.bili_zm20200902"
32
+ SPMID = "main.ugc-video-detail.0.0"
33
+ FROM_SPMID = "main.my-history.0.0"
34
+ GRPC_HEADER_SIZE = 5
35
+
36
+
37
+ class AppHTTPClient(Protocol):
38
+ """APP 播放层需要的最小二进制 POST 接口。"""
39
+
40
+ async def post_bytes(
41
+ self,
42
+ url: str,
43
+ body: bytes,
44
+ *,
45
+ headers: Mapping[str, str] | None = None,
46
+ ) -> bytes:
47
+ """发送 gRPC 请求并返回原始响应体。"""
48
+ ...
49
+
50
+
51
+ class GRPCFrameError(ValueError):
52
+ """表示 gRPC message frame 缺失、截断或压缩标志无效。"""
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class AppPlayRequest:
57
+ """APP PlayView 请求的业务字段。"""
58
+
59
+ aid: str
60
+ cid: str
61
+ episode_id: str
62
+ bangumi: bool
63
+ encoding: str
64
+
65
+
66
+ async def request_play_view(
67
+ client: AppHTTPClient,
68
+ request: AppPlayRequest,
69
+ *,
70
+ access_token: str,
71
+ ) -> str:
72
+ """请求 UGC 或 PGC APP 播放接口并返回现有解析器所需 JSON。"""
73
+ identifier = request.episode_id if request.bangumi else request.aid
74
+ payload = build_play_payload(
75
+ int(identifier),
76
+ int(request.cid),
77
+ encoding="HEVC" if request.bangumi else request.encoding,
78
+ )
79
+ response = await client.post_bytes(
80
+ PGC_PLAY_URL if request.bangumi else UGC_PLAY_URL,
81
+ pack_grpc_message(payload),
82
+ headers=build_app_headers(access_token),
83
+ )
84
+ reply = playviewreply_pb2.PlayViewReply()
85
+ reply.ParseFromString(unpack_grpc_message(response))
86
+ return play_reply_to_json(reply)
87
+
88
+
89
+ def build_play_payload(aid: int, cid: int, *, encoding: str) -> bytes:
90
+ """序列化原版固定 qn/fnval/host 与编码偏好的 PlayViewReq。"""
91
+ request = playviewreq_pb2.PlayViewReq(
92
+ epId=aid,
93
+ cid=cid,
94
+ qn=127,
95
+ fnval=4048,
96
+ fourk=True,
97
+ spmid=SPMID,
98
+ fromSpmid=FROM_SPMID,
99
+ preferCodecType=_codec_type(encoding),
100
+ download=0,
101
+ forceHost=2,
102
+ )
103
+ return request.SerializeToString(deterministic=True)
104
+
105
+
106
+ def pack_grpc_message(payload: bytes) -> bytes:
107
+ """将 protobuf wire bytes 压缩并封装为一条 gRPC message。"""
108
+ compressed = gzip.compress(payload)
109
+ return b"\x01" + len(compressed).to_bytes(4, "big") + compressed
110
+
111
+
112
+ def unpack_grpc_message(frame: bytes) -> bytes:
113
+ """读取一条压缩或未压缩 gRPC message,并拒绝截断数据。"""
114
+ if len(frame) < GRPC_HEADER_SIZE:
115
+ raise GRPCFrameError("gRPC message frame 少于 5 字节")
116
+ compressed = frame[0]
117
+ size = int.from_bytes(frame[1:5], "big")
118
+ payload = frame[GRPC_HEADER_SIZE:]
119
+ if len(payload) < size:
120
+ raise GRPCFrameError(f"gRPC message 声明 {size} 字节,实际仅 {len(payload)} 字节")
121
+ payload = payload[:size]
122
+ if compressed == 1:
123
+ try:
124
+ return gzip.decompress(payload)
125
+ except (EOFError, OSError) as error:
126
+ raise GRPCFrameError("gRPC gzip message 无法解压") from error
127
+ if compressed == 0:
128
+ return payload
129
+ raise GRPCFrameError(f"不支持的 gRPC 压缩标志:{compressed}")
130
+
131
+
132
+ def build_app_headers(access_token: str) -> dict[str, str]:
133
+ """构造与原版 AppHelper.GetHeader 相同的 gRPC 元数据。"""
134
+ return {
135
+ "Host": "grpc.biliapi.net",
136
+ "user-agent": (
137
+ "Dalvik/2.1.0 (Linux; U; Android 11; M2012K11AC Build/RKQ1.200826.002) "
138
+ f"{APP_VERSION} os/android model/M2012K11AC mobi_app/android "
139
+ f"build/{APP_BUILD} channel/{APP_CHANNEL} innerVer/{APP_BUILD} osVer/11 "
140
+ "network/2 grpc-java-cronet/1.36.1"
141
+ ),
142
+ "te": "trailers",
143
+ "x-bili-fawkes-req-bin": _protobuf_header(
144
+ fawkesreq_pb2.FawkesReq(appkey="android64", env="prod", sessionId="dedf8669")
145
+ ),
146
+ "x-bili-metadata-bin": _protobuf_header(
147
+ metadata_pb2.Metadata(
148
+ accessKey=access_token,
149
+ mobiApp="android",
150
+ build=APP_BUILD,
151
+ channel=APP_CHANNEL,
152
+ buvid="",
153
+ platform="android",
154
+ )
155
+ ),
156
+ "authorization": f"identify_v1 {access_token}",
157
+ "x-bili-device-bin": _protobuf_header(
158
+ device_pb2.Device(
159
+ appId=1,
160
+ build=APP_BUILD,
161
+ buvid="",
162
+ mobiApp="android",
163
+ platform="android",
164
+ channel=APP_CHANNEL,
165
+ brand="M2012K11AC",
166
+ model="Build/RKQ1.200826.002",
167
+ osver="11",
168
+ )
169
+ ),
170
+ "x-bili-network-bin": _protobuf_header(
171
+ network_pb2.Network(type=network_pb2.Network.WIFI, oid="46007")
172
+ ),
173
+ "x-bili-restriction-bin": "",
174
+ "x-bili-locale-bin": _protobuf_header(
175
+ locale_pb2.Locale(cLocale=locale_pb2.Locale.LocaleIds(language="zh", region="CN"))
176
+ ),
177
+ "x-bili-exps-bin": "",
178
+ "grpc-encoding": "gzip",
179
+ "grpc-accept-encoding": "identity,gzip",
180
+ "grpc-timeout": "17996161u",
181
+ }
182
+
183
+
184
+ def play_reply_to_json(reply: playviewreply_pb2.PlayViewReply) -> str:
185
+ """把 PlayViewReply 映射为原版网页式 DASH JSON。"""
186
+ video_info = reply.videoInfo
187
+ duration_seconds = video_info.timelength // 1000
188
+ videos: list[dict[str, object]] = []
189
+ for stream in video_info.streamList:
190
+ if not stream.HasField("dashVideo"):
191
+ continue
192
+ video = stream.dashVideo
193
+ bandwidth = video.size * 8 // duration_seconds
194
+ videos.append(
195
+ {
196
+ "id": stream.streamInfo.quality,
197
+ "base_url": video.baseUrl,
198
+ "backup_url": list(video.backupUrl),
199
+ "bandwidth": bandwidth,
200
+ "codecid": video.codecid,
201
+ }
202
+ )
203
+
204
+ audios = [_audio_json(audio, "M4A") for audio in video_info.dashAudio]
205
+ if video_info.HasField("flac") and video_info.flac.HasField("audio"):
206
+ audios.append(_audio_json(video_info.flac.audio, "FLAC"))
207
+ if video_info.HasField("dolby") and video_info.dolby.HasField("audio"):
208
+ audios.append(_audio_json(video_info.dolby.audio, "E-AC-3"))
209
+
210
+ clips = []
211
+ if reply.HasField("business"):
212
+ clips = [
213
+ {"start": clip.start, "end": clip.end, "toastText": clip.toastText}
214
+ for clip in reply.business.clipInfo
215
+ ]
216
+
217
+ background: list[dict[str, object]] = []
218
+ roles: list[dict[str, object]] = []
219
+ if reply.HasField("playExtInfo") and reply.playExtInfo.HasField("playDubbingInfo"):
220
+ dubbing = reply.playExtInfo.playDubbingInfo
221
+ if dubbing.HasField("backgroundAudio"):
222
+ background = [_audio_json(audio, "M4A") for audio in dubbing.backgroundAudio.audio]
223
+ roles.extend(
224
+ {
225
+ "audio_id": material.audioId,
226
+ "title": material.title,
227
+ "person_name": material.personName,
228
+ "audio": [_audio_json(audio, "M4A") for audio in material.audio],
229
+ }
230
+ for group in dubbing.roleAudioList
231
+ for material in group.audioMaterialList
232
+ )
233
+
234
+ document = {
235
+ "code": 0,
236
+ "message": "0",
237
+ "ttl": 1,
238
+ "data": {
239
+ "timelength": video_info.timelength,
240
+ "dash": {"video": videos, "audio": audios},
241
+ "clip_info_list": clips,
242
+ },
243
+ "dubbing_info": {"background_audio": background, "role_audio_list": roles},
244
+ }
245
+ return json.dumps(document, ensure_ascii=False, separators=(",", ":"))
246
+
247
+
248
+ def _codec_type(encoding: str) -> playviewreq_pb2.PlayViewReq.CodeType:
249
+ """把 CLI 编码名映射为 PlayViewReq.CodeType,未知值回退 HEVC。"""
250
+ return {
251
+ "AVC": playviewreq_pb2.PlayViewReq.CODE264,
252
+ "HEVC": playviewreq_pb2.PlayViewReq.CODE265,
253
+ "AV1": playviewreq_pb2.PlayViewReq.CODEAV1,
254
+ }.get(encoding, playviewreq_pb2.PlayViewReq.CODE265)
255
+
256
+
257
+ def _protobuf_header(message: Message) -> str:
258
+ """将一个 protobuf header message 编码为 gRPC -bin Base64。"""
259
+ return base64.b64encode(message.SerializeToString(deterministic=True)).decode("ascii")
260
+
261
+
262
+ def _audio_json(audio: playviewreply_pb2.DashItem, codec: str) -> dict[str, object]:
263
+ """把 APP DashItem 转为网页音频字段。"""
264
+ return {
265
+ "id": audio.id,
266
+ "base_url": audio.baseUrl,
267
+ "backup_url": list(audio.backupUrl),
268
+ "bandwidth": audio.bandwidth,
269
+ "codecs": codec,
270
+ }
bbdown/archives.py ADDED
@@ -0,0 +1,52 @@
1
+ """已下载 aid 归档的兼容读取与线程安全追加。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from bbdown.paths import get_state_home, legacy_state_directories
11
+
12
+ ARCHIVE_FILE_NAME = "BBDown.archives"
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ArchiveStore:
17
+ """跨新旧位置查重,并始终向新状态目录写入。"""
18
+
19
+ state_home: Path = field(default_factory=get_state_home)
20
+ legacy_directories: tuple[Path, ...] = field(default_factory=legacy_state_directories)
21
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
22
+
23
+ @property
24
+ def output_path(self) -> Path:
25
+ """返回新归档文件路径。"""
26
+ return self.state_home / ARCHIVE_FILE_NAME
27
+
28
+ def _paths(self) -> tuple[Path, ...]:
29
+ """返回新目录优先且去重后的归档候选。"""
30
+ candidates: Iterable[Path] = (
31
+ self.output_path,
32
+ *(directory / ARCHIVE_FILE_NAME for directory in self.legacy_directories),
33
+ )
34
+ return tuple(dict.fromkeys(path.expanduser().resolve() for path in candidates))
35
+
36
+ def contains(self, aid: str) -> bool:
37
+ """检查任一兼容归档是否包含完整 aid token。"""
38
+ with self._lock:
39
+ for path in self._paths():
40
+ try:
41
+ if path.is_file() and aid in path.read_text(encoding="utf-8-sig").split("|"):
42
+ return True
43
+ except OSError:
44
+ continue
45
+ return False
46
+
47
+ def append(self, aid: str) -> None:
48
+ """按原版 ``aid|`` 格式追加到新状态目录。"""
49
+ with self._lock:
50
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
51
+ with self.output_path.open("a", encoding="utf-8", newline="") as stream:
52
+ stream.write(f"{aid}|")
bbdown/bv.py ADDED
@@ -0,0 +1,47 @@
1
+ """Bilibili AV 号与新版 BV 号之间的可逆转换。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ XOR_CODE = 23_442_827_791_579
6
+ MASK_CODE = (1 << 51) - 1
7
+ MAX_AID = MASK_CODE + 1
8
+ MIN_AID = 1
9
+ BASE = 58
10
+ BV_PAYLOAD_LENGTH = 9
11
+ ALPHABET = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf"
12
+ REVERSE_ALPHABET = {character: index for index, character in enumerate(ALPHABET)}
13
+
14
+
15
+ def encode_bv(avid: int) -> str:
16
+ """把有效 AV 数字编码为包含 ``BV1`` 前缀的 BV 号。"""
17
+ if avid < MIN_AID:
18
+ raise ValueError(f"Av {avid} is smaller than {MIN_AID}")
19
+ if avid >= MAX_AID:
20
+ raise ValueError(f"Av {avid} is bigger than {MAX_AID}")
21
+
22
+ encoded = (MAX_AID | avid) ^ XOR_CODE
23
+ payload: list[str] = []
24
+ for _ in range(BV_PAYLOAD_LENGTH):
25
+ payload.append(ALPHABET[encoded % BASE])
26
+ encoded //= BASE
27
+ payload.reverse()
28
+ payload[0], payload[6] = payload[6], payload[0]
29
+ payload[1], payload[4] = payload[4], payload[1]
30
+ return "BV1" + "".join(payload)
31
+
32
+
33
+ def decode_bv(bvid: str) -> int:
34
+ """把完整 BV 号解码为 AV 数字,并拒绝长度或字符无效的输入。"""
35
+ if len(bvid) != BV_PAYLOAD_LENGTH + 3 or bvid[:3].lower() != "bv1":
36
+ raise ValueError(f"Bv {bvid} must be 12 characters and start with BV1")
37
+
38
+ payload = list(bvid[3:])
39
+ payload[0], payload[6] = payload[6], payload[0]
40
+ payload[1], payload[4] = payload[4], payload[1]
41
+ avid = 0
42
+ try:
43
+ for character in payload:
44
+ avid = avid * BASE + REVERSE_ALPHABET[character]
45
+ except KeyError as error:
46
+ raise ValueError(f"Bv {bvid} contains an invalid character") from error
47
+ return (avid & MASK_CODE) ^ XOR_CODE
bbdown/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """BBDown 命令行入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from dataclasses import replace
10
+
11
+ from bbdown._version import __version__
12
+ from bbdown.config import load_config, resolve_config_path
13
+ from bbdown.doctor import collect_diagnostics, diagnostics_succeeded, render_diagnostics
14
+ from bbdown.login import run_login
15
+ from bbdown.options import DownloadOptions, add_download_arguments, parse_download_options
16
+ from bbdown.server import DEFAULT_LISTEN_URL, run_server
17
+ from bbdown.workflow import run_download
18
+
19
+ DESCRIPTION = "BBDown是一个免费且便捷高效的哔哩哔哩下载/解析软件."
20
+
21
+
22
+ def development_preview_message(version: str) -> str | None:
23
+ """仅为含预发布标识的版本生成开发中提示。"""
24
+ if not any(character.isalpha() for character in version):
25
+ return None
26
+ return f"BBDown {version} 开发预览版:功能仍在持续验证中。"
27
+
28
+
29
+ PREVIEW_MESSAGE = development_preview_message(__version__)
30
+
31
+
32
+ def _add_question_mark_help(parser: argparse.ArgumentParser) -> None:
33
+ """添加 System.CommandLine 默认提供的 ``-?`` 帮助别名。"""
34
+ parser.add_argument("-?", action="help", help="show this help message and exit")
35
+
36
+
37
+ def create_parser() -> argparse.ArgumentParser:
38
+ """创建具有原版下载参数和别名的顶层参数解析器。"""
39
+ parser = argparse.ArgumentParser(
40
+ prog="BBDown",
41
+ description=DESCRIPTION,
42
+ epilog=(
43
+ "Commands:\n"
44
+ " login 通过APP扫描二维码以登录您的WEB账号\n"
45
+ " logintv 通过APP扫描二维码以登录您的TV账号\n"
46
+ " serve 以服务器模式运行"
47
+ ),
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ )
50
+ _add_question_mark_help(parser)
51
+ add_download_arguments(parser)
52
+ parser.add_argument("--version", action="version", version=f"BBDown {__version__}")
53
+ return parser
54
+
55
+
56
+ def create_doctor_parser() -> argparse.ArgumentParser:
57
+ """创建 ``doctor`` 子命令解析器。"""
58
+ parser = argparse.ArgumentParser(prog="BBDown doctor", description="检查 BBDown 运行环境")
59
+ _add_question_mark_help(parser)
60
+ return parser
61
+
62
+
63
+ def create_serve_parser() -> argparse.ArgumentParser:
64
+ """创建兼容原版监听参数并提供可选安全增强的服务端解析器。"""
65
+ parser = argparse.ArgumentParser(prog="BBDown serve", description="以服务器模式运行")
66
+ _add_question_mark_help(parser)
67
+ parser.add_argument(
68
+ "-l",
69
+ "--listen",
70
+ default=DEFAULT_LISTEN_URL,
71
+ help=f"监听 URL(默认:{DEFAULT_LISTEN_URL})",
72
+ )
73
+ parser.add_argument("--token", help="要求客户端使用 Bearer Token")
74
+ parser.add_argument(
75
+ "--cors-origin",
76
+ action="append",
77
+ dest="cors_origins",
78
+ help="允许的 CORS 来源,可重复指定",
79
+ )
80
+ return parser
81
+
82
+
83
+ def _parse_with_config(
84
+ parser: argparse.ArgumentParser,
85
+ arguments: list[str],
86
+ ) -> DownloadOptions:
87
+ """载入配置选项,同时丢弃配置中的旧式位置参数。"""
88
+ command_options = parse_download_options(parser, arguments)
89
+ config_path = resolve_config_path(explicit_path=command_options.config_file)
90
+ loaded = load_config(config_path)
91
+ if loaded.error is not None or not loaded.arguments:
92
+ return command_options
93
+ config_parser = argparse.ArgumentParser(add_help=False)
94
+ add_download_arguments(config_parser, url_required=False)
95
+ try:
96
+ config_options = parse_download_options(config_parser, list(loaded.arguments))
97
+ except SystemExit:
98
+ return command_options
99
+ config_destinations = _specified_option_destinations(config_parser, loaded.arguments)
100
+ command_destinations = _specified_option_destinations(parser, arguments)
101
+ overrides = {
102
+ destination: getattr(config_options, destination)
103
+ for destination in config_destinations - command_destinations
104
+ }
105
+ return replace(command_options, **overrides)
106
+
107
+
108
+ def _specified_option_destinations(
109
+ parser: argparse.ArgumentParser,
110
+ arguments: Sequence[str],
111
+ ) -> set[str]:
112
+ """返回参数序列显式出现的选项目标,不把位置参数算作配置。"""
113
+ actions = parser._option_string_actions
114
+ destinations: set[str] = set()
115
+ for argument in arguments:
116
+ option = argument.split("=", maxsplit=1)[0]
117
+ action = actions.get(option)
118
+ if action is not None:
119
+ destinations.add(action.dest)
120
+ return destinations
121
+
122
+
123
+ def _run_login_command(mode: str) -> int:
124
+ """运行二维码登录,并把成功、过期或取消映射为稳定退出码。"""
125
+ try:
126
+ result = asyncio.run(run_login(mode))
127
+ except KeyboardInterrupt:
128
+ print("登录已取消", file=sys.stderr)
129
+ return 130
130
+ except Exception as error:
131
+ print(str(error), file=sys.stderr)
132
+ return 1
133
+ return 0 if result.succeeded else 1
134
+
135
+
136
+ def main(argv: Sequence[str] | None = None) -> int: # noqa: PLR0911
137
+ """运行 BBDown 命令行并返回进程退出码。"""
138
+ arguments = list(sys.argv[1:] if argv is None else argv)
139
+ if arguments and arguments[0] == "doctor":
140
+ create_doctor_parser().parse_args(arguments[1:])
141
+ results = collect_diagnostics()
142
+ print(render_diagnostics(results))
143
+ return 0 if diagnostics_succeeded(results) else 1
144
+ if arguments == ["login"]:
145
+ return _run_login_command("web")
146
+ if arguments == ["logintv"]:
147
+ return _run_login_command("tv")
148
+ if arguments and arguments[0] == "serve":
149
+ serve_options = create_serve_parser().parse_args(arguments[1:])
150
+ try:
151
+ run_server(
152
+ serve_options.listen,
153
+ token=serve_options.token,
154
+ cors_origins=serve_options.cors_origins,
155
+ )
156
+ except KeyboardInterrupt:
157
+ print("服务已停止", file=sys.stderr)
158
+ return 130
159
+ except (OSError, ValueError) as error:
160
+ print(str(error), file=sys.stderr)
161
+ return 1
162
+ return 0
163
+
164
+ if not arguments:
165
+ print("Required argument missing for command: 'BBDown'.", file=sys.stderr)
166
+ print("请使用 BBDown --help 查看帮助", file=sys.stderr)
167
+ return 1
168
+
169
+ parser = create_parser()
170
+ options = _parse_with_config(parser, arguments)
171
+ if PREVIEW_MESSAGE is not None:
172
+ print(PREVIEW_MESSAGE, file=sys.stderr)
173
+ try:
174
+ asyncio.run(run_download(options, arguments))
175
+ except KeyboardInterrupt:
176
+ print("任务已取消", file=sys.stderr)
177
+ return 130
178
+ except Exception as error:
179
+ print(str(error), file=sys.stderr)
180
+ return 1
181
+ return 0
bbdown/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """BBDown 配置文件的发现与 token 解析。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from bbdown.paths import get_state_home, legacy_state_directories
10
+
11
+ CONFIG_FILE_NAME = "BBDown.config"
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class LoadedConfig:
16
+ """表示一次不会抛出文件系统异常的配置读取结果。"""
17
+
18
+ path: Path | None
19
+ arguments: tuple[str, ...]
20
+ error: OSError | None = None
21
+
22
+
23
+ def resolve_config_path(
24
+ *,
25
+ explicit_path: str | Path | None = None,
26
+ state_home: Path | None = None,
27
+ legacy_directories: Iterable[Path] | None = None,
28
+ ) -> Path | None:
29
+ """按显式路径、新状态目录、原版目录的优先级寻找配置文件。"""
30
+ if explicit_path is not None:
31
+ return Path(explicit_path).expanduser()
32
+
33
+ home = get_state_home() if state_home is None else state_home
34
+ legacy = legacy_state_directories() if legacy_directories is None else legacy_directories
35
+ candidates = (home / CONFIG_FILE_NAME, *(path / CONFIG_FILE_NAME for path in legacy))
36
+ return next((path for path in candidates if path.is_file()), None)
37
+
38
+
39
+ def parse_config_lines(lines: Iterable[str]) -> tuple[str, ...]:
40
+ """按原版规则把每行配置展开为命令行 token。"""
41
+ arguments: list[str] = []
42
+ for line in lines:
43
+ line_without_newline = line.rstrip("\r\n")
44
+ if not line_without_newline or line_without_newline.startswith("#"):
45
+ continue
46
+ trimmed = line_without_newline.strip()
47
+ if trimmed.startswith("-") and " " in trimmed:
48
+ option, value = trimmed.split(" ", maxsplit=1)
49
+ arguments.extend((option, value.strip(" ").strip('"')))
50
+ else:
51
+ arguments.append(trimmed.strip('"'))
52
+ return tuple(arguments)
53
+
54
+
55
+ def load_config(path: Path | None) -> LoadedConfig:
56
+ """读取配置;文件不存在或读取失败时返回可检查的结果。"""
57
+ if path is None or not path.is_file():
58
+ return LoadedConfig(path=path, arguments=())
59
+ try:
60
+ lines = path.read_text(encoding="utf-8-sig").splitlines()
61
+ except OSError as error:
62
+ return LoadedConfig(path=path, arguments=(), error=error)
63
+ return LoadedConfig(path=path, arguments=parse_config_lines(lines))