bili23-cli 0.1.0__tar.gz

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,30 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ build/
8
+ dist/
9
+ .eggs/
10
+
11
+ # Virtual envs
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # uv
17
+ uv.lock
18
+
19
+ # Editors / OS
20
+ .vscode/
21
+ .idea/
22
+ .DS_Store
23
+ *.swp
24
+
25
+ # Test downloads
26
+ *.mp4
27
+ *.m4s
28
+ *.flv
29
+ *.mkv
30
+ _test-output/
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-07
11
+
12
+ ### Added
13
+ - Initial release.
14
+ - Headless CLI reusing the Bili23-Downloader GUI login session (`config.json`).
15
+ - Wbi signature (`w_rid`) implementation for modern Bilibili API auth.
16
+ - Parse + download user-uploaded videos (BV/av): `wbi/view` → `wbi/playurl` → multi-threaded Range download → `ffmpeg` merge.
17
+ - Quality selection (`-q`: auto/8K/…/360P) and codec selection (`-c`: auto/AVC/HEVC/AV1).
18
+ - Multi-part video support (`-p`).
19
+ - Anonymous fallback (`--no-cookie`).
20
+
21
+ [Unreleased]: https://github.com/1WorldCapture/bili23-cli/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/1WorldCapture/bili23-cli/releases/tag/v0.1.0
@@ -0,0 +1,17 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2026 Lyon Liang
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU General Public License for more details.
15
+
16
+ You should have received a copy of the GNU General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: bili23-cli
3
+ Version: 0.1.0
4
+ Summary: Headless CLI for downloading Bilibili videos — reuses the Bili23-Downloader GUI login session, no separate login required.
5
+ Project-URL: Homepage, https://github.com/1WorldCapture/bili23-cli
6
+ Project-URL: Repository, https://github.com/1WorldCapture/bili23-cli
7
+ Project-URL: Issues, https://github.com/1WorldCapture/bili23-cli/issues
8
+ Author: Lyon Liang
9
+ License-Expression: GPL-3.0-only
10
+ License-File: LICENSE
11
+ Keywords: bili23,bilibili,bilibili-downloader,cli,video-downloader
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: Multimedia :: Video
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx>=0.27
28
+ Description-Content-Type: text/markdown
29
+
30
+ # bili23-cli
31
+
32
+ > Headless CLI for downloading Bilibili videos — **reuses the [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader) GUI login session**, so the CLI never asks you to log in.
33
+
34
+ Give it a Bilibili URL, get an `.mp4`. That's it.
35
+
36
+ ```bash
37
+ bili23-cli "https://www.bilibili.com/video/BV1GE411Q7Ew/"
38
+ ```
39
+
40
+ ## Why this exists
41
+
42
+ The official [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader) is a Qt GUI app — great for clicking around, but not scriptable. The author's separate [CLI repo](https://github.com/ScottSloan/Bili23-Downloader-CLI) was last published to PyPI in **2022** and is now stale (missing modern Bilibili auth: Wbi signing, buvid/ticket cookies, etc.).
43
+
44
+ `bili23-cli` is a **modern, dependency-light rewrite** that:
45
+
46
+ - 🍪 **Reuses your GUI login** — reads the cookie the GUI already stored, so you get your account's quality (1080P / 4K / VIP) without logging in again.
47
+ - 🔐 **Implements current Bilibili auth** — Wbi signature (`w_rid`), full cookie set (SESSDATA / buvid3/4 / bili_ticket / …), correct UA & Referer.
48
+ - 🪶 **Minimal dependencies** — only [`httpx`](https://www.python-httpx.org/) + system `ffmpeg`. No PySide6 / Qt.
49
+ - 🧵 **Multi-threaded** — 4 MB chunked Range downloads.
50
+ - 🎛️ **Selectable quality / codec** — `-q 1080P -c HEVC`, or `auto` for best-available.
51
+
52
+ ## Prerequisites
53
+
54
+ 1. **The GUI app installed & logged in** — [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader/releases). Log in once; `bili23-cli` reads its session cookie.
55
+ - This is how you get high quality. Anonymous use (`--no-cookie`) works but is limited to ~480P and some videos 403.
56
+ 2. **ffmpeg** on your `PATH` (for merging audio + video):
57
+ - macOS: `brew install ffmpeg`
58
+ - Linux: `sudo apt install ffmpeg`
59
+ - Windows: <https://ffmpeg.org/download.html>
60
+ 3. **Python ≥ 3.9**.
61
+
62
+ ## Installation
63
+
64
+ ### As a standalone tool (recommended)
65
+
66
+ ```bash
67
+ # with uv
68
+ uv tool install bili23-cli
69
+
70
+ # or with pipx
71
+ pipx install bili23-cli
72
+ ```
73
+
74
+ This installs the `bili23-cli` command globally, in an isolated environment.
75
+
76
+ ### From source
77
+
78
+ ```bash
79
+ git clone https://github.com/1WorldCapture/bili23-cli.git
80
+ cd bili23-cli
81
+ uv tool install . # or: pipx install .
82
+ ```
83
+
84
+ ## Usage
85
+
86
+ ```bash
87
+ bili23-cli <URL-or-BVid> [options]
88
+ ```
89
+
90
+ ### Examples
91
+
92
+ ```bash
93
+ # Simplest: download to current dir, auto best quality
94
+ bili23-cli "https://www.bilibili.com/video/BV1GE411Q7Ew/"
95
+
96
+ # Bare BV id works too
97
+ bili23-cli BV1GE411Q7Ew
98
+
99
+ # Choose quality + codec, output dir
100
+ bili23-cli BV1GE411Q7Ew -o ~/Downloads -q 1080P -c HEVC
101
+
102
+ # Multi-part video: pick part 3
103
+ bili23-cli "https://www.bilibili.com/video/BVxxxxx/" -p 3
104
+
105
+ # More threads
106
+ bili23-cli BV1GE411Q7Ew -t 16
107
+ ```
108
+
109
+ ### Options
110
+
111
+ | Option | Description |
112
+ |---|---|
113
+ | `url` | Bilibili video URL or bare BV id (positional) |
114
+ | `-o, --output DIR` | Output directory (default: current dir) |
115
+ | `-q, --quality` | `auto` / `8K` / `杜比视界` / `HDR` / `4K` / `1080P60` / `1080P+` / `1080P` / `720P` / `480P` / `360P` (default: `auto`) |
116
+ | `-c, --codec` | `auto` / `AVC` / `HEVC` / `AV1` (default: `auto`, prefers AVC > HEVC > AV1) |
117
+ | `-p, --page N` | Part number, 1-based (default: `1`) |
118
+ | `-t, --thread N` | Download threads (default: `8`) |
119
+ | `--no-merge` | Don't merge; keep `video.m4s` / `audio.m4s` |
120
+ | `--no-cookie` | Skip the GUI login cookie (anonymous; quality limited) |
121
+ | `--keep-parts` | Keep `.m4s` parts after merge |
122
+
123
+ ## How it works
124
+
125
+ ### Auth reuse (the key trick)
126
+
127
+ The GUI stores your login in (macOS) `~/Library/Application Support/Bili23 Downloader/config.json` under the `Cookie` key. `bili23-cli` reads it and rebuilds the exact cookie dict the GUI sends — including `SESSDATA`, `bili_jct`, `buvid3/4`, `bili_ticket`, and the `CURRENT_FNVAL=4048` flag — so the CLI looks indistinguishable from the GUI to Bilibili's servers.
128
+
129
+ On each run it also calls `/x/web-interface/nav` to fetch fresh **Wbi signing keys** (`img_key` / `sub_key`) — it does **not** trust the cached values, which silently expire.
130
+
131
+ ### Download pipeline
132
+
133
+ Only two API calls are needed:
134
+
135
+ 1. `GET /x/web-interface/wbi/view?bvid=…` (+ Wbi sig) → title, `cid`, parts
136
+ 2. `GET /x/player/wbi/playurl?bvid=…&cid=…&qn=…&fnval=4048&fourk=1` (+ Wbi sig) → DASH `video[]` / `audio[]` CDN URLs
137
+
138
+ Then: multi-threaded Range download of `video.m4s` + `audio.m4s`, followed by `ffmpeg -c copy` into `.mp4`.
139
+
140
+ See [`docs/research-notes.md`](docs/research-notes.md) for the full reverse-engineering writeup of the GUI's internals.
141
+
142
+ ## Scope (v0.1)
143
+
144
+ **Supported:** user-uploaded videos (BV/av) — parse, quality/codec selection, multi-threaded download, merge.
145
+
146
+ **Not yet:** bangumi / movies / courses / audio / favorites (different APIs), danmaku / subtitles / cover / metadata, batch download, cross-process resume, login flow (by design — reuse the GUI).
147
+
148
+ ## Acknowledgements
149
+
150
+ This project is an independent CLI companion to — and **not affiliated with** — [ScottSloan/Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader). The auth and download logic is a clean-room reimplementation based on studying that project's source.
151
+
152
+ Bilibili is a trademark of Shanghai Kuoyu Network Technology Co., Ltd. This tool is for personal use; please respect Bilibili's Terms of Service and copyright.
153
+
154
+ ## License
155
+
156
+ [GPL-3.0](LICENSE)
@@ -0,0 +1,127 @@
1
+ # bili23-cli
2
+
3
+ > Headless CLI for downloading Bilibili videos — **reuses the [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader) GUI login session**, so the CLI never asks you to log in.
4
+
5
+ Give it a Bilibili URL, get an `.mp4`. That's it.
6
+
7
+ ```bash
8
+ bili23-cli "https://www.bilibili.com/video/BV1GE411Q7Ew/"
9
+ ```
10
+
11
+ ## Why this exists
12
+
13
+ The official [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader) is a Qt GUI app — great for clicking around, but not scriptable. The author's separate [CLI repo](https://github.com/ScottSloan/Bili23-Downloader-CLI) was last published to PyPI in **2022** and is now stale (missing modern Bilibili auth: Wbi signing, buvid/ticket cookies, etc.).
14
+
15
+ `bili23-cli` is a **modern, dependency-light rewrite** that:
16
+
17
+ - 🍪 **Reuses your GUI login** — reads the cookie the GUI already stored, so you get your account's quality (1080P / 4K / VIP) without logging in again.
18
+ - 🔐 **Implements current Bilibili auth** — Wbi signature (`w_rid`), full cookie set (SESSDATA / buvid3/4 / bili_ticket / …), correct UA & Referer.
19
+ - 🪶 **Minimal dependencies** — only [`httpx`](https://www.python-httpx.org/) + system `ffmpeg`. No PySide6 / Qt.
20
+ - 🧵 **Multi-threaded** — 4 MB chunked Range downloads.
21
+ - 🎛️ **Selectable quality / codec** — `-q 1080P -c HEVC`, or `auto` for best-available.
22
+
23
+ ## Prerequisites
24
+
25
+ 1. **The GUI app installed & logged in** — [Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader/releases). Log in once; `bili23-cli` reads its session cookie.
26
+ - This is how you get high quality. Anonymous use (`--no-cookie`) works but is limited to ~480P and some videos 403.
27
+ 2. **ffmpeg** on your `PATH` (for merging audio + video):
28
+ - macOS: `brew install ffmpeg`
29
+ - Linux: `sudo apt install ffmpeg`
30
+ - Windows: <https://ffmpeg.org/download.html>
31
+ 3. **Python ≥ 3.9**.
32
+
33
+ ## Installation
34
+
35
+ ### As a standalone tool (recommended)
36
+
37
+ ```bash
38
+ # with uv
39
+ uv tool install bili23-cli
40
+
41
+ # or with pipx
42
+ pipx install bili23-cli
43
+ ```
44
+
45
+ This installs the `bili23-cli` command globally, in an isolated environment.
46
+
47
+ ### From source
48
+
49
+ ```bash
50
+ git clone https://github.com/1WorldCapture/bili23-cli.git
51
+ cd bili23-cli
52
+ uv tool install . # or: pipx install .
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ bili23-cli <URL-or-BVid> [options]
59
+ ```
60
+
61
+ ### Examples
62
+
63
+ ```bash
64
+ # Simplest: download to current dir, auto best quality
65
+ bili23-cli "https://www.bilibili.com/video/BV1GE411Q7Ew/"
66
+
67
+ # Bare BV id works too
68
+ bili23-cli BV1GE411Q7Ew
69
+
70
+ # Choose quality + codec, output dir
71
+ bili23-cli BV1GE411Q7Ew -o ~/Downloads -q 1080P -c HEVC
72
+
73
+ # Multi-part video: pick part 3
74
+ bili23-cli "https://www.bilibili.com/video/BVxxxxx/" -p 3
75
+
76
+ # More threads
77
+ bili23-cli BV1GE411Q7Ew -t 16
78
+ ```
79
+
80
+ ### Options
81
+
82
+ | Option | Description |
83
+ |---|---|
84
+ | `url` | Bilibili video URL or bare BV id (positional) |
85
+ | `-o, --output DIR` | Output directory (default: current dir) |
86
+ | `-q, --quality` | `auto` / `8K` / `杜比视界` / `HDR` / `4K` / `1080P60` / `1080P+` / `1080P` / `720P` / `480P` / `360P` (default: `auto`) |
87
+ | `-c, --codec` | `auto` / `AVC` / `HEVC` / `AV1` (default: `auto`, prefers AVC > HEVC > AV1) |
88
+ | `-p, --page N` | Part number, 1-based (default: `1`) |
89
+ | `-t, --thread N` | Download threads (default: `8`) |
90
+ | `--no-merge` | Don't merge; keep `video.m4s` / `audio.m4s` |
91
+ | `--no-cookie` | Skip the GUI login cookie (anonymous; quality limited) |
92
+ | `--keep-parts` | Keep `.m4s` parts after merge |
93
+
94
+ ## How it works
95
+
96
+ ### Auth reuse (the key trick)
97
+
98
+ The GUI stores your login in (macOS) `~/Library/Application Support/Bili23 Downloader/config.json` under the `Cookie` key. `bili23-cli` reads it and rebuilds the exact cookie dict the GUI sends — including `SESSDATA`, `bili_jct`, `buvid3/4`, `bili_ticket`, and the `CURRENT_FNVAL=4048` flag — so the CLI looks indistinguishable from the GUI to Bilibili's servers.
99
+
100
+ On each run it also calls `/x/web-interface/nav` to fetch fresh **Wbi signing keys** (`img_key` / `sub_key`) — it does **not** trust the cached values, which silently expire.
101
+
102
+ ### Download pipeline
103
+
104
+ Only two API calls are needed:
105
+
106
+ 1. `GET /x/web-interface/wbi/view?bvid=…` (+ Wbi sig) → title, `cid`, parts
107
+ 2. `GET /x/player/wbi/playurl?bvid=…&cid=…&qn=…&fnval=4048&fourk=1` (+ Wbi sig) → DASH `video[]` / `audio[]` CDN URLs
108
+
109
+ Then: multi-threaded Range download of `video.m4s` + `audio.m4s`, followed by `ffmpeg -c copy` into `.mp4`.
110
+
111
+ See [`docs/research-notes.md`](docs/research-notes.md) for the full reverse-engineering writeup of the GUI's internals.
112
+
113
+ ## Scope (v0.1)
114
+
115
+ **Supported:** user-uploaded videos (BV/av) — parse, quality/codec selection, multi-threaded download, merge.
116
+
117
+ **Not yet:** bangumi / movies / courses / audio / favorites (different APIs), danmaku / subtitles / cover / metadata, batch download, cross-process resume, login flow (by design — reuse the GUI).
118
+
119
+ ## Acknowledgements
120
+
121
+ This project is an independent CLI companion to — and **not affiliated with** — [ScottSloan/Bili23-Downloader](https://github.com/ScottSloan/Bili23-Downloader). The auth and download logic is a clean-room reimplementation based on studying that project's source.
122
+
123
+ Bilibili is a trademark of Shanghai Kuoyu Network Technology Co., Ltd. This tool is for personal use; please respect Bilibili's Terms of Service and copyright.
124
+
125
+ ## License
126
+
127
+ [GPL-3.0](LICENSE)
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bili23-cli"
7
+ version = "0.1.0"
8
+ description = "Headless CLI for downloading Bilibili videos — reuses the Bili23-Downloader GUI login session, no separate login required."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "GPL-3.0-only"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Lyon Liang" }]
14
+ keywords = ["bilibili", "bilibili-downloader", "bili23", "video-downloader", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ "Topic :: Multimedia :: Video",
30
+ ]
31
+ dependencies = [
32
+ "httpx>=0.27",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/1WorldCapture/bili23-cli"
37
+ Repository = "https://github.com/1WorldCapture/bili23-cli"
38
+ Issues = "https://github.com/1WorldCapture/bili23-cli/issues"
39
+
40
+ # uv tool install bili23-cli 后,命令名是 bili23-cli
41
+ [project.scripts]
42
+ bili23-cli = "bili23_cli.cli:main"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/bili23_cli"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["src/bili23_cli", "README.md", "LICENSE", "CHANGELOG.md"]
@@ -0,0 +1,6 @@
1
+ """Bili23-Downloader 的 headless CLI。
2
+
3
+ 复用 GUI 版本(Bili23-Downloader)登录后的 cookie 数据,给一个 B 站地址即可下载。
4
+ 详见:https://github.com/ScottSloan/Bili23-Downloader
5
+ """
6
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,216 @@
1
+ """CLI 主流程编排:参数解析 → 加载 GUI 鉴权 → 解析 → 下载 → 合并。
2
+
3
+ 用法:
4
+ python -m bili23_cli <B站地址> [选项]
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import shutil
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from . import config as cfg_mod
15
+ from . import download, merge, parse, wbi
16
+ from .client import make_client
17
+ from .naming import sanitize
18
+
19
+
20
+ def _format_size(n: int) -> str:
21
+ if n >= 1 << 30:
22
+ return f"{n / (1 << 30):.2f} GB"
23
+ if n >= 1 << 20:
24
+ return f"{n / (1 << 20):.1f} MB"
25
+ if n >= 1 << 10:
26
+ return f"{n / (1 << 10):.1f} KB"
27
+ return f"{n} B"
28
+
29
+
30
+ _last_progress_ts = 0.0
31
+ _last_progress_pct = -1
32
+
33
+
34
+ def _progress_bar(downloaded: int, total: int, *, label: str = ""):
35
+ """简单文本进度条(写 stderr,节流:≥100ms 且 ≥1% 变化才刷新)。"""
36
+ global _last_progress_ts, _last_progress_pct
37
+ pct = 0 if total <= 0 else min(downloaded * 100 // total, 100)
38
+ now = time.time()
39
+ # 节流:除非完成,否则至少间隔 100ms 且百分比有变化才刷新
40
+ if pct != 100 and (now - _last_progress_ts < 0.1 or pct == _last_progress_pct):
41
+ return
42
+ _last_progress_ts = now
43
+ _last_progress_pct = pct
44
+
45
+ bar = "█" * (pct // 5) + "░" * (20 - pct // 5) if total > 0 else "?" * 20
46
+ sys.stderr.write(
47
+ f"\r {label} [{bar}] {pct:3d}% {_format_size(downloaded)}/{_format_size(total)}"
48
+ )
49
+ sys.stderr.flush()
50
+ if total > 0 and downloaded >= total:
51
+ sys.stderr.write("\n")
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ p = argparse.ArgumentParser(
56
+ prog="bili23-cli",
57
+ description="Bili23-Downloader 的 headless CLI —— 复用 GUI 登录态下载 B 站视频",
58
+ epilog="示例:\n python -m bili23_cli BV1GE411Q7Ew\n"
59
+ " python -m bili23_cli 'https://www.bilibili.com/video/BV1GE411Q7Ew/' -q 1080P -c HEVC",
60
+ formatter_class=argparse.RawDescriptionHelpFormatter,
61
+ )
62
+ p.add_argument("url", help="B 站视频地址或 BV 号")
63
+ p.add_argument("-o", "--output", default=".", help="输出目录,默认当前目录")
64
+ q_group = p.add_mutually_exclusive_group()
65
+ q_group.add_argument("-q", "--quality", default="auto",
66
+ choices=["auto", "8K", "杜比视界", "HDR", "4K", "1080P60", "1080P+", "1080P", "720P", "480P", "360P"],
67
+ help="清晰度(默认 auto,按可用最高优先级)")
68
+ p.add_argument("-c", "--codec", default="auto",
69
+ choices=["auto", "AVC", "HEVC", "AV1"],
70
+ help="视频编码(默认 auto,偏好 AVC>HEVC>AV1)")
71
+ p.add_argument("-p", "--page", type=int, default=1, help="分 P 序号,从 1 开始(默认 1)")
72
+ p.add_argument("-t", "--thread", type=int, default=8, help="下载线程数(默认 8)")
73
+ p.add_argument("--no-merge", action="store_true", help="不合并,保留 video.m4s / audio.m4s")
74
+ p.add_argument("--no-cookie", action="store_true", help="不读取 GUI 登录态(匿名下载)")
75
+ p.add_argument("--keep-parts", action="store_true", help="合并后保留 m4s 临时文件")
76
+ return p
77
+
78
+
79
+ def _resolve_qn(quality: str) -> int:
80
+ # 清晰度名字 → id(与 parse.VIDEO_QUALITY_NAME 对齐;auto=200)
81
+ name_to_id = {
82
+ "8K": 127, "杜比视界": 126, "HDR": 125, "4K": 120, "1080P60": 116,
83
+ "1080P+": 112, "1080P": 80, "720P": 64, "480P": 32, "360P": 16, "auto": 200,
84
+ }
85
+ return name_to_id.get(quality, 200)
86
+
87
+
88
+ def _resolve_codec(codec: str) -> int:
89
+ return {"auto": 20, "AVC": 7, "HEVC": 12, "AV1": 13}.get(codec, 20)
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ args = build_parser().parse_args(argv)
94
+
95
+ # ---- 1. 检查 ffmpeg ----
96
+ if not args.no_merge and not merge.ffmpeg_available():
97
+ print("⚠️ 未找到 ffmpeg,将下载但无法合并(可用 --no-merge 或先装 ffmpeg)", file=sys.stderr)
98
+
99
+ # ---- 2. 加载 GUI 鉴权 ----
100
+ cookies: dict[str, str] = {}
101
+ ua = ""
102
+ if not args.no_cookie:
103
+ try:
104
+ gui_cfg = cfg_mod.load_gui_config()
105
+ cookies = cfg_mod.build_cookies(gui_cfg)
106
+ ua = cfg_mod.get_user_agent(gui_cfg)
107
+ logged = cfg_mod.is_logged_in(gui_cfg)
108
+ print(f"✓ 已复用 GUI 登录态(is_login={logged}, cookie 字段 {len(cookies)} 个)", file=sys.stderr)
109
+ except cfg_mod.ConfigNotFoundError as e:
110
+ print(f"⚠️ {e}", file=sys.stderr)
111
+ print(" 将以匿名方式下载(清晰度会受限)。可用 --no-cookie 显式跳过。", file=sys.stderr)
112
+
113
+ client = make_client(cookies, ua)
114
+
115
+ # ---- 3. 拉 wbi keys + 校验登录态 ----
116
+ print("→ 获取鉴权信息…", file=sys.stderr)
117
+ # fetch_wbi_keys 返回 (img_key, sub_key, nav_data)
118
+ img_key, sub_key, nav_data = wbi.fetch_wbi_keys(client)
119
+ wbi_keys = (img_key, sub_key)
120
+ used_cookie = bool(cookies)
121
+ if used_cookie and not nav_data.get("isLogin"):
122
+ print("⚠️ GUI 登录态已过期,请在 GUI 中重新登录后重试(将以匿名继续,清晰度受限)", file=sys.stderr)
123
+ elif nav_data.get("isLogin"):
124
+ print(f"✓ 登录有效:{nav_data.get('uname', '?')}"
125
+ f"(大会员:{'是' if nav_data.get('vipStatus') else '否'})", file=sys.stderr)
126
+ else:
127
+ print("ℹ️ 匿名访问(清晰度受限)", file=sys.stderr)
128
+
129
+ # ---- 4. 解析视频 ----
130
+ bvid = parse.extract_bvid(args.url)
131
+ print(f"→ 解析视频 {bvid}…", file=sys.stderr)
132
+ meta = parse.parse_video(client, bvid, wbi_keys)
133
+ print(f" 标题:{meta.title}")
134
+ print(f" UP 主:{meta.up_name} 分 P 数:{len(meta.pages)}")
135
+
136
+ # 选 P
137
+ if args.page < 1 or args.page > len(meta.pages):
138
+ print(f"⚠️ 分 P 序号 {args.page} 越界(共 {len(meta.pages)} P),使用第 1 P", file=sys.stderr)
139
+ page_idx = 0
140
+ else:
141
+ page_idx = args.page - 1
142
+ page = meta.pages[page_idx]
143
+ cid = page["cid"]
144
+ if len(meta.pages) > 1:
145
+ print(f" 当前选择:第 {page['page']} P — {page['part']}")
146
+
147
+ # ---- 5. 拿 playurl + 选流 ----
148
+ want_qn = _resolve_qn(args.quality)
149
+ want_codec = _resolve_codec(args.codec)
150
+ # 用请求的 qn(auto 时传 80 占位,触发返回所有 dash 流)
151
+ playurl = parse.get_playurl(client, bvid, cid, 80 if want_qn == 200 else want_qn, wbi_keys)
152
+ avail = parse.available_qualities(playurl)
153
+ avail_names = [f"{parse.VIDEO_QUALITY_NAME.get(q, q)}" for q in avail]
154
+ print(f" 可用清晰度:{', '.join(avail_names)}", file=sys.stderr)
155
+
156
+ video_stream, audio_stream = parse.select_streams(playurl, want_qn, want_codec)
157
+ if video_stream:
158
+ qn = video_stream.quality_id
159
+ print(f" 选定:{parse.VIDEO_QUALITY_NAME.get(qn, qn)} / {parse.CODEC_NAME.get(video_stream.codec_id, video_stream.codec_id)}",
160
+ file=sys.stderr)
161
+
162
+ # ---- 6. 下载 ----
163
+ out_dir = Path(args.output).expanduser().resolve()
164
+ out_dir.mkdir(parents=True, exist_ok=True)
165
+ base_name = sanitize(meta.title if len(meta.pages) == 1 else f"{meta.title}_P{page['page']}_{page['part']}")
166
+ # 临时文件加 pid 防冲突
167
+ tag = f"bili23_{bvid}"
168
+ video_tmp = out_dir / f"video_{tag}.m4s"
169
+ audio_tmp = out_dir / f"audio_{tag}.m4s"
170
+ output = out_dir / f"{base_name}.mp4"
171
+
172
+ streams_to_dl = []
173
+ if video_stream:
174
+ streams_to_dl.append(("video", video_stream, video_tmp))
175
+ streams_to_dl.append(("audio", audio_stream, audio_tmp))
176
+
177
+ for label_s, s, tmp in streams_to_dl:
178
+ # 探测大小
179
+ size = s.file_size or parse.probe_size(client, s.url, s.backup_urls)
180
+ print(f"→ 下载 {label_s}({_format_size(size) if size else '大小未知'})…", file=sys.stderr)
181
+ t0 = time.time()
182
+
183
+ def cb(d, t, _label=label_s):
184
+ _progress_bar(d, t, label=_label)
185
+
186
+ download.download_stream(
187
+ client, s.url, tmp,
188
+ total_size=size, backup_urls=s.backup_urls,
189
+ thread_count=args.thread, on_progress=cb, label=label_s,
190
+ )
191
+ dt = time.time() - t0
192
+ if size and dt > 0:
193
+ print(f" {label_s} 完成,平均 { _format_size(int(size / dt))}/s", file=sys.stderr)
194
+
195
+ # ---- 7. 合并 ----
196
+ if args.no_merge:
197
+ print(f"\n✓ 已保留分片(--no-merge):", file=sys.stderr)
198
+ for _, _, tmp in streams_to_dl:
199
+ print(f" {tmp}", file=sys.stderr)
200
+ else:
201
+ print("→ 合并音视频…", file=sys.stderr)
202
+ if video_stream:
203
+ merge.merge_video_audio(video_tmp, audio_tmp, output)
204
+ if not args.keep_parts:
205
+ video_tmp.unlink(missing_ok=True)
206
+ audio_tmp.unlink(missing_ok=True)
207
+ else:
208
+ # 纯音频
209
+ audio_out = out_dir / f"{base_name}.m4a"
210
+ merge.remux_audio_only(audio_tmp, audio_out)
211
+ output = audio_out
212
+ if not args.keep_parts:
213
+ audio_tmp.unlink(missing_ok=True)
214
+ print(f"\n✅ 完成:{output}", file=sys.stderr)
215
+
216
+ return 0
@@ -0,0 +1,40 @@
1
+ """httpx 客户端封装。
2
+
3
+ 对应 GUI `src/util/network/request.py` 的核心(去掉 Qt 相关的 NetworkRequestWorker):
4
+ - cookie / UA / Referer 注入
5
+ - 重试、超时、跟随重定向
6
+ - 统一的 get_json 帮助函数(检查 code==0)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+
13
+ def make_client(cookies: dict[str, str], user_agent: str) -> httpx.Client:
14
+ """构造一个注入了鉴权信息的 httpx.Client。
15
+
16
+ 对应 GUI request.py:_create_client() + _apply_cookies()。
17
+ cookie 直接以 dict 形式传入(httpx 会作为请求 Cookie 头发出)。
18
+ """
19
+ transport = httpx.HTTPTransport(retries=3)
20
+ return httpx.Client(
21
+ cookies=cookies,
22
+ headers={
23
+ "User-Agent": user_agent,
24
+ # B 站要求带 Referer,否则很多接口会拒绝
25
+ "Referer": "https://www.bilibili.com/",
26
+ },
27
+ follow_redirects=True,
28
+ timeout=httpx.Timeout(15.0, connect=10.0),
29
+ transport=transport,
30
+ )
31
+
32
+
33
+ def get_json(client: httpx.Client, url: str, *, params: dict | None = None) -> dict:
34
+ """GET 一个 B 站 API,返回 JSON,并校验 code==0。失败抛 RuntimeError。"""
35
+ resp = client.get(url, params=params)
36
+ resp.raise_for_status()
37
+ data = resp.json()
38
+ if data.get("code") != 0:
39
+ raise RuntimeError(f"接口返回错误 code={data.get('code')}: {data.get('message', data)}")
40
+ return data["data"]
@@ -0,0 +1,103 @@
1
+ """读 GUI 版本(Bili23-Downloader)的 config.json,复用其登录后的鉴权数据。
2
+
3
+ 对应 GUI 源码 `src/util/common/config.py` 的 ConfigItem 定义 和
4
+ `src/util/network/request.py:203-223` 的 `get_cookies()`。
5
+
6
+ 我们直接读 config.json 的 `["Cookie"]` section(已是普通 JSON),
7
+ 不依赖 PySide6 / qfluentwidgets,从而实现 headless 复用。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ def _gui_config_paths() -> list[Path]:
19
+ """返回各平台 GUI config.json 的候选路径(取自 GUI 的 QStandardPaths.AppDataLocation 语义)。"""
20
+ home = Path.home()
21
+ app = "Bili23 Downloader"
22
+ if sys.platform == "darwin":
23
+ return [home / "Library" / "Application Support" / app / "config.json"]
24
+ if sys.platform == "win32":
25
+ # %APPDATA% 下;也兼容 roaming/local
26
+ base = os.environ.get("APPDATA") or str(home / "AppData" / "Roaming")
27
+ return [Path(base) / app / "config.json"]
28
+ # Linux / 其他:XDG
29
+ xdg = os.environ.get("XDG_DATA_HOME") or str(home / ".local" / "share")
30
+ return [Path(xdg) / app / "config.json"]
31
+
32
+
33
+ class ConfigNotFoundError(FileNotFoundError):
34
+ """找不到 GUI 的 config.json。"""
35
+
36
+
37
+ def find_gui_config() -> Path:
38
+ """定位 GUI 的 config.json。找不到则抛 ConfigNotFoundError。"""
39
+ for p in _gui_config_paths():
40
+ if p.exists():
41
+ return p
42
+ raise ConfigNotFoundError(
43
+ "未找到 Bili23-Downloader (GUI) 的 config.json,尝试过:\n "
44
+ + "\n ".join(str(p) for p in _gui_config_paths())
45
+ + "\n请先安装 GUI 版本并登录:https://github.com/ScottSloan/Bili23-Downloader"
46
+ )
47
+
48
+
49
+ def load_gui_config(path: Optional[Path] = None) -> dict:
50
+ """读取 GUI 的 config.json 全文。"""
51
+ path = path or find_gui_config()
52
+ return json.loads(Path(path).read_text(encoding="utf-8"))
53
+
54
+
55
+ def get_user_agent(cfg: dict) -> str:
56
+ """从 config 取 UA;缺失时回退到 GUI 默认值(config.py:374)。"""
57
+ return (
58
+ cfg.get("Advanced", {}).get("user_agent")
59
+ or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
60
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0"
61
+ )
62
+
63
+
64
+ def build_cookies(cfg: dict) -> dict[str, str]:
65
+ """复刻 GUI `request.py:get_cookies()`:把 config 的 Cookie section 转成请求 cookie 字典。
66
+
67
+ 关键点(来自 GUI 源码 request.py:203-223):
68
+ - config 的 `uuid` 字段对应 cookie 名 `_uuid`(键名不对称)
69
+ - `b_nut` / `bili_ticket_expires` 要 str() 化
70
+ - `CURRENT_FNVAL=4048` / `CURRENT_QUALITY=0` 是硬编码常量(DASH+全格式位掩码)
71
+ - 登录态字段(SESSDATA 等)仅在 is_login=True 时加入
72
+ """
73
+ ck = cfg.get("Cookie", {})
74
+
75
+ cookies: dict[str, str] = {
76
+ "_uuid": ck.get("uuid", ""),
77
+ "b_lsid": ck.get("b_lsid", ""),
78
+ "b_nut": str(ck.get("b_nut", 0)),
79
+ "bili_ticket": ck.get("bili_ticket", ""),
80
+ "bili_ticket_expires": str(ck.get("bili_ticket_expires", 0)),
81
+ "buvid_fp": ck.get("buvid_fp", ""),
82
+ "buvid3": ck.get("buvid3", ""),
83
+ "buvid4": ck.get("buvid4", ""),
84
+ "CURRENT_FNVAL": "4048",
85
+ "CURRENT_QUALITY": "0",
86
+ }
87
+
88
+ if ck.get("is_login"):
89
+ cookies.update(
90
+ {
91
+ "bili_jct": ck.get("bili_jct", ""),
92
+ "DedeUserID": ck.get("DedeUserID", ""),
93
+ "DedeUserID__ckMd5": ck.get("DedeUserID__ckMd5", ""),
94
+ "SESSDATA": ck.get("SESSDATA", ""),
95
+ }
96
+ )
97
+
98
+ return cookies
99
+
100
+
101
+ def is_logged_in(cfg: dict) -> bool:
102
+ """config 里记录的登录态(仅本地标记;真实有效性靠 nav 接口确认)。"""
103
+ return bool(cfg.get("Cookie", {}).get("is_login", False))
@@ -0,0 +1,143 @@
1
+ """多线程分片下载(Range 请求)。
2
+
3
+ 对应 GUI `src/util/download/downloader/downloader.py` 的 ChunkWorker(去掉 QRunnable/QThreadPool):
4
+ - 预分配文件 → 按 chunk 切片 → 线程池并发 Range 下载 → seek 写入
5
+ - 重试(5xx/429/超时),指数退避
6
+ - on_progress 回调报告进度
7
+
8
+ B 站 dash 流是 m4s,可随机访问(支持 Range),适合分片。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ import httpx
19
+
20
+ CHUNK_SIZE = 4 * 1024 * 1024 # 4MB,对齐 GUI downloader.py:274
21
+ MAX_RETRIES = 5
22
+ RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
23
+
24
+ ProgressCb = Callable[[int, int], None] # (downloaded_bytes, total_bytes)
25
+
26
+
27
+ def download_stream(
28
+ client: httpx.Client,
29
+ url: str,
30
+ dest_path: Path,
31
+ *,
32
+ total_size: int,
33
+ backup_urls: list[str] | None = None,
34
+ thread_count: int = 8,
35
+ on_progress: ProgressCb | None = None,
36
+ label: str = "",
37
+ ) -> None:
38
+ """下载一个流到 dest_path,多线程分片。
39
+
40
+ total_size 由调用方通过 probe_size 得到(>0 时用预分配 + 分片;<=0 时单线程直连下载)。
41
+ """
42
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ if total_size <= 0:
45
+ # 探测失败:单线程顺序下载兜底
46
+ _download_sequential(client, url, dest_path, backup_urls, on_progress, label)
47
+ return
48
+
49
+ # 预分配文件
50
+ with open(dest_path, "wb") as f:
51
+ f.truncate(total_size)
52
+
53
+ chunks = _calc_chunks(total_size, CHUNK_SIZE)
54
+ done_bytes = [0] * len(chunks) # 各分片已下载字节(加锁累加)
55
+ lock = threading.Lock()
56
+
57
+ def report():
58
+ if on_progress:
59
+ with lock:
60
+ downloaded = sum(done_bytes)
61
+ on_progress(downloaded, total_size)
62
+
63
+ urls = [url] + (backup_urls or [])
64
+ headers_base = {"Referer": "https://www.bilibili.com/"}
65
+
66
+ def worker(idx: int, chunk_range: tuple[int, int]):
67
+ start, end = chunk_range # [start, end)
68
+ attempts = 0
69
+ last_err: Exception | None = None
70
+ while attempts < MAX_RETRIES:
71
+ u = urls[attempts % len(urls)] if urls else url
72
+ try:
73
+ # httpx stream 模式发 Range
74
+ with client.stream(
75
+ "GET", u, headers={**headers_base, "Range": f"bytes={start}-{end - 1}"},
76
+ ) as r:
77
+ if r.status_code in RETRYABLE_STATUS:
78
+ raise httpx.HTTPStatusError(f"retryable {r.status_code}", request=r.request, response=r)
79
+ r.raise_for_status()
80
+ written = 0
81
+ with open(dest_path, "r+b") as f:
82
+ f.seek(start)
83
+ for buf in r.iter_bytes(chunk_size=64 * 1024):
84
+ f.write(buf)
85
+ written += len(buf)
86
+ with lock:
87
+ done_bytes[idx] = written
88
+ report()
89
+ return # 该分片完成
90
+ except Exception as e:
91
+ last_err = e
92
+ attempts += 1
93
+ time.sleep(min(2 ** (attempts - 1), 8))
94
+ raise RuntimeError(f"分片 {idx} ({start}-{end}) 下载失败,重试 {MAX_RETRIES} 次:{last_err}")
95
+
96
+ with ThreadPoolExecutor(max_workers=thread_count) as pool:
97
+ futures = {pool.submit(worker, i, c): i for i, c in enumerate(chunks)}
98
+ for fut in as_completed(futures):
99
+ fut.result() # 抛出异常即终止
100
+
101
+ report()
102
+
103
+
104
+ def _download_sequential(
105
+ client: httpx.Client, url: str, dest_path: Path,
106
+ backup_urls: list[str] | None, on_progress: ProgressCb | None, label: str,
107
+ ) -> None:
108
+ """单线程顺序下载(total_size 未知时的兜底)。"""
109
+ urls = [url] + (backup_urls or [])
110
+ headers = {"Referer": "https://www.bilibili.com/"}
111
+ last_err: Exception | None = None
112
+ for attempt in range(MAX_RETRIES):
113
+ u = urls[attempt % len(urls)] if urls else url
114
+ try:
115
+ with client.stream("GET", u, headers=headers) as r:
116
+ r.raise_for_status()
117
+ total = int(r.headers.get("Content-Length", 0))
118
+ downloaded = 0
119
+ with open(dest_path, "wb") as f:
120
+ for buf in r.iter_bytes(chunk_size=64 * 1024):
121
+ f.write(buf)
122
+ downloaded += len(buf)
123
+ if on_progress:
124
+ on_progress(downed := downloaded, total)
125
+ return
126
+ except Exception as e:
127
+ last_err = e
128
+ time.sleep(min(2 ** attempt, 8))
129
+ raise RuntimeError(f"{label} 顺序下载失败:{last_err}")
130
+
131
+
132
+ def _calc_chunks(total: int, piece: int) -> list[tuple[int, int]]:
133
+ """把 [0, total) 切成 piece 大小的 [start, end) 区间列表。
134
+
135
+ 对齐 GUI downloader.py:444-461 的 calc_chunk_list。
136
+ """
137
+ chunks = []
138
+ n = (total + piece - 1) // piece
139
+ for i in range(n):
140
+ start = i * piece
141
+ end = min((i + 1) * piece, total)
142
+ chunks.append((start, end))
143
+ return chunks
@@ -0,0 +1,48 @@
1
+ """ffmpeg 合并音视频。
2
+
3
+ 对应 GUI `src/util/ffmpeg/command.py` 的 merge_video_audio。
4
+ dash 流是分离的 video.m4s + audio.m4s,用 ffmpeg `-c copy` 无损封装成 mp4。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+
13
+ def ffmpeg_available() -> bool:
14
+ return shutil.which("ffmpeg") is not None
15
+
16
+
17
+ def merge_video_audio(video_path: Path, audio_path: Path, output_path: Path) -> None:
18
+ """合并 video.m4s + audio.m4s → output.mp4(无损 copy)。
19
+
20
+ 对应 GUI ffmpeg/command.py:35-63(无封面分支)。
21
+ """
22
+ if not ffmpeg_available():
23
+ raise RuntimeError("未找到 ffmpeg。音视频合并依赖 ffmpeg,请先安装:\n"
24
+ " mac: brew install ffmpeg\n linux: sudo apt install ffmpeg\n"
25
+ " win: https://ffmpeg.org/download.html")
26
+
27
+ cmd = [
28
+ "ffmpeg", "-y",
29
+ "-i", str(video_path),
30
+ "-i", str(audio_path),
31
+ "-c:v", "copy", "-c:a", "copy",
32
+ str(output_path),
33
+ ]
34
+ result = subprocess.run(cmd, capture_output=True)
35
+ if result.returncode != 0:
36
+ raise RuntimeError(
37
+ f"ffmpeg 合并失败 (exit {result.returncode}):\n"
38
+ f"{result.stderr.decode('utf-8', errors='replace')[-1000:]}"
39
+ )
40
+
41
+
42
+ def remux_audio_only(audio_path: Path, output_path: Path) -> None:
43
+ """纯音频:m4s → m4a(直接 copy,仅改容器)。对应 GUI 的纯音频分支。"""
44
+ cmd = ["ffmpeg", "-y", "-i", str(audio_path), "-c:a", "copy", str(output_path)]
45
+ result = subprocess.run(cmd, capture_output=True)
46
+ if result.returncode != 0:
47
+ # 兜底:直接重命名
48
+ shutil.move(str(audio_path), str(output_path))
@@ -0,0 +1,16 @@
1
+ """文件名清洗。对应 GUI util/format/file_name.py 的合法文件名过滤。"""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ # Windows / macOS / Linux 通用的非法文件名字符
7
+ _ILLEGAL = re.compile(r'[/\\:*?"<>|]')
8
+
9
+
10
+ def sanitize(name: str, max_len: int = 120) -> str:
11
+ """清洗成合法文件名:去掉非法字符,裁剪长度,去首尾空白/点。"""
12
+ name = _ILLEGAL.sub("", name)
13
+ name = name.strip().strip(".").strip()
14
+ if len(name) > max_len:
15
+ name = name[:max_len].rstrip()
16
+ return name or "bilibili_video"
@@ -0,0 +1,202 @@
1
+ """视频解析:wbi/view(元信息)+ wbi/playurl(下载流)。
2
+
3
+ 对应 GUI:
4
+ - `src/util/parse/parser/video.py` 的 get_video_info / get_bvid
5
+ - `src/util/parse/preview/previewer.py` 的 get_video_info(playurl)
6
+ - `src/util/download/parse/video_info.py` / `audio_info.py` 的流选择
7
+
8
+ 关键 API:
9
+ - view: https://api.bilibili.com/x/web-interface/wbi/view (+Wbi)
10
+ - playurl: https://api.bilibili.com/x/player/wbi/playurl (+Wbi,fnval=4048)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Optional
17
+
18
+ from . import wbi
19
+
20
+ # 清晰度 / 编码常量(照搬 GUI common/data/media_info.py)
21
+ VIDEO_QUALITY_NAME = {
22
+ 127: "8K", 126: "杜比视界", 125: "HDR", 120: "4K", 116: "1080P60",
23
+ 112: "1080P+", 100: "AI", 80: "1080P", 64: "720P", 32: "480P", 16: "360P",
24
+ }
25
+ # 清晰度优先级(从高到低,auto 模式按此选)
26
+ VIDEO_QUALITY_PRIORITY = [127, 126, 125, 120, 116, 112, 100, 80, 64, 32, 16]
27
+ # 视频编码
28
+ CODEC_NAME = {7: "AVC/H.264", 12: "HEVC/H.265", 13: "AV1"}
29
+ CODEC_PRIORITY = [7, 12, 13] # AVC → HEVC → AV1(GUI 默认偏好)
30
+ # 音频优先级
31
+ AUDIO_QUALITY_PRIORITY = [30251, 30250, 30280, 30232, 30216]
32
+
33
+ VIEW_API = "https://api.bilibili.com/x/web-interface/wbi/view"
34
+ PLAYURL_API = "https://api.bilibili.com/x/player/wbi/playurl"
35
+
36
+
37
+ def extract_bvid(url_or_bvid: str) -> str:
38
+ """从 URL 或裸 BV/av 号中提取 BV 号。
39
+
40
+ 对应 GUI video.py:238-243 / 209-212。av→BV 转换暂不支持(测试地址是 BV)。
41
+ """
42
+ s = url_or_bvid.strip()
43
+ m = re.search(r"BV[0-9A-Za-z]{10}", s)
44
+ if m:
45
+ return m.group(0)
46
+ raise ValueError(f"无法从输入中识别 BV 号:{url_or_bvid!r}(暂仅支持 BV 号与含 BV 的 URL)")
47
+
48
+
49
+ @dataclass
50
+ class VideoMeta:
51
+ bvid: str
52
+ aid: int
53
+ title: str
54
+ up_name: str
55
+ cid: int # 默认(第一 P)的 cid
56
+ pages: list[dict] # [{"cid":.., "part":.., "page":..}]
57
+
58
+
59
+ @dataclass
60
+ class StreamInfo:
61
+ """单个待下载流(video 或 audio)。"""
62
+ url: str
63
+ file_size: int
64
+ media_type: str # "video" | "audio"
65
+ quality_id: int
66
+ codec_id: int
67
+ backup_urls: list[str]
68
+
69
+
70
+ def parse_video(client, bvid: str, wbi_keys: tuple[str, str]) -> VideoMeta:
71
+ """调 wbi/view 拿视频元信息。对应 GUI parser/video.py:274-293。"""
72
+ query = wbi.sign({"bvid": bvid}, *wbi_keys)
73
+ data = client.get(f"{VIEW_API}?{query}")
74
+ data.raise_for_status()
75
+ j = data.json()
76
+ if j.get("code") != 0:
77
+ raise RuntimeError(f"view 接口错误:{j.get('message', j)}")
78
+
79
+ d = j["data"]
80
+ return VideoMeta(
81
+ bvid=d["bvid"],
82
+ aid=d["aid"],
83
+ title=d["title"],
84
+ up_name=d.get("owner", {}).get("name", ""),
85
+ cid=d["cid"],
86
+ pages=[
87
+ {"cid": p["cid"], "part": p.get("part", str(p.get("page", ""))), "page": p.get("page", 1)}
88
+ for p in d.get("pages", [])
89
+ ],
90
+ )
91
+
92
+
93
+ def get_playurl(client, bvid: str, cid: int, qn: int, wbi_keys: tuple[str, str]) -> dict:
94
+ """调 wbi/playurl 拿 dash 数据。对应 GUI previewer.py:118-143 / parse_worker.py:79-96。
95
+
96
+ fnval=4048 是组合位(DASH+HDR+4K+杜比+8K+AV1),拿到所有可用清晰度/编码。
97
+ """
98
+ params = {"bvid": bvid, "cid": cid, "qn": qn, "fnver": 0, "fnval": 4048, "fourk": 1}
99
+ query = wbi.sign(params, *wbi_keys)
100
+ resp = client.get(f"{PLAYURL_API}?{query}")
101
+ resp.raise_for_status()
102
+ j = resp.json()
103
+ if j.get("code") != 0:
104
+ raise RuntimeError(f"playurl 接口错误:{j.get('message', j)}")
105
+ return j["data"]
106
+
107
+
108
+ def available_qualities(playurl_data: dict) -> list[int]:
109
+ """从 dash.video 里提取可用清晰度(qn),降序去重。"""
110
+ qns = {v["id"] for v in playurl_data.get("dash", {}).get("video", [])}
111
+ return sorted(qns, reverse=True)
112
+
113
+
114
+ def select_streams(
115
+ playurl_data: dict,
116
+ qn: int = 200, # 200 = auto,按优先级选最高
117
+ codec_id: int = 20, # 20 = auto
118
+ ) -> tuple[Optional[StreamInfo], StreamInfo]:
119
+ """选择 video 流(按 qn + codec)和 audio 流(按优先级选最高)。
120
+
121
+ 返回 (video_stream_or_None, audio_stream)。纯音频稿件 video 为 None。
122
+ 对应 GUI download/parse/video_info.py + audio_info.py 的选择逻辑。
123
+ """
124
+ dash = playurl_data.get("dash", {})
125
+ video_streams = dash.get("video", [])
126
+ audio_streams = dash.get("audio", [])
127
+
128
+ # ---- 选 video ----
129
+ video: Optional[StreamInfo] = None
130
+ if video_streams:
131
+ # 确定 qn
132
+ if qn == 200: # auto
133
+ avail = available_qualities(playurl_data)
134
+ chosen_qn = next((q for q in VIDEO_QUALITY_PRIORITY if q in set(avail)), avail[0])
135
+ else:
136
+ chosen_qn = qn
137
+ # 在该 qn 下选 codec
138
+ candidates = [v for v in video_streams if v["id"] == chosen_qn]
139
+ if codec_id == 20 and candidates: # auto codec
140
+ chosen_codec = next((c for c in CODEC_PRIORITY if c in {v["codecid"] for v in candidates}),
141
+ candidates[0]["codecid"])
142
+ else:
143
+ chosen_codec = codec_id
144
+ chosen = next((v for v in candidates if v["codecid"] == chosen_codec), candidates[0]) if candidates else None
145
+ if chosen:
146
+ video = _to_stream(chosen, "video")
147
+
148
+ # ---- 选 audio(按优先级)----
149
+ chosen_audio = None
150
+ if audio_streams:
151
+ # 按 id 匹配优先级;GUI 还会处理 dolby/flac,这里取普通 audio 里最高优先级
152
+ ids = {a["id"] for a in audio_streams}
153
+ best_id = next((q for q in AUDIO_QUALITY_PRIORITY if q in ids), audio_streams[0]["id"])
154
+ chosen_audio = next(a for a in audio_streams if a["id"] == best_id)
155
+ elif video is None:
156
+ raise RuntimeError("playurl 既无视频流也无音频流,无法下载")
157
+ audio = _to_stream(chosen_audio, "audio")
158
+ return video, audio
159
+
160
+
161
+ def _to_stream(item: dict, media_type: str) -> StreamInfo:
162
+ """从 dash 里的一个流条目构造 StreamInfo(含备份 URL)。
163
+
164
+ 对应 GUI download/parse/query_worker.py 的 URL 提取逻辑。
165
+ """
166
+ url = item.get("baseUrl") or item.get("base_url") or item.get("url", "")
167
+ backups = item.get("backupUrl") or item.get("backup_url") or item.get("backupUrls", [])
168
+ return StreamInfo(
169
+ url=url,
170
+ file_size=int(item.get("size", 0) or 0),
171
+ media_type=media_type,
172
+ quality_id=item.get("id", 0),
173
+ codec_id=item.get("codecid", 0),
174
+ backup_urls=list(backups),
175
+ )
176
+
177
+
178
+ def probe_size(client, url: str, backup_urls: list[str] | None = None) -> int:
179
+ """HEAD 探测文件大小;容错 CDN 不返回 Content-Length 的情况。
180
+
181
+ B 站 CDN 偶发 HEAD 不带 Content-Length,此时回退 GET stream 读 header 或 Content-Range。
182
+ """
183
+ candidates = [url] + (backup_urls or [])
184
+ for u in candidates:
185
+ if not u:
186
+ continue
187
+ try:
188
+ r = client.head(u, headers={"Referer": "https://www.bilibili.com/"})
189
+ cl = r.headers.get("Content-Length")
190
+ if cl and cl.isdigit() and int(cl) > 0:
191
+ return int(cl)
192
+ # 回退:GET stream(不下载 body)
193
+ g = client.get(u, headers={"Referer": "https://www.bilibili.com/"}, stream=True, params={})
194
+ cl = g.headers.get("Content-Length")
195
+ if not cl and g.headers.get("Content-Range"):
196
+ cl = g.headers["Content-Range"].split("/")[-1]
197
+ g.close()
198
+ if cl and cl.isdigit():
199
+ return int(cl)
200
+ except Exception:
201
+ continue
202
+ return 0 # 探测失败,交给分片下载自己处理
@@ -0,0 +1,75 @@
1
+ """Wbi 签名(B 站接口鉴权)。
2
+
3
+ 照搬 GUI 源码 `src/util/parse/parser/base.py:16-63` 的算法:
4
+ mixinKeyEncTab → getMixinKey(img_key+sub_key)[:32] → 加 wts + 排序 + 剔除!'()*
5
+ → md5(query+mixin_key) → w_rid。
6
+
7
+ 参考:https://github.com/SocialSisterYi/bilibili-API-collect (WBI 签名)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import urllib.parse
13
+ from functools import reduce
14
+ from hashlib import md5
15
+ from pathlib import Path
16
+ from typing import Tuple
17
+
18
+ # 固定的混淆表(GUI base.py:16-21)
19
+ MIXIN_KEY_ENC_TAB = [
20
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
21
+ 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
22
+ 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
23
+ 36, 20, 34, 44, 52,
24
+ ]
25
+
26
+ # nav 接口返回的 wbi_img URL 的 stem 就是这两个 key(GUI user.py:25-29)
27
+ NAV_API = "https://api.bilibili.com/x/web-interface/nav"
28
+
29
+
30
+ def get_mixin_key(img_key: str, sub_key: str) -> str:
31
+ """用混淆表对 img_key+sub_key 重新排列,取前 32 位作为签名密钥。"""
32
+ raw = img_key + sub_key
33
+ return reduce(lambda s, i: s + raw[i], MIXIN_KEY_ENC_TAB, "")[:32]
34
+
35
+
36
+ def sign(params: dict, img_key: str, sub_key: str) -> str:
37
+ """对请求参数做 Wbi 签名,返回完整的 query string(含 wts + w_rid)。
38
+
39
+ 对应 GUI base.py:44-63 的 enc_wbi()。
40
+ """
41
+ mixin_key = get_mixin_key(img_key, sub_key)
42
+ curr_time = round(time.time())
43
+
44
+ params = dict(sorted({**params, "wts": curr_time}.items()))
45
+ # 剔除 B 站规定的非法字符
46
+ params = {
47
+ k: "".join(filter(lambda ch: ch not in "!'()*", str(v)))
48
+ for k, v in params.items()
49
+ }
50
+
51
+ query = urllib.parse.urlencode(params)
52
+ w_rid = md5((query + mixin_key).encode()).hexdigest()
53
+ params["w_rid"] = w_rid
54
+ return urllib.parse.urlencode(params)
55
+
56
+
57
+ def fetch_wbi_keys(client) -> Tuple[str, str, dict]:
58
+ """调 /x/web-interface/nav 拉 img_key / sub_key(该接口本身无需 Wbi 签名)。
59
+
60
+ 对应 GUI user.py:21-29。注意:不信任 config.json 里存的旧值,每次实时拉取。
61
+ 返回 (img_key, sub_key, nav_data),nav_data 含 isLogin/uname/vipStatus 等。
62
+ """
63
+ resp = client.get(NAV_API)
64
+ resp.raise_for_status()
65
+ data = resp.json()
66
+ # 注意:nav 接口在未登录时返回 code=-101,但 data.wbi_img 仍然存在
67
+ # (Wbi 签名密钥与登录态无关,匿名也可用于对 wbi 接口签名)
68
+ payload = data.get("data") or {}
69
+ wbi = payload.get("wbi_img")
70
+ if not wbi or "img_url" not in wbi:
71
+ raise RuntimeError(f"nav 接口未返回 wbi_img:{data.get('message', data)}")
72
+
73
+ img_key = Path(wbi["img_url"]).stem
74
+ sub_key = Path(wbi["sub_url"]).stem
75
+ return img_key, sub_key, payload