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.
- bili23_cli-0.1.0/.gitignore +30 -0
- bili23_cli-0.1.0/CHANGELOG.md +22 -0
- bili23_cli-0.1.0/LICENSE +17 -0
- bili23_cli-0.1.0/PKG-INFO +156 -0
- bili23_cli-0.1.0/README.md +127 -0
- bili23_cli-0.1.0/pyproject.toml +48 -0
- bili23_cli-0.1.0/src/bili23_cli/__init__.py +6 -0
- bili23_cli-0.1.0/src/bili23_cli/__main__.py +4 -0
- bili23_cli-0.1.0/src/bili23_cli/cli.py +216 -0
- bili23_cli-0.1.0/src/bili23_cli/client.py +40 -0
- bili23_cli-0.1.0/src/bili23_cli/config.py +103 -0
- bili23_cli-0.1.0/src/bili23_cli/download.py +143 -0
- bili23_cli-0.1.0/src/bili23_cli/merge.py +48 -0
- bili23_cli-0.1.0/src/bili23_cli/naming.py +16 -0
- bili23_cli-0.1.0/src/bili23_cli/parse.py +202 -0
- bili23_cli-0.1.0/src/bili23_cli/wbi.py +75 -0
|
@@ -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
|
bili23_cli-0.1.0/LICENSE
ADDED
|
@@ -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,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
|