Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276fb5b3e4 | ||
|
|
e05f58b8a1 | ||
|
|
8dfc96e1dc | ||
|
|
cdc639cf75 | ||
|
|
847c3115cd | ||
|
|
7dc049ffe5 | ||
|
|
265fe630dd | ||
|
|
f31900e6c7 | ||
|
|
54b46c150e | ||
|
|
7d9999d6aa | ||
|
|
05aa30119e | ||
|
|
368b9ef735 | ||
|
|
0113bf704d | ||
|
|
66a7b1394e | ||
|
|
ae05cad22f | ||
|
|
be3abab13f | ||
|
|
c432a282a7 | ||
|
|
e9e20ace93 | ||
|
|
6187827e1b | ||
|
|
8a4a95e343 | ||
|
|
401fcdc630 | ||
|
|
b2d22253c5 | ||
|
|
29bfc2efce | ||
|
|
75de39dfbb | ||
|
|
8f37fdf841 | ||
|
|
20e3ac2129 | ||
|
|
3a8f33d273 | ||
|
|
d46881aea6 | ||
|
|
e25339c53c | ||
|
|
5102999676 | ||
|
|
991ce3ea3c | ||
|
|
e4fb096d0c | ||
|
|
28070aa7d8 | ||
|
|
33e758bd91 | ||
|
|
86e858082d | ||
|
|
2ffe432f37 | ||
|
|
6ef9ecaee0 | ||
|
|
9ef88e1b2b | ||
|
|
6e7c6061b2 | ||
|
|
40b3f77748 | ||
|
|
c27d1a2381 | ||
|
|
4c5d1b6ea1 | ||
|
|
0b6fd72682 | ||
|
|
e65cd36b2e | ||
|
|
352282f277 | ||
|
|
fa2bc7b5e8 | ||
|
|
bb90f0c6f2 | ||
|
|
90f2a1d4ed | ||
|
|
e2b65746dd | ||
|
|
24d0da0bf3 | ||
|
|
ff1150e863 | ||
|
|
940abd4f3b | ||
|
|
4c9ad2318c | ||
|
|
097f885050 | ||
|
|
6ebef0a414 | ||
|
|
4818e62414 | ||
|
|
1744f8647b | ||
|
|
c4db12b154 | ||
|
|
2ef99a20c9 | ||
|
|
67de151234 | ||
|
|
73f97f937f | ||
|
|
8fee6fb97a | ||
|
|
e5e5b07978 | ||
|
|
cd2bd9cbb3 | ||
|
|
f044b18337 | ||
|
|
d3bfca42f6 | ||
|
|
10ccb47790 | ||
|
|
e732e7d616 | ||
|
|
f81d9fc6eb | ||
|
|
25c7377b76 | ||
|
|
cd245caabc | ||
|
|
8d9266b2ee | ||
|
|
db62f5527a |
6
.github/workflows/check.yaml
vendored
@@ -5,8 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
types: ['opened', 'reopened', 'synchronize', 'ready_for_review']
|
||||
|
||||
concurrency:
|
||||
# Allow only one workflow per any non-`main` branch.
|
||||
@@ -22,11 +21,12 @@ jobs:
|
||||
tests:
|
||||
name: Run Clippy and tests
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- run: rustup toolchain install nightly && rustup default nightly && rustup component add rustfmt clippy
|
||||
- run: rustup default nightly && rustup component add rustfmt clippy
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
40
.github/workflows/doc.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Build Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
doc:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
name: Build documentation
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
- name: Build documentation
|
||||
run: bun run docs:build
|
||||
- name: Deploy Github Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs/.vitepress/dist
|
||||
force_orphan: true
|
||||
commit_message: 部署来自 main 的最新文档变更:
|
||||
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
**/target
|
||||
auth_data
|
||||
*.sqlite
|
||||
*.json
|
||||
video
|
||||
debug*
|
||||
node_modules
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
1421
Cargo.lock
generated
101
Cargo.toml
@@ -1,54 +1,77 @@
|
||||
[package]
|
||||
name = "bili-sync-rs"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.81", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7", features = ["serde"] }
|
||||
async-stream = "0.3.5"
|
||||
chrono = { version = "0.4.35", features = ["serde"] }
|
||||
cookie = "0.18.0"
|
||||
dirs = "5.0.1"
|
||||
entity = { path = "entity" }
|
||||
env_logger = "0.11.3"
|
||||
filenamify = "0.1.0"
|
||||
[workspace.package]
|
||||
version = "2.2.0"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
bili_sync_entity = { path = "crates/bili_sync_entity" }
|
||||
bili_sync_migration = { path = "crates/bili_sync_migration" }
|
||||
|
||||
anyhow = { version = "1.0.95", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7.1", features = ["serde"] }
|
||||
async-std = { version = "1.13.0", features = ["attributes", "tokio1"] }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.85"
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
clap = { version = "4.5.26", features = ["env"] }
|
||||
cookie = "0.18.1"
|
||||
dirs = "6.0.0"
|
||||
float-ord = "0.3.2"
|
||||
futures = "0.3.30"
|
||||
handlebars = "5.1.2"
|
||||
futures = "0.3.31"
|
||||
handlebars = "6.3.0"
|
||||
hex = "0.4.3"
|
||||
log = "0.4.21"
|
||||
memchr = "2.5.0"
|
||||
migration = { path = "migration" }
|
||||
once_cell = "1.19.0"
|
||||
prost = "0.12.4"
|
||||
quick-xml = { version = "0.31.0", features = ["async-tokio"] }
|
||||
leaky-bucket = "1.1.2"
|
||||
md5 = "0.7.0"
|
||||
memchr = "2.7.4"
|
||||
once_cell = "1.20.2"
|
||||
prost = "0.13.4"
|
||||
quick-xml = { version = "0.37.2", features = ["async-tokio"] }
|
||||
rand = "0.8.5"
|
||||
regex = "1.10.3"
|
||||
reqwest = { version = "0.12.4", features = [
|
||||
"json",
|
||||
"stream",
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.12", features = [
|
||||
"charset",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"charset",
|
||||
"http2",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"stream",
|
||||
], default-features = false }
|
||||
rsa = { version = "0.9.6", features = ["sha2"] }
|
||||
sea-orm = { version = "0.12", features = [
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
rsa = { version = "0.9.7", features = ["sha2"] }
|
||||
sea-orm = { version = "1.1.4", features = [
|
||||
"macros",
|
||||
"runtime-tokio-rustls",
|
||||
"sqlx-sqlite",
|
||||
] }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
thiserror = "1.0.58"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.8.12"
|
||||
sea-orm-migration = { version = "1.1.4", features = [] }
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
serde_json = "1.0.135"
|
||||
serde_urlencoded = "0.7.1"
|
||||
strum = { version = "0.26.3", features = ["derive"] }
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
toml = "0.8.19"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono"] }
|
||||
|
||||
[workspace]
|
||||
members = [".", "entity", "migration"]
|
||||
[workspace.metadata.release]
|
||||
release = false
|
||||
tag-message = ""
|
||||
tag-prefix = ""
|
||||
pre-release-commit-message = "chore: 发布 bili-sync {{version}}"
|
||||
publish = false
|
||||
pre-release-replacements = [
|
||||
{ file = "../../docs/.vitepress/config.mts", search = "\"v[0-9\\.]+\"", replace = "\"v{{version}}\"", exactly = 1 },
|
||||
{ file = "../../docs/introduction.md", search = " v[0-9\\.]+,", replace = " v{{version}},", exactly = 1 },
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
11
Dockerfile
@@ -1,18 +1,15 @@
|
||||
FROM alpine as base
|
||||
FROM alpine AS base
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./*-bili-sync-rs ./targets/
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& apk del tzdata
|
||||
ffmpeg
|
||||
|
||||
COPY ./*-bili-sync-rs ./targets/
|
||||
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
mv ./targets/Linux-x86_64-bili-sync-rs ./bili-sync-rs; \
|
||||
|
||||
21
Justfile
Normal file
@@ -0,0 +1,21 @@
|
||||
clean:
|
||||
rm -rf ./*-bili-sync-rs
|
||||
|
||||
build:
|
||||
cargo build --target x86_64-unknown-linux-musl --release
|
||||
|
||||
build-docker: build
|
||||
cp target/x86_64-unknown-linux-musl/release/bili-sync-rs ./Linux-x86_64-bili-sync-rs
|
||||
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
|
||||
just clean
|
||||
|
||||
copy-config:
|
||||
rm -rf ~/.config/bili-sync
|
||||
cp -r ~/.config/nas/bili-sync-rs ~/.config/bili-sync
|
||||
sed -i -e 's/\/Bilibilis/\/Test_Bilibilis/g' -e 's/.config\/nas/.config\/test_nas/g' ~/.config/bili-sync/config.toml
|
||||
|
||||
run:
|
||||
cargo run
|
||||
|
||||
debug: copy-config
|
||||
just run
|
||||
21
License
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ᴀᴍᴛᴏᴀᴇʀ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
177
README.md
@@ -3,173 +3,38 @@
|
||||
## 简介
|
||||
|
||||
> [!NOTE]
|
||||
> 此为 v2.x 版本文档,v1.x 版本文档请前往[此处](https://github.com/amtoaer/bili-sync/tree/v1.x)查看。
|
||||
|
||||
|
||||
为 NAS 用户编写的 BILIBILI 收藏夹同步工具,可使用 EMBY 等媒体库工具浏览。
|
||||
|
||||
支持展示视频封面、名称、加入日期、标签、分页等。
|
||||
> [点击此处](https://bili-sync.allwens.work/)查看文档
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust & Tokio 驱动。
|
||||
|
||||
## 效果演示
|
||||
|
||||
**注:因为可能同时存在单页视频和多页视频,媒体库类型请选择“混合内容”。**
|
||||
|
||||
### 概览
|
||||

|
||||

|
||||
### 详情
|
||||

|
||||

|
||||
### 播放(使用 infuse)
|
||||

|
||||

|
||||
### 文件排布
|
||||

|
||||

|
||||
|
||||
## 配置文件说明
|
||||
|
||||
> [!NOTE]
|
||||
> 在 Docker 环境中,`~` 会被展开为 `/app`。
|
||||
## 功能与路线图
|
||||
|
||||
程序默认会将配置文件存储于 `~/.config/bili-sync/config.toml`,数据库文件存储于 `~/.config/bili-sync/data.sqlite`,如果发现不存在会新建并写入默认配置。
|
||||
- [x] 使用用户填写的凭据认证,并在必要时自动刷新
|
||||
- [x] 支持收藏夹与视频列表/视频合集的下载
|
||||
- [x] 自动选择用户设置范围内最优的视频和音频流,并在下载完成后使用 FFmpeg 合并
|
||||
- [x] 使用 Tokio 与 Reqwest,对视频、视频分页进行异步并发下载
|
||||
- [x] 使用媒体服务器支持的文件命名,方便一键作为媒体库导入
|
||||
- [x] 当前轮次下载失败会在下一轮下载时重试,失败次数过多自动丢弃
|
||||
- [x] 使用数据库保存媒体信息,避免对同个视频的多次请求
|
||||
- [x] 打印日志,并在请求出现风控时自动终止,等待下一轮执行
|
||||
- [x] 提供多平台的二进制可执行文件,为 Linux 平台提供了立即可用的 Docker 镜像
|
||||
- [x] 支持对“稍后再看”内视频的自动扫描与下载
|
||||
- [x] 支持对 UP 主投稿视频的自动扫描与下载
|
||||
- [x] 支持限制任务的并行度和接口请求频率
|
||||
- [ ] 下载单个文件时支持断点续传与并发下载
|
||||
|
||||
配置文件加载时会进行简单校验,默认配置无法通过校验,程序会报错终止运行。
|
||||
|
||||
可以下载程序后直接运行程序,看到报错后参考报错信息对默认配置进行修改,修改正确后即可正常运行。
|
||||
|
||||
对于配置文件中的 `credential`,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)。
|
||||
|
||||
配置文件中的 `video_name` 和 `page_name` 支持使用模板,模板的替换语法请参考示例。模板中的内容在执行时会被动态替换为对应的内容。
|
||||
|
||||
video_name 支持设置 bvid(视频编号)、title(视频标题)、upper_name(up 主名称)、upper_mid(up 主 id)。
|
||||
|
||||
page_name 除支持 video 的全部参数外,还支持 ptitle(分 P 标题)、pid(分 P 页号)。
|
||||
|
||||
对于每个 favorite_list 的下载路径,程序会在其下建立如下的文件夹:
|
||||
|
||||
1. 单页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── {page_name}.mp4
|
||||
│ ├── {page_name}.nfo
|
||||
│ └── {page_name}-poster.jpg
|
||||
```
|
||||
|
||||
2. 多页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── poster.jpg
|
||||
│ ├── Season 1
|
||||
│ │ ├── {page_name} - S01E01.mp4
|
||||
│ │ ├── {page_name} - S01E01.nfo
|
||||
│ │ ├── {page_name} - S01E01-thumb.jpg
|
||||
│ │ ├── {page_name} - S01E02.mp4
|
||||
│ │ ├── {page_name} - S01E02.nfo
|
||||
│ │ └── {page_name} - S01E02-thumb.jpg
|
||||
│ └── tvshow.nfo
|
||||
```
|
||||
|
||||
对于 filter_option 的可选值,请前往 [analyzer.rs](https://github.com/amtoaer/bili-sync/blob/main/src/bilibili/analyzer.rs) 查看。
|
||||
|
||||
对于 danmaku_option 的项含义,请前往 [danmaku/mod.rs](https://github.com/amtoaer/bili-sync/blob/main/src/bilibili/danmaku/canvas/mod.rs) 与 [原项目的说明](https://github.com/gwy15/danmu2ass?tab=readme-ov-file#%E5%91%BD%E4%BB%A4%E8%A1%8C) 查看。
|
||||
|
||||
## 配置文件示例
|
||||
|
||||
```toml
|
||||
# 视频所处文件夹的名称
|
||||
video_name = "{{title}}"
|
||||
# 视频分页文件的命名
|
||||
page_name = "{{bvid}}"
|
||||
# 扫描运行的间隔(单位:秒)
|
||||
interval = 1200
|
||||
# emby 演员信息的保存位置
|
||||
upper_path = "/home/amtoaer/.config/nas/emby/metadata/people/"
|
||||
|
||||
[credential]
|
||||
# Bilibili 的 Web 端身份凭据,需要凭据才能下载高清视频
|
||||
sessdata = ""
|
||||
bili_jct = ""
|
||||
buvid3 = ""
|
||||
dedeuserid = ""
|
||||
ac_time_value = ""
|
||||
|
||||
[filter_option]
|
||||
# 视频、音频流的筛选选项,程序会使用范围内质量最高的流
|
||||
# 注意设置范围过小可能导致无满足条件的流,推荐仅调整质量上限和编码优先级
|
||||
video_max_quality = "Quality8k"
|
||||
video_min_quality = "Quality360p"
|
||||
audio_max_quality = "QualityHiRES"
|
||||
audio_min_quality = "Quality64k"
|
||||
codecs = [
|
||||
"AV1",
|
||||
"HEV",
|
||||
"AVC",
|
||||
]
|
||||
no_dolby_video = false
|
||||
no_dolby_audio = false
|
||||
no_hdr = false
|
||||
no_hires = false
|
||||
|
||||
[danmaku_option]
|
||||
# 弹幕的一些相关选项,如字体、字号、透明度、停留时间、是否加粗等
|
||||
duration = 12.0
|
||||
font = "黑体"
|
||||
font_size = 25
|
||||
width_ratio = 1.2
|
||||
horizontal_gap = 20.0
|
||||
lane_size = 32
|
||||
float_percentage = 0.5
|
||||
bottom_percentage = 0.3
|
||||
opacity = 76
|
||||
bold = true
|
||||
outline = 0.8
|
||||
time_offset = 0.0
|
||||
|
||||
[favorite_list]
|
||||
# 收藏夹 ID = 存储的位置
|
||||
52642258 = "/home/amtoaer/HDDs/Videos/Bilibilis/混剪"
|
||||
```
|
||||
|
||||
## Docker Compose 文件示例
|
||||
|
||||
该项目为 `Linux/amd64` 与 `Linux/arm64` 提供了 Docker 版本镜像。
|
||||
|
||||
Docker 版包含该平台对应版本的可执行文件(位于`/app/bili-sync-rs`),并预装了 FFmpeg,其它用法与普通版本完全一致。(可查看 [用于构建镜像的 Dockerfile](./Dockerfile) )
|
||||
|
||||
以下是一个 Docker Compose 的编写示例:
|
||||
```yaml
|
||||
services:
|
||||
bili-sync-rs:
|
||||
image: amtoaer/bili-sync-rs:v2.0.0
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
tty: true # 该选项请仅在日志终端支持彩色输出时启用,否则日志中可能会出现乱码
|
||||
hostname: bili-sync-rs
|
||||
container_name: bili-sync-rs
|
||||
volumes:
|
||||
- /home/amtoaer/.config/nas/bili-sync-rs:/app/.config/bili-sync
|
||||
# 以及一些其它必要的挂载,确保此处的挂载与 bili-sync-rs 的配置相匹配
|
||||
# ...
|
||||
logging:
|
||||
driver: "local"
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
- [x] 凭证认证
|
||||
- [x] 视频选优
|
||||
- [x] 视频下载
|
||||
- [x] 支持并发下载
|
||||
- [x] 支持作为 daemon 运行
|
||||
- [x] 构建 nfo 和 poster 文件,方便以单集形式导入 emby
|
||||
- [x] 支持收藏夹翻页,下载全部历史视频
|
||||
- [x] 对接数据库,提前检查,按需下载
|
||||
- [x] 支持弹幕下载
|
||||
- [x] 更好的错误处理
|
||||
- [x] 更好的日志
|
||||
- [x] 请求过快出现风控的 workaround
|
||||
- [x] 提供简单易用的打包(如 docker)
|
||||
- [ ] 支持 UP 主合集下载
|
||||
|
||||
## 参考与借鉴
|
||||
|
||||
@@ -177,4 +42,4 @@ services:
|
||||
|
||||
+ [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) B 站的第三方接口文档
|
||||
+ [bilibili-api](https://github.com/Nemo2011/bilibili-api) 使用 Python 调用接口的参考实现
|
||||
+ [danmu2ass](https://github.com/gwy15/danmu2ass) 本项目弹幕下载功能的缝合来源
|
||||
+ [danmu2ass](https://github.com/gwy15/danmu2ass) 本项目弹幕下载功能的缝合来源
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 MiB |
BIN
assets/detail.webp
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
assets/dir.png
|
Before Width: | Height: | Size: 1015 KiB |
BIN
assets/dir.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 4.6 MiB |
BIN
assets/overview.webp
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
assets/play.png
|
Before Width: | Height: | Size: 2.4 MiB |
BIN
assets/play.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
52
crates/bili_sync/Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "bili_sync"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
readme = "../../README.md"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bili_sync_entity = { workspace = true }
|
||||
bili_sync_migration = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
cookie = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
float-ord = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
handlebars = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
leaky-bucket = { workspace = true }
|
||||
md5 = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[package.metadata.release]
|
||||
release = true
|
||||
|
||||
[[bin]]
|
||||
name = "bili-sync-rs"
|
||||
path = "src/main.rs"
|
||||
203
crates/bili_sync/src/adapter/collection.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{IntoCondition, OnConflict};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, TransactionTrait};
|
||||
|
||||
use crate::adapter::{helper, VideoListModel};
|
||||
use crate::bilibili::{self, BiliClient, Collection, CollectionItem, CollectionType, VideoInfo};
|
||||
use crate::utils::status::Status;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for collection::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
helper::count_videos(video::Column::CollectionId.eq(self.id).into_condition(), connection).await
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
helper::filter_videos(
|
||||
video::Column::CollectionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
helper::filter_videos_with_pages(
|
||||
video::Column::CollectionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
helper::video_keys(
|
||||
video::Column::CollectionId.eq(self.id),
|
||||
videos_info,
|
||||
[video::Column::Bvid, video::Column::Pubtime],
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.collection_id = Set(Some(self.id));
|
||||
helper::video_with_path(video_model, &self.path, video_info)
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
video: bilibili::Video<'_>,
|
||||
video_model: video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, view_info)) => {
|
||||
let VideoInfo::View { pages, .. } = &view_info else {
|
||||
unreachable!("view_info must be VideoInfo::View")
|
||||
};
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
helper::create_video_pages(pages, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
|
||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
helper::error_fetch_video_detail(e, video_model, connection).await?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!(
|
||||
"开始获取{} {} - {} 的视频与分页信息...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!(
|
||||
"获取{} {} - {} 的视频与分页信息完成",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!(
|
||||
"开始下载{}: {} - {} 中所有未处理过的视频...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!(
|
||||
"下载{}: {} - {} 中未处理过的视频完成",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!(
|
||||
"开始扫描{}: {} - {} 的新视频...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描{}: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name,
|
||||
got_count,
|
||||
new_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn collection_from<'a>(
|
||||
collection_item: &'a CollectionItem,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let collection = Collection::new(bili_client, collection_item);
|
||||
let collection_info = collection.get_info().await?;
|
||||
collection::Entity::insert(collection::ActiveModel {
|
||||
s_id: Set(collection_info.sid),
|
||||
m_id: Set(collection_info.mid),
|
||||
r#type: Set(collection_info.collection_type.into()),
|
||||
name: Set(collection_info.name.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
collection::Column::SId,
|
||||
collection::Column::MId,
|
||||
collection::Column::Type,
|
||||
])
|
||||
.update_columns([collection::Column::Name, collection::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
collection::Entity::find()
|
||||
.filter(
|
||||
collection::Column::SId
|
||||
.eq(collection_item.sid.clone())
|
||||
.and(collection::Column::MId.eq(collection_item.mid.clone()))
|
||||
.and(collection::Column::Type.eq(Into::<i32>::into(collection_item.collection_type.clone()))),
|
||||
)
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(collection.into_simple_video_stream()),
|
||||
))
|
||||
}
|
||||
160
crates/bili_sync/src/adapter/favorite.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{IntoCondition, OnConflict};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, TransactionTrait};
|
||||
|
||||
use crate::adapter::{helper, VideoListModel};
|
||||
use crate::bilibili::{self, BiliClient, FavoriteList, VideoInfo};
|
||||
use crate::utils::status::Status;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for favorite::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
helper::count_videos(video::Column::FavoriteId.eq(self.id).into_condition(), connection).await
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
helper::filter_videos(
|
||||
video::Column::FavoriteId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
helper::filter_videos_with_pages(
|
||||
video::Column::FavoriteId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
helper::video_keys(
|
||||
video::Column::FavoriteId.eq(self.id),
|
||||
videos_info,
|
||||
[video::Column::Bvid, video::Column::Favtime],
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.favorite_id = Set(Some(self.id));
|
||||
helper::video_with_path(video_model, &self.path, video_info)
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
video: bilibili::Video<'_>,
|
||||
video_model: video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, pages_info)) => {
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
helper::create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
helper::error_fetch_video_detail(e, video_model, connection).await?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始获取收藏夹 {} - {} 的视频与分页信息...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("获取收藏夹 {} - {} 的视频与分页信息完成", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载收藏夹: {} - {} 中所有未处理过的视频...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载收藏夹: {} - {} 中未处理过的视频完成", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描收藏夹: {} - {} 的新视频...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描收藏夹: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
self.f_id, self.name, got_count, new_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn favorite_from<'a>(
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let favorite = FavoriteList::new(bili_client, fid.to_owned());
|
||||
let favorite_info = favorite.get_info().await?;
|
||||
favorite::Entity::insert(favorite::ActiveModel {
|
||||
f_id: Set(favorite_info.id),
|
||||
name: Set(favorite_info.title.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(favorite::Column::FId)
|
||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::FId.eq(favorite_info.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(favorite.into_video_stream()),
|
||||
))
|
||||
}
|
||||
138
crates/bili_sync/src/adapter/helper/mod.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{Condition, DatabaseTransaction, QuerySelect};
|
||||
|
||||
use crate::bilibili::{BiliError, PageInfo, VideoInfo};
|
||||
use crate::config::{PathSafeTemplate, TEMPLATE};
|
||||
use crate::utils::id_time_key;
|
||||
|
||||
/// 使用 condition 筛选视频,返回视频数量
|
||||
pub(super) async fn count_videos(condition: Condition, conn: &DatabaseConnection) -> Result<u64> {
|
||||
Ok(video::Entity::find().filter(condition).count(conn).await?)
|
||||
}
|
||||
|
||||
/// 使用 condition 筛选视频,返回视频列表
|
||||
pub(super) async fn filter_videos(condition: Condition, conn: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
Ok(video::Entity::find().filter(condition).all(conn).await?)
|
||||
}
|
||||
|
||||
/// 使用 condition 筛选视频,返回视频列表和相关的分 P 列表
|
||||
pub(super) async fn filter_videos_with_pages(
|
||||
condition: Condition,
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(condition)
|
||||
.find_with_related(page::Entity)
|
||||
.all(conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 返回 videos_info 存在于视频表里那部分对应的 key
|
||||
pub(super) async fn video_keys(
|
||||
expr: SimpleExpr,
|
||||
videos_info: &[VideoInfo],
|
||||
columns: [video::Column; 2],
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::Bvid
|
||||
.is_in(videos_info.iter().map(|v| v.bvid().to_string()))
|
||||
.and(expr),
|
||||
)
|
||||
.select_only()
|
||||
.columns(columns)
|
||||
.into_tuple()
|
||||
.all(conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(bvid, time)| id_time_key(&bvid, &time))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 返回设置了 path 的视频
|
||||
pub(super) fn video_with_path(
|
||||
mut video_model: video::ActiveModel,
|
||||
base_path: &str,
|
||||
video_info: &VideoInfo,
|
||||
) -> video::ActiveModel {
|
||||
if let Some(fmt_args) = &video_info.to_fmt_args() {
|
||||
video_model.path = Set(Path::new(base_path)
|
||||
.join(TEMPLATE.path_safe_render("video", fmt_args).unwrap())
|
||||
.to_string_lossy()
|
||||
.to_string());
|
||||
}
|
||||
video_model
|
||||
}
|
||||
|
||||
/// 处理获取视频详细信息失败的情况
|
||||
pub(super) async fn error_fetch_video_detail(
|
||||
e: anyhow::Error,
|
||||
video_model: bili_sync_entity::video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建视频的所有分 P
|
||||
pub(crate) async fn create_video_pages(
|
||||
pages_info: &[PageInfo],
|
||||
video_model: &video::Model,
|
||||
connection: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.iter()
|
||||
.map(move |p| {
|
||||
let (width, height) = match &p.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
(Some(d.width), Some(d.height))
|
||||
} else {
|
||||
(Some(d.height), Some(d.width))
|
||||
}
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
cid: Set(p.cid),
|
||||
pid: Set(p.page),
|
||||
name: Set(p.name.clone()),
|
||||
width: Set(width),
|
||||
height: Set(height),
|
||||
duration: Set(p.duration),
|
||||
image: Set(p.first_frame.clone()),
|
||||
download_status: Set(0),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
for page_chunk in page_models.chunks(50) {
|
||||
page::Entity::insert_many(page_chunk.to_vec())
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
94
crates/bili_sync/src/adapter/mod.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
mod collection;
|
||||
mod favorite;
|
||||
mod helper;
|
||||
mod submission;
|
||||
mod watch_later;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::adapter::collection::collection_from;
|
||||
use crate::adapter::favorite::favorite_from;
|
||||
use crate::adapter::submission::submission_from;
|
||||
use crate::adapter::watch_later::watch_later_from;
|
||||
use crate::bilibili::{self, BiliClient, CollectionItem, VideoInfo};
|
||||
|
||||
pub enum Args<'a> {
|
||||
Favorite { fid: &'a str },
|
||||
Collection { collection_item: &'a CollectionItem },
|
||||
WatchLater,
|
||||
Submission { upper_id: &'a str },
|
||||
}
|
||||
|
||||
pub async fn video_list_from<'a>(
|
||||
args: Args<'a>,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
match args {
|
||||
Args::Favorite { fid } => favorite_from(fid, path, bili_client, connection).await,
|
||||
Args::Collection { collection_item } => collection_from(collection_item, path, bili_client, connection).await,
|
||||
Args::WatchLater => watch_later_from(path, bili_client, connection).await,
|
||||
Args::Submission { upper_id } => submission_from(upper_id, path, bili_client, connection).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VideoListModel {
|
||||
/// 与视频列表关联的视频总数
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64>;
|
||||
|
||||
/// 未填充的视频
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<bili_sync_entity::video::Model>>;
|
||||
|
||||
/// 未处理的视频和分页
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(bili_sync_entity::video::Model, Vec<bili_sync_entity::page::Model>)>>;
|
||||
|
||||
/// 该批次视频的存在标记
|
||||
async fn exist_labels(&self, videos_info: &[VideoInfo], connection: &DatabaseConnection)
|
||||
-> Result<HashSet<String>>;
|
||||
|
||||
/// 视频信息对应的视频 model
|
||||
fn video_model_by_info(
|
||||
&self,
|
||||
video_info: &VideoInfo,
|
||||
base_model: Option<bili_sync_entity::video::Model>,
|
||||
) -> bili_sync_entity::video::ActiveModel;
|
||||
|
||||
/// 视频 model 中缺失的信息
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
video: bilibili::Video<'_>,
|
||||
video_model: bili_sync_entity::video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()>;
|
||||
|
||||
/// 开始获取视频
|
||||
fn log_fetch_video_start(&self);
|
||||
|
||||
/// 结束获取视频
|
||||
fn log_fetch_video_end(&self);
|
||||
|
||||
/// 开始下载视频
|
||||
fn log_download_video_start(&self);
|
||||
|
||||
/// 结束下载视频
|
||||
fn log_download_video_end(&self);
|
||||
|
||||
/// 开始刷新视频
|
||||
fn log_refresh_video_start(&self);
|
||||
|
||||
/// 结束刷新视频
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64);
|
||||
}
|
||||
176
crates/bili_sync/src/adapter/submission.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{IntoCondition, OnConflict};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, TransactionTrait};
|
||||
|
||||
use crate::adapter::helper::video_with_path;
|
||||
use crate::adapter::{helper, VideoListModel};
|
||||
use crate::bilibili::{self, BiliClient, Submission, VideoInfo};
|
||||
use crate::utils::status::Status;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for submission::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
helper::count_videos(video::Column::SubmissionId.eq(self.id).into_condition(), connection).await
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
helper::filter_videos(
|
||||
video::Column::SubmissionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
helper::filter_videos_with_pages(
|
||||
video::Column::SubmissionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
helper::video_keys(
|
||||
video::Column::SubmissionId.eq(self.id),
|
||||
videos_info,
|
||||
[video::Column::Bvid, video::Column::Ctime],
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.submission_id = Set(Some(self.id));
|
||||
video_with_path(video_model, &self.path, video_info)
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
video: bilibili::Video<'_>,
|
||||
video_model: video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, view_info)) => {
|
||||
let VideoInfo::View { pages, .. } = &view_info else {
|
||||
unreachable!("view_info must be VideoInfo::View")
|
||||
};
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
helper::create_video_pages(pages, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
|
||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
helper::error_fetch_video_detail(e, video_model, connection).await?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!(
|
||||
"开始获取 UP 主 {} - {} 投稿的视频与分页信息...",
|
||||
self.upper_id, self.upper_name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!(
|
||||
"获取 UP 主 {} - {} 投稿的视频与分页信息完成",
|
||||
self.upper_id, self.upper_name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!(
|
||||
"开始下载 UP 主 {} - {} 投稿的所有未处理过的视频...",
|
||||
self.upper_id, self.upper_name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!(
|
||||
"下载 UP 主 {} - {} 投稿的所有未处理过的视频完成",
|
||||
self.upper_id, self.upper_name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描 UP 主 {} - {} 投稿的新视频...", self.upper_id, self.upper_name);
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描 UP 主 {} - {} 投稿的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
self.upper_id, self.upper_name, got_count, new_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn submission_from<'a>(
|
||||
upper_id: &str,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let submission = Submission::new(bili_client, upper_id.to_owned());
|
||||
let upper = submission.get_info().await?;
|
||||
submission::Entity::insert(submission::ActiveModel {
|
||||
upper_id: Set(upper.mid.parse()?),
|
||||
upper_name: Set(upper.name),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(submission::Column::UpperId)
|
||||
.update_columns([submission::Column::UpperName, submission::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::UpperId.eq(upper.mid))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(submission.into_video_stream()),
|
||||
))
|
||||
}
|
||||
158
crates/bili_sync/src/adapter/watch_later.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{IntoCondition, OnConflict};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, TransactionTrait};
|
||||
|
||||
use crate::adapter::helper::video_with_path;
|
||||
use crate::adapter::{helper, VideoListModel};
|
||||
use crate::bilibili::{self, BiliClient, VideoInfo, WatchLater};
|
||||
use crate::utils::status::Status;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for watch_later::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
helper::count_videos(video::Column::WatchLaterId.eq(self.id).into_condition(), connection).await
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
helper::filter_videos(
|
||||
video::Column::WatchLaterId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
helper::filter_videos_with_pages(
|
||||
video::Column::WatchLaterId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.into_condition(),
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
helper::video_keys(
|
||||
video::Column::WatchLaterId.eq(self.id),
|
||||
videos_info,
|
||||
[video::Column::Bvid, video::Column::Favtime],
|
||||
connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.watch_later_id = Set(Some(self.id));
|
||||
video_with_path(video_model, &self.path, video_info)
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
video: bilibili::Video<'_>,
|
||||
video_model: video::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, pages_info)) => {
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
helper::create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
helper::error_fetch_video_detail(e, video_model, connection).await?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始获取稍后再看的视频与分页信息...");
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("获取稍后再看的视频与分页信息完成");
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载稍后再看中所有未处理过的视频...");
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载稍后再看中未处理过的视频完成");
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描稍后再看的新视频...");
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描稍后再看的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
got_count, new_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn watch_later_from<'a>(
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let watch_later = WatchLater::new(bili_client);
|
||||
watch_later::Entity::insert(watch_later::ActiveModel {
|
||||
id: Set(1),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(watch_later::Column::Id)
|
||||
.update_column(watch_later::Column::Path)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
watch_later::Entity::find()
|
||||
.filter(watch_later::Column::Id.eq(1))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(watch_later.into_video_stream()),
|
||||
))
|
||||
}
|
||||
385
crates/bili_sync/src/bilibili/analyzer.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::error::BiliError;
|
||||
|
||||
pub struct PageAnalyzer {
|
||||
info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, strum::FromRepr, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
pub enum VideoQuality {
|
||||
Quality360p = 16,
|
||||
Quality480p = 32,
|
||||
Quality720p = 64,
|
||||
Quality1080p = 80,
|
||||
Quality1080pPLUS = 112,
|
||||
Quality1080p60 = 116,
|
||||
Quality4k = 120,
|
||||
QualityHdr = 125,
|
||||
QualityDolby = 126,
|
||||
Quality8k = 127,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, strum::FromRepr, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AudioQuality {
|
||||
Quality64k = 30216,
|
||||
Quality132k = 30232,
|
||||
QualityDolby = 30250,
|
||||
QualityHiRES = 30251,
|
||||
Quality192k = 30280,
|
||||
}
|
||||
|
||||
impl AudioQuality {
|
||||
#[inline]
|
||||
pub fn as_sort_key(&self) -> isize {
|
||||
match self {
|
||||
// 这可以让 Dolby 和 Hi-RES 排在 192k 之后,且 Dolby 和 Hi-RES 之间的顺序不变
|
||||
Self::QualityHiRES | Self::QualityDolby => (*self as isize) + 40,
|
||||
_ => *self as isize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd<AudioQuality> for AudioQuality {
|
||||
fn partial_cmp(&self, other: &AudioQuality) -> Option<std::cmp::Ordering> {
|
||||
self.as_sort_key().partial_cmp(&other.as_sort_key())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
pub enum VideoCodecs {
|
||||
#[strum(serialize = "hev")]
|
||||
HEV,
|
||||
#[strum(serialize = "avc")]
|
||||
AVC,
|
||||
#[strum(serialize = "av01")]
|
||||
AV1,
|
||||
}
|
||||
|
||||
// 视频流的筛选偏好
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FilterOption {
|
||||
pub video_max_quality: VideoQuality,
|
||||
pub video_min_quality: VideoQuality,
|
||||
pub audio_max_quality: AudioQuality,
|
||||
pub audio_min_quality: AudioQuality,
|
||||
pub codecs: Vec<VideoCodecs>,
|
||||
pub no_dolby_video: bool,
|
||||
pub no_dolby_audio: bool,
|
||||
pub no_hdr: bool,
|
||||
pub no_hires: bool,
|
||||
}
|
||||
|
||||
impl Default for FilterOption {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
video_max_quality: VideoQuality::Quality8k,
|
||||
video_min_quality: VideoQuality::Quality360p,
|
||||
audio_max_quality: AudioQuality::QualityHiRES,
|
||||
audio_min_quality: AudioQuality::Quality64k,
|
||||
codecs: vec![VideoCodecs::AV1, VideoCodecs::HEV, VideoCodecs::AVC],
|
||||
no_dolby_video: false,
|
||||
no_dolby_audio: false,
|
||||
no_hdr: false,
|
||||
no_hires: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上游项目中的五种流类型,不过目测应该只有 Flv、DashVideo、DashAudio 三种会被用到
|
||||
#[derive(Debug, PartialEq, PartialOrd)]
|
||||
pub enum Stream {
|
||||
Flv(String),
|
||||
Html5Mp4(String),
|
||||
EpositeTryMp4(String),
|
||||
DashVideo {
|
||||
url: String,
|
||||
quality: VideoQuality,
|
||||
codecs: VideoCodecs,
|
||||
},
|
||||
DashAudio {
|
||||
url: String,
|
||||
quality: AudioQuality,
|
||||
},
|
||||
}
|
||||
|
||||
// 通用的获取流链接的方法,交由 Downloader 使用
|
||||
impl Stream {
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
Self::Flv(url) => url,
|
||||
Self::Html5Mp4(url) => url,
|
||||
Self::EpositeTryMp4(url) => url,
|
||||
Self::DashVideo { url, .. } => url,
|
||||
Self::DashAudio { url, .. } => url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于获取视频流的最佳筛选结果,有两种可能:
|
||||
/// 1. 单个混合流,作为 Mixed 返回
|
||||
/// 2. 视频、音频分离,作为 VideoAudio 返回,其中音频流可能不存在(对于无声视频,如 BV1J7411H7KQ)
|
||||
#[derive(Debug)]
|
||||
pub enum BestStream {
|
||||
VideoAudio { video: Stream, audio: Option<Stream> },
|
||||
Mixed(Stream),
|
||||
}
|
||||
|
||||
impl PageAnalyzer {
|
||||
pub fn new(info: serde_json::Value) -> Self {
|
||||
Self { info }
|
||||
}
|
||||
|
||||
fn is_flv_stream(&self) -> bool {
|
||||
self.info.get("durl").is_some() && self.info["format"].as_str().is_some_and(|f| f.starts_with("flv"))
|
||||
}
|
||||
|
||||
fn is_html5_mp4_stream(&self) -> bool {
|
||||
self.info.get("durl").is_some()
|
||||
&& self.info["format"].as_str().is_some_and(|f| f.starts_with("mp4"))
|
||||
&& self.info["is_html5"].as_bool().is_some_and(|b| b)
|
||||
}
|
||||
|
||||
fn is_episode_try_mp4_stream(&self) -> bool {
|
||||
self.info.get("durl").is_some()
|
||||
&& self.info["format"].as_str().is_some_and(|f| f.starts_with("mp4"))
|
||||
&& self.info["is_html5"].as_bool().is_none_or(|b| !b)
|
||||
}
|
||||
|
||||
/// 获取所有的视频、音频流,并根据条件筛选
|
||||
fn streams(&mut self, filter_option: &FilterOption) -> Result<Vec<Stream>> {
|
||||
if self.is_flv_stream() {
|
||||
return Ok(vec![Stream::Flv(
|
||||
self.info["durl"][0]["url"]
|
||||
.as_str()
|
||||
.ok_or(anyhow!("invalid flv stream"))?
|
||||
.to_string(),
|
||||
)]);
|
||||
}
|
||||
if self.is_html5_mp4_stream() {
|
||||
return Ok(vec![Stream::Html5Mp4(
|
||||
self.info["durl"][0]["url"]
|
||||
.as_str()
|
||||
.ok_or(anyhow!("invalid html5 mp4 stream"))?
|
||||
.to_string(),
|
||||
)]);
|
||||
}
|
||||
if self.is_episode_try_mp4_stream() {
|
||||
return Ok(vec![Stream::EpositeTryMp4(
|
||||
self.info["durl"][0]["url"]
|
||||
.as_str()
|
||||
.ok_or(anyhow!("invalid episode try mp4 stream"))?
|
||||
.to_string(),
|
||||
)]);
|
||||
}
|
||||
let mut streams: Vec<Stream> = Vec::new();
|
||||
for video in self.info["dash"]["video"]
|
||||
.as_array()
|
||||
.ok_or(BiliError::RiskControlOccurred)?
|
||||
.iter()
|
||||
{
|
||||
let (Some(url), Some(quality), Some(codecs)) = (
|
||||
video["baseUrl"].as_str(),
|
||||
video["id"].as_u64(),
|
||||
video["codecs"].as_str(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let quality = VideoQuality::from_repr(quality as usize).ok_or(anyhow!("invalid video stream quality"))?;
|
||||
// 从视频流的 codecs 字段中获取编码格式,此处并非精确匹配而是判断包含,比如 codecs 是 av1.42c01e,需要匹配为 av1
|
||||
let Some(codecs) = [VideoCodecs::HEV, VideoCodecs::AVC, VideoCodecs::AV1]
|
||||
.into_iter()
|
||||
.find(|c| codecs.contains(c.as_ref()))
|
||||
else {
|
||||
// 少数情况会走到此处,如 codecs 为 dvh1.08.09、hvc1.2.4.L123.90 等,直接跳过,不影响流程
|
||||
continue;
|
||||
};
|
||||
if !filter_option.codecs.contains(&codecs)
|
||||
|| quality < filter_option.video_min_quality
|
||||
|| quality > filter_option.video_max_quality
|
||||
|| (quality == VideoQuality::QualityHdr && filter_option.no_hdr)
|
||||
|| (quality == VideoQuality::QualityDolby && filter_option.no_dolby_video)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
streams.push(Stream::DashVideo {
|
||||
url: url.to_string(),
|
||||
quality,
|
||||
codecs,
|
||||
});
|
||||
}
|
||||
if let Some(audios) = self.info["dash"]["audio"].as_array() {
|
||||
for audio in audios.iter() {
|
||||
let (Some(url), Some(quality)) = (audio["baseUrl"].as_str(), audio["id"].as_u64()) else {
|
||||
continue;
|
||||
};
|
||||
let quality =
|
||||
AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid audio stream quality"))?;
|
||||
if quality < filter_option.audio_min_quality || quality > filter_option.audio_max_quality {
|
||||
continue;
|
||||
}
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
let flac = &self.info["dash"]["flac"]["audio"];
|
||||
if !(filter_option.no_hires || flac.is_null()) {
|
||||
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
|
||||
bail!("invalid flac stream");
|
||||
};
|
||||
let quality = AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid flac stream quality"))?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
let dolby_audio = &self.info["dash"]["dolby"]["audio"][0];
|
||||
if !(filter_option.no_dolby_audio || dolby_audio.is_null()) {
|
||||
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
|
||||
bail!("invalid dolby audio stream");
|
||||
};
|
||||
let quality =
|
||||
AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid dolby audio stream quality"))?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(streams)
|
||||
}
|
||||
|
||||
pub fn best_stream(&mut self, filter_option: &FilterOption) -> Result<BestStream> {
|
||||
let streams = self.streams(filter_option)?;
|
||||
if self.is_flv_stream() || self.is_html5_mp4_stream() || self.is_episode_try_mp4_stream() {
|
||||
// 按照 streams 中的假设,符合这三种情况的流只有一个,直接取
|
||||
return Ok(BestStream::Mixed(
|
||||
streams.into_iter().next().ok_or(anyhow!("no stream found"))?,
|
||||
));
|
||||
}
|
||||
let (videos, audios): (Vec<Stream>, Vec<Stream>) =
|
||||
streams.into_iter().partition(|s| matches!(s, Stream::DashVideo { .. }));
|
||||
Ok(BestStream::VideoAudio {
|
||||
video: Iterator::max_by(videos.into_iter(), |a, b| match (a, b) {
|
||||
(
|
||||
Stream::DashVideo {
|
||||
quality: a_quality,
|
||||
codecs: a_codecs,
|
||||
..
|
||||
},
|
||||
Stream::DashVideo {
|
||||
quality: b_quality,
|
||||
codecs: b_codecs,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
if a_quality != b_quality {
|
||||
return a_quality.partial_cmp(b_quality).unwrap();
|
||||
};
|
||||
filter_option
|
||||
.codecs
|
||||
.iter()
|
||||
.position(|c| c == b_codecs)
|
||||
.cmp(&filter_option.codecs.iter().position(|c| c == a_codecs))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.ok_or(anyhow!("no video stream found"))?,
|
||||
audio: Iterator::max_by(audios.into_iter(), |a, b| match (a, b) {
|
||||
(Stream::DashAudio { quality: a_quality, .. }, Stream::DashAudio { quality: b_quality, .. }) => {
|
||||
a_quality.partial_cmp(b_quality).unwrap()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bilibili::{BiliClient, Video};
|
||||
use crate::config::CONFIG;
|
||||
|
||||
#[test]
|
||||
fn test_quality_order() {
|
||||
assert!([
|
||||
VideoQuality::Quality360p,
|
||||
VideoQuality::Quality480p,
|
||||
VideoQuality::Quality720p,
|
||||
VideoQuality::Quality1080p,
|
||||
VideoQuality::Quality1080pPLUS,
|
||||
VideoQuality::Quality1080p60,
|
||||
VideoQuality::Quality4k,
|
||||
VideoQuality::QualityHdr,
|
||||
VideoQuality::QualityDolby,
|
||||
VideoQuality::Quality8k
|
||||
]
|
||||
.is_sorted());
|
||||
assert!([
|
||||
AudioQuality::Quality64k,
|
||||
AudioQuality::Quality132k,
|
||||
AudioQuality::Quality192k,
|
||||
AudioQuality::QualityDolby,
|
||||
AudioQuality::QualityHiRES,
|
||||
]
|
||||
.is_sorted());
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_best_stream() {
|
||||
let testcases = [
|
||||
// 随便一个 8k + hires 视频
|
||||
(
|
||||
"BV1xRChYUE2R",
|
||||
VideoQuality::Quality8k,
|
||||
Some(AudioQuality::QualityHiRES),
|
||||
),
|
||||
// 一个没有声音的纯视频
|
||||
("BV1J7411H7KQ", VideoQuality::Quality720p, None),
|
||||
// 一个杜比全景声的演示片
|
||||
(
|
||||
"BV1Mm4y1P7JV",
|
||||
VideoQuality::Quality4k,
|
||||
Some(AudioQuality::QualityDolby),
|
||||
),
|
||||
];
|
||||
for (bvid, video_quality, audio_quality) in testcases.into_iter() {
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, bvid.to_owned());
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let best_stream = video
|
||||
.get_page_analyzer(&first_page)
|
||||
.await
|
||||
.expect("failed to get page analyzer")
|
||||
.best_stream(&CONFIG.filter_option)
|
||||
.expect("failed to get best stream");
|
||||
dbg!(bvid, &best_stream);
|
||||
match best_stream {
|
||||
BestStream::VideoAudio {
|
||||
video: Stream::DashVideo { quality, .. },
|
||||
audio,
|
||||
} => {
|
||||
assert_eq!(quality, video_quality);
|
||||
assert_eq!(
|
||||
audio.map(|audio_stream| match audio_stream {
|
||||
Stream::DashAudio { quality, .. } => quality,
|
||||
_ => unreachable!(),
|
||||
}),
|
||||
audio_quality,
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use leaky_bucket::RateLimiter;
|
||||
use reqwest::{header, Method};
|
||||
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
use crate::bilibili::Credential;
|
||||
use crate::config::CONFIG;
|
||||
use crate::config::{RateLimit, CONFIG};
|
||||
|
||||
// 一个对 reqwest::Client 的简单封装,用于 Bilibili 请求
|
||||
#[derive(Clone)]
|
||||
@@ -29,7 +32,7 @@ impl Client {
|
||||
.default_headers(headers)
|
||||
.gzip(true)
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(std::time::Duration::from_secs(30))
|
||||
.read_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -60,15 +63,32 @@ impl Default for Client {
|
||||
|
||||
pub struct BiliClient {
|
||||
pub client: Client,
|
||||
limiter: Option<RateLimiter>,
|
||||
}
|
||||
|
||||
impl BiliClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::new();
|
||||
Self { client }
|
||||
let limiter = CONFIG
|
||||
.concurrent_limit
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|RateLimit { limit, duration }| {
|
||||
RateLimiter::builder()
|
||||
.initial(*limit)
|
||||
.refill(*limit)
|
||||
.max(*limit)
|
||||
.interval(Duration::from_millis(*duration))
|
||||
.build()
|
||||
});
|
||||
Self { client, limiter }
|
||||
}
|
||||
|
||||
pub fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
|
||||
/// 获取一个预构建的请求,通过该方法获取请求时会检查并等待速率限制
|
||||
pub async fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
|
||||
if let Some(limiter) = &self.limiter {
|
||||
limiter.acquire_one().await;
|
||||
}
|
||||
let credential = CONFIG.credential.load();
|
||||
self.client.request(method, url, credential.as_deref())
|
||||
}
|
||||
@@ -85,4 +105,13 @@ impl BiliClient {
|
||||
CONFIG.credential.store(Some(Arc::new(new_credential)));
|
||||
CONFIG.save()
|
||||
}
|
||||
|
||||
/// 获取 wbi img,用于生成请求签名
|
||||
pub async fn wbi_img(&self) -> Result<WbiImg> {
|
||||
let credential = CONFIG.credential.load();
|
||||
let Some(credential) = credential.as_deref() else {
|
||||
bail!("no credential found");
|
||||
};
|
||||
credential.wbi_img(&self.client).await
|
||||
}
|
||||
}
|
||||
268
crates/bili_sync/src/bilibili/collection.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo, MIXIN_KEY};
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
|
||||
pub enum CollectionType {
|
||||
Series,
|
||||
Season,
|
||||
}
|
||||
|
||||
impl From<CollectionType> for i32 {
|
||||
fn from(v: CollectionType) -> Self {
|
||||
match v {
|
||||
CollectionType::Series => 1,
|
||||
CollectionType::Season => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for CollectionType {
|
||||
fn from(v: i32) -> Self {
|
||||
match v {
|
||||
1 => CollectionType::Series,
|
||||
2 => CollectionType::Season,
|
||||
_ => panic!("invalid collection type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CollectionType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
CollectionType::Series => "视频列表",
|
||||
CollectionType::Season => "视频合集",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
pub struct CollectionItem {
|
||||
pub mid: String,
|
||||
pub sid: String,
|
||||
pub collection_type: CollectionType,
|
||||
}
|
||||
|
||||
pub struct Collection<'a> {
|
||||
client: &'a BiliClient,
|
||||
collection: &'a CollectionItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CollectionInfo {
|
||||
pub name: String,
|
||||
pub mid: i64,
|
||||
pub sid: i64,
|
||||
pub collection_type: CollectionType,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CollectionInfo {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionInfoRaw {
|
||||
mid: i64,
|
||||
name: String,
|
||||
season_id: Option<i64>,
|
||||
series_id: Option<i64>,
|
||||
}
|
||||
let raw = CollectionInfoRaw::deserialize(deserializer)?;
|
||||
let (sid, collection_type) = match (raw.season_id, raw.series_id) {
|
||||
(Some(sid), None) => (sid, CollectionType::Season),
|
||||
(None, Some(sid)) => (sid, CollectionType::Series),
|
||||
_ => return Err(serde::de::Error::custom("invalid collection info")),
|
||||
};
|
||||
Ok(CollectionInfo {
|
||||
mid: raw.mid,
|
||||
name: raw.name,
|
||||
sid,
|
||||
collection_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Collection<'a> {
|
||||
pub fn new(client: &'a BiliClient, collection: &'a CollectionItem) -> Self {
|
||||
Self { client, collection }
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<CollectionInfo> {
|
||||
let meta = match self.collection.collection_type {
|
||||
// 没有找到专门获取 Season 信息的接口,所以直接获取第一页,从里面取 meta 信息
|
||||
CollectionType::Season => self.get_videos(1).await?["data"]["meta"].take(),
|
||||
CollectionType::Series => self.get_series_info().await?["data"]["meta"].take(),
|
||||
};
|
||||
Ok(serde_json::from_value(meta)?)
|
||||
}
|
||||
|
||||
async fn get_series_info(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series")
|
||||
.await
|
||||
.query(&[("series_id", self.collection.sid.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
let page = page.to_string();
|
||||
let (url, query) = match self.collection.collection_type {
|
||||
CollectionType::Series => (
|
||||
"https://api.bilibili.com/x/series/archives",
|
||||
encoded_query(
|
||||
vec![
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("series_id", self.collection.sid.as_str()),
|
||||
("only_normal", "true"),
|
||||
("sort", "desc"),
|
||||
("pn", page.as_str()),
|
||||
("ps", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
|
||||
),
|
||||
),
|
||||
CollectionType::Season => (
|
||||
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||
encoded_query(
|
||||
vec![
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("season_id", self.collection.sid.as_str()),
|
||||
("sort_reverse", "true"),
|
||||
("page_num", page.as_str()),
|
||||
("page_size", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
|
||||
),
|
||||
),
|
||||
};
|
||||
self.client
|
||||
.request(Method::GET, url)
|
||||
.await
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_simple_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
|
||||
stream! {
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let mut videos = match self.get_videos(page).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to get videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if !videos["data"]["archives"].is_array() {
|
||||
warn!("no videos found in collection {:?} page {}", self.collection, page);
|
||||
break;
|
||||
}
|
||||
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["archives"].take()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to parse videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
for video_info in videos_info{
|
||||
yield video_info;
|
||||
}
|
||||
let fields = match self.collection.collection_type{
|
||||
CollectionType::Series => ["num", "size", "total"],
|
||||
CollectionType::Season => ["page_num", "page_size", "total"],
|
||||
};
|
||||
let fields = fields.into_iter().map(|f| videos["data"]["page"][f].as_i64()).collect::<Option<Vec<i64>>>().map(|v| (v[0], v[1], v[2]));
|
||||
let Some((num, size, total)) = fields else {
|
||||
error!("failed to get pages of collection {:?} page {}: {:?}", self.collection, page, fields);
|
||||
break;
|
||||
};
|
||||
if num * size >= total {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_collection_info_parse() {
|
||||
let testcases = vec![
|
||||
(
|
||||
r#"
|
||||
{
|
||||
"category": 0,
|
||||
"cover": "https://archive.biliimg.com/bfs/archive/a6fbf7a7b9f4af09d9cf40482268634df387ef68.jpg",
|
||||
"description": "",
|
||||
"mid": 521722088,
|
||||
"name": "合集·【命运方舟全剧情解说】",
|
||||
"ptime": 1714701600,
|
||||
"season_id": 1987140,
|
||||
"total": 10
|
||||
}
|
||||
"#,
|
||||
CollectionInfo {
|
||||
mid: 521722088,
|
||||
name: "合集·【命运方舟全剧情解说】".to_owned(),
|
||||
sid: 1987140,
|
||||
collection_type: CollectionType::Season,
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"
|
||||
{
|
||||
"series_id": 387212,
|
||||
"mid": 521722088,
|
||||
"name": "提瓦特冒险记",
|
||||
"description": "原神沙雕般的游戏体验",
|
||||
"keywords": [
|
||||
""
|
||||
],
|
||||
"creator": "",
|
||||
"state": 2,
|
||||
"last_update_ts": 1633167320,
|
||||
"total": 3,
|
||||
"ctime": 1633167320,
|
||||
"mtime": 1633167320,
|
||||
"raw_keywords": "",
|
||||
"category": 1
|
||||
}
|
||||
"#,
|
||||
CollectionInfo {
|
||||
mid: 521722088,
|
||||
name: "提瓦特冒险记".to_owned(),
|
||||
sid: 387212,
|
||||
collection_type: CollectionType::Series,
|
||||
},
|
||||
),
|
||||
];
|
||||
for (json, expect) in testcases {
|
||||
let info: CollectionInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info, expect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
@@ -9,8 +10,13 @@ use rsa::sha2::Sha256;
|
||||
use rsa::{Oaep, RsaPublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::error::BiliError;
|
||||
use crate::bilibili::Client;
|
||||
use crate::bilibili::{Client, Validate};
|
||||
|
||||
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38,
|
||||
41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
|
||||
20, 34, 44, 52,
|
||||
];
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
@@ -21,7 +27,39 @@ pub struct Credential {
|
||||
pub ac_time_value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WbiImg {
|
||||
img_url: String,
|
||||
sub_url: String,
|
||||
}
|
||||
|
||||
impl From<WbiImg> for Option<String> {
|
||||
/// 尝试将 WbiImg 转换成 mixin_key
|
||||
fn from(value: WbiImg) -> Self {
|
||||
let key = match (
|
||||
get_filename(value.img_url.as_str()),
|
||||
get_filename(value.sub_url.as_str()),
|
||||
) {
|
||||
(Some(img_key), Some(sub_key)) => img_key.to_string() + sub_key,
|
||||
_ => return None,
|
||||
};
|
||||
let key = key.as_bytes();
|
||||
Some(MIXIN_KEY_ENC_TAB.iter().take(32).map(|&x| key[x] as char).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Credential {
|
||||
pub async fn wbi_img(&self, client: &Client) -> Result<WbiImg> {
|
||||
let mut res = client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/nav", Some(self))
|
||||
.send()
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"]["wbi_img"].take())?)
|
||||
}
|
||||
|
||||
/// 检查凭据是否有效
|
||||
pub async fn need_refresh(&self, client: &Client) -> Result<bool> {
|
||||
let res = client
|
||||
@@ -34,14 +72,8 @@ impl Credential {
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
res["data"]["refresh"].as_bool().ok_or(anyhow!("check refresh failed"))
|
||||
}
|
||||
|
||||
@@ -105,14 +137,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
.error_for_status()?;
|
||||
// 必须在 .json 前取出 headers,否则 res 会被消耗
|
||||
let headers = std::mem::take(res.headers_mut());
|
||||
let res = res.json::<serde_json::Value>().await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
let res = res.json::<serde_json::Value>().await?.validate()?;
|
||||
let set_cookies = headers.get_all(header::SET_COOKIE);
|
||||
let mut credential = Self {
|
||||
buvid3: self.buvid3.clone(),
|
||||
@@ -144,7 +169,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
}
|
||||
|
||||
async fn confirm_refresh(&self, client: &Client, new_credential: &Credential) -> Result<()> {
|
||||
let res = client
|
||||
client
|
||||
.request(
|
||||
Method::POST,
|
||||
"https://passport.bilibili.com/x/passport-login/web/confirm/refresh",
|
||||
@@ -159,14 +184,8 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -183,6 +202,49 @@ fn regex_find(pattern: &str, doc: &str) -> Result<String> {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn get_filename(url: &str) -> Option<&str> {
|
||||
url.rsplit_once('/')
|
||||
.and_then(|(_, s)| s.rsplit_once('.'))
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
|
||||
pub fn encoded_query<'a>(
|
||||
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
|
||||
mixin_key: Option<&str>,
|
||||
) -> Vec<(&'a str, Cow<'a, str>)> {
|
||||
match mixin_key {
|
||||
Some(key) => _encoded_query(params, key, chrono::Local::now().timestamp().to_string()),
|
||||
None => params.into_iter().map(|(k, v)| (k, v.into())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn _encoded_query<'a>(
|
||||
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
|
||||
mixin_key: &str,
|
||||
timestamp: String,
|
||||
) -> Vec<(&'a str, Cow<'a, str>)> {
|
||||
let mut params: Vec<(&'a str, Cow<'a, str>)> = params
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
// FIXME: 总感觉这里不太好,即使 v 是 &str 也会被转换成 String
|
||||
v.into()
|
||||
.chars()
|
||||
.filter(|&x| !"!'()*".contains(x))
|
||||
.collect::<String>()
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
params.push(("wts", timestamp.into()));
|
||||
params.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let query = serde_urlencoded::to_string(¶ms).unwrap().replace('+', "%20");
|
||||
params.push(("w_rid", format!("{:x}", md5::compute(query.clone() + mixin_key)).into()));
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -201,4 +263,42 @@ mod tests {
|
||||
"b0cc8411ded2f9db2cff2edb3123acac",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_query() {
|
||||
let query = vec![
|
||||
("bar", "五一四".to_string()),
|
||||
("baz", "1919810".to_string()),
|
||||
("foo", "one one four".to_string()),
|
||||
];
|
||||
assert_eq!(
|
||||
serde_urlencoded::to_string(query).unwrap().replace('+', "%20"),
|
||||
"bar=%E4%BA%94%E4%B8%80%E5%9B%9B&baz=1919810&foo=one%20one%20four"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wbi_key() {
|
||||
let key = WbiImg {
|
||||
img_url: "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string(),
|
||||
sub_url: "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string(),
|
||||
};
|
||||
let key = Option::<String>::from(key).expect("fail to convert key");
|
||||
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
|
||||
assert_eq!(
|
||||
dbg!(_encoded_query(
|
||||
vec![("foo", "114"), ("bar", "514"), ("zab", "1919810")],
|
||||
key.as_str(),
|
||||
"1702204169".to_string(),
|
||||
)),
|
||||
// 上面产生的结果全是 Cow::Owned,但 eq 只会比较值,这样写比较方便
|
||||
vec![
|
||||
("bar", Cow::Borrowed("514")),
|
||||
("foo", Cow::Borrowed("114")),
|
||||
("wts", Cow::Borrowed("1702204169")),
|
||||
("zab", Cow::Borrowed("1919810")),
|
||||
("w_rid", Cow::Borrowed("8f6f2b5b3d485fe1886cec6a0be8c5d4")),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::error::BiliError;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
pub struct FavoriteList<'a> {
|
||||
client: &'a BiliClient,
|
||||
fid: String,
|
||||
@@ -19,26 +16,8 @@ pub struct FavoriteListInfo {
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct VideoInfo {
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub vtype: i32,
|
||||
pub bvid: String,
|
||||
pub intro: String,
|
||||
pub cover: String,
|
||||
pub upper: Upper,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub ctime: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub fav_time: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub pubtime: DateTime<Utc>,
|
||||
pub attr: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Upper {
|
||||
pub mid: i64,
|
||||
pub struct Upper<T> {
|
||||
pub mid: T,
|
||||
pub name: String,
|
||||
pub face: String,
|
||||
}
|
||||
@@ -51,26 +30,21 @@ impl<'a> FavoriteList<'a> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/folder/info")
|
||||
.await
|
||||
.query(&[("media_id", &self.fid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
async fn get_videos(&self, page: u32) -> Result<Value> {
|
||||
let res = self
|
||||
.client
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/resource/list")
|
||||
.await
|
||||
.query(&[
|
||||
("media_id", self.fid.as_str()),
|
||||
("pn", &page.to_string()),
|
||||
@@ -83,15 +57,8 @@ impl<'a> FavoriteList<'a> {
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
Ok(res)
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
// 拿到收藏夹的所有权,返回一个收藏夹下的视频流
|
||||
@@ -117,7 +84,7 @@ impl<'a> FavoriteList<'a> {
|
||||
break;
|
||||
},
|
||||
};
|
||||
for video_info in videos_info.into_iter(){
|
||||
for video_info in videos_info{
|
||||
yield video_info;
|
||||
}
|
||||
if videos["data"]["has_more"].is_boolean() && videos["data"]["has_more"].as_bool().unwrap(){
|
||||
180
crates/bili_sync/src/bilibili/mod.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{bail, Result};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use client::{BiliClient, Client};
|
||||
pub use collection::{Collection, CollectionItem, CollectionType};
|
||||
pub use credential::Credential;
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
use once_cell::sync::Lazy;
|
||||
pub use submission::Submission;
|
||||
pub use video::{Dimension, PageInfo, Video};
|
||||
pub use watch_later::WatchLater;
|
||||
|
||||
mod analyzer;
|
||||
mod client;
|
||||
mod collection;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod submission;
|
||||
mod video;
|
||||
mod watch_later;
|
||||
|
||||
static MIXIN_KEY: Lazy<ArcSwapOption<String>> = Lazy::new(Default::default);
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn set_global_mixin_key(key: String) {
|
||||
MIXIN_KEY.store(Some(Arc::new(key)));
|
||||
}
|
||||
|
||||
pub(crate) trait Validate {
|
||||
type Output;
|
||||
|
||||
fn validate(self) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl Validate for serde_json::Value {
|
||||
type Output = serde_json::Value;
|
||||
|
||||
fn validate(self) -> Result<Self::Output> {
|
||||
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
/// 注意此处的顺序是有要求的,因为对于 untagged 的 enum 来说,serde 会按照顺序匹配
|
||||
/// > There is no explicit tag identifying which variant the data contains.
|
||||
/// > Serde will try to match the data against each variant in order and the first one that deserializes successfully is the one returned.
|
||||
pub enum VideoInfo {
|
||||
/// 从视频详情接口获取的视频信息
|
||||
View {
|
||||
title: String,
|
||||
bvid: String,
|
||||
#[serde(rename = "desc")]
|
||||
intro: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "owner")]
|
||||
upper: Upper<i64>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
pages: Vec<PageInfo>,
|
||||
state: i32,
|
||||
},
|
||||
/// 从收藏夹中获取的视频信息
|
||||
Detail {
|
||||
title: String,
|
||||
#[serde(rename = "type")]
|
||||
vtype: i32,
|
||||
bvid: String,
|
||||
intro: String,
|
||||
cover: String,
|
||||
upper: Upper<i64>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
fav_time: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
attr: i32,
|
||||
},
|
||||
/// 从稍后再看中获取的视频信息
|
||||
WatchLater {
|
||||
title: String,
|
||||
bvid: String,
|
||||
#[serde(rename = "desc")]
|
||||
intro: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "owner")]
|
||||
upper: Upper<i64>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "add_at", with = "ts_seconds")]
|
||||
fav_time: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
state: i32,
|
||||
},
|
||||
/// 从视频列表中获取的视频信息
|
||||
Simple {
|
||||
bvid: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
},
|
||||
Submission {
|
||||
title: String,
|
||||
bvid: String,
|
||||
#[serde(rename = "description")]
|
||||
intro: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "created", with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::{pin_mut, StreamExt};
|
||||
|
||||
use super::*;
|
||||
use crate::utils::init_logger;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_video_info_type() {
|
||||
init_logger("None,bili_sync=debug");
|
||||
let bili_client = BiliClient::new();
|
||||
// 请求 UP 主视频必须要获取 mixin key,使用 key 计算请求参数的签名,否则直接提示权限不足返回空
|
||||
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
|
||||
panic!("获取 mixin key 失败");
|
||||
};
|
||||
set_global_mixin_key(mixin_key);
|
||||
let video = Video::new(&bili_client, "BV1Z54y1C7ZB".to_string());
|
||||
assert!(matches!(video.get_view_info().await, Ok(VideoInfo::View { .. })));
|
||||
let collection_item = CollectionItem {
|
||||
mid: "521722088".to_string(),
|
||||
sid: "387214".to_string(),
|
||||
collection_type: CollectionType::Series,
|
||||
};
|
||||
let collection = Collection::new(&bili_client, &collection_item);
|
||||
let stream = collection.into_simple_video_stream();
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(VideoInfo::Simple { .. })));
|
||||
let favorite = FavoriteList::new(&bili_client, "3084505258".to_string());
|
||||
let stream = favorite.into_video_stream();
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(VideoInfo::Detail { .. })));
|
||||
let watch_later = WatchLater::new(&bili_client);
|
||||
let stream = watch_later.into_video_stream();
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(VideoInfo::WatchLater { .. })));
|
||||
let submission = Submission::new(&bili_client, "956761".to_string());
|
||||
let stream = submission.into_video_stream();
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(VideoInfo::Submission { .. })));
|
||||
}
|
||||
}
|
||||
93
crates/bili_sync/src/bilibili/submission.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use anyhow::Result;
|
||||
use arc_swap::access::Access;
|
||||
use async_stream::stream;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::favorite_list::Upper;
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo, MIXIN_KEY};
|
||||
pub struct Submission<'a> {
|
||||
client: &'a BiliClient,
|
||||
upper_id: String,
|
||||
}
|
||||
|
||||
impl<'a> Submission<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String) -> Self {
|
||||
Self { client, upper_id }
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<Upper<String>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/card")
|
||||
.await
|
||||
.query(&[("mid", self.upper_id.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"]["card"].take())?)
|
||||
}
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/space/wbi/arc/search")
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("mid", self.upper_id.clone()),
|
||||
("order", "pubdate".to_string()),
|
||||
("order_avoided", "true".to_string()),
|
||||
("platform", "web".to_string()),
|
||||
("web_location", "1550101".to_string()),
|
||||
("pn", page.to_string()),
|
||||
("ps", "30".to_string()),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
|
||||
stream! {
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let mut videos = match self.get_videos(page).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to get videos of upper {} page {}: {}", self.upper_id, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if !videos["data"]["list"]["vlist"].is_array() {
|
||||
warn!("no medias found in upper {} page {}", self.upper_id, page);
|
||||
break;
|
||||
}
|
||||
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["list"]["vlist"].take()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to parse videos of upper {} page {}: {}", self.upper_id, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
for video_info in videos_info{
|
||||
yield video_info;
|
||||
}
|
||||
if videos["data"]["page"]["count"].is_i64() && videos["data"]["page"]["count"].as_i64().unwrap() > (page * 30) as i64 {
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ use reqwest::Method;
|
||||
|
||||
use crate::bilibili::analyzer::PageAnalyzer;
|
||||
use crate::bilibili::client::BiliClient;
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
|
||||
use crate::bilibili::error::BiliError;
|
||||
use crate::bilibili::{Validate, VideoInfo, MIXIN_KEY};
|
||||
|
||||
static MASK_CODE: u64 = 2251799813685247;
|
||||
static XOR_CODE: u64 = 23442827791579;
|
||||
@@ -39,7 +40,7 @@ impl serde::Serialize for Tag {
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct PageInfo {
|
||||
pub cid: i32,
|
||||
pub cid: i64,
|
||||
pub page: i32,
|
||||
#[serde(rename = "part")]
|
||||
pub name: String,
|
||||
@@ -61,23 +62,34 @@ impl<'a> Video<'a> {
|
||||
Self { client, aid, bvid }
|
||||
}
|
||||
|
||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||
/// 直接调用视频信息接口获取详细的视频信息
|
||||
pub async fn get_view_info(&self) -> Result<VideoInfo> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/pagelist")
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view")
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/pagelist")
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
@@ -85,26 +97,21 @@ impl<'a> Video<'a> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||
let tasks = FuturesUnordered::new();
|
||||
for i in 1..=(page.duration + 359) / 360 {
|
||||
tasks.push(self.get_danmaku_segment(page, i as i32));
|
||||
tasks.push(self.get_danmaku_segment(page, i as i64));
|
||||
}
|
||||
let result: Vec<Vec<DanmakuElem>> = tasks.try_collect().await?;
|
||||
let mut result: Vec<DanmakuElem> = result.into_iter().flatten().collect();
|
||||
@@ -112,17 +119,18 @@ impl<'a> Video<'a> {
|
||||
Ok(DanmakuWriter::new(page, result.into_iter().map(|x| x.into()).collect()))
|
||||
}
|
||||
|
||||
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i32) -> Result<Vec<DanmakuElem>> {
|
||||
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i64) -> Result<Vec<DanmakuElem>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "http://api.bilibili.com/x/v2/dm/web/seg.so")
|
||||
.await
|
||||
.query(&[("type", 1), ("oid", page.cid), ("segment_index", segment_idx)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let headers = std::mem::take(res.headers_mut());
|
||||
let content_type = headers.get("content-type");
|
||||
if !content_type.is_some_and(|v| v == "application/octet-stream") {
|
||||
if content_type.is_none_or(|v| v != "application/octet-stream") {
|
||||
bail!(
|
||||
"unexpected content type: {:?}, body: {:?}",
|
||||
content_type,
|
||||
@@ -136,26 +144,24 @@ impl<'a> Video<'a> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/playurl")
|
||||
.query(&[
|
||||
("avid", self.aid.as_str()),
|
||||
("cid", page.cid.to_string().as_str()),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
("fourk", "1"),
|
||||
])
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("avid", self.aid.as_str()),
|
||||
("cid", page.cid.to_string().as_str()),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
("fourk", "1"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
let (code, msg) = match (res["code"].as_i64(), res["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(PageAnalyzer::new(res["data"].take()))
|
||||
}
|
||||
}
|
||||
@@ -176,8 +182,8 @@ fn bvid_to_aid(bvid: &str) -> u64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bvid_to_aid() {
|
||||
#[test]
|
||||
fn test_bvid_to_aid() {
|
||||
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
||||
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
||||
}
|
||||
49
crates/bili_sync/src/bilibili/watch_later.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
pub struct WatchLater<'a> {
|
||||
client: &'a BiliClient,
|
||||
}
|
||||
|
||||
impl<'a> WatchLater<'a> {
|
||||
pub fn new(client: &'a BiliClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
async fn get_videos(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v2/history/toview")
|
||||
.await
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
|
||||
stream! {
|
||||
let Ok(mut videos) = self.get_videos().await else {
|
||||
error!("Failed to get watch later list");
|
||||
return;
|
||||
};
|
||||
if !videos["data"]["list"].is_array() {
|
||||
error!("Watch later list is not an array");
|
||||
}
|
||||
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["list"].take()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Failed to parse watch later list: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
for video in videos_info {
|
||||
yield video;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/bili_sync/src/config/clap.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[arg(short, long, env = "SCAN_ONLY")]
|
||||
pub scan_only: bool,
|
||||
|
||||
#[arg(short, long, default_value = "None,bili_sync=info", env = "RUST_LOG")]
|
||||
pub log_level: String,
|
||||
}
|
||||
84
crates/bili_sync/src/config/global.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use handlebars::handlebars_helper;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::config::clap::Args;
|
||||
use crate::config::item::PathSafeTemplate;
|
||||
use crate::config::Config;
|
||||
|
||||
/// 全局的 CONFIG,可以从中读取配置信息
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(load_config);
|
||||
|
||||
/// 全局的 TEMPLATE,用来渲染 video_name 和 page_name 模板
|
||||
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||
if s.chars().count() > len {
|
||||
s.chars().take(len).collect::<String>()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars.path_safe_register("video", &CONFIG.video_name).unwrap();
|
||||
handlebars.path_safe_register("page", &CONFIG.page_name).unwrap();
|
||||
handlebars
|
||||
});
|
||||
|
||||
/// 全局的 ARGS,用来解析命令行参数
|
||||
pub static ARGS: Lazy<Args> = Lazy::new(Args::parse);
|
||||
|
||||
/// 全局的 CONFIG_DIR,表示配置文件夹的路径
|
||||
pub static CONFIG_DIR: Lazy<PathBuf> =
|
||||
Lazy::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[inline]
|
||||
fn load_config() -> Config {
|
||||
let config = Config::load().unwrap_or_else(|err| {
|
||||
if err
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_none_or(|e| e.kind() != std::io::ErrorKind::NotFound)
|
||||
{
|
||||
panic!("加载配置文件失败,错误为: {err}");
|
||||
}
|
||||
warn!("配置文件不存在,使用默认配置...");
|
||||
Config::default()
|
||||
});
|
||||
// 放到外面,确保新的配置项被保存
|
||||
info!("配置加载完毕,覆盖刷新原有配置");
|
||||
config.save().unwrap();
|
||||
// 检查配置文件内容
|
||||
info!("校验配置文件内容...");
|
||||
config.check();
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn load_config() -> Config {
|
||||
let credential = match (
|
||||
std::env::var("TEST_SESSDATA"),
|
||||
std::env::var("TEST_BILI_JCT"),
|
||||
std::env::var("TEST_BUVID3"),
|
||||
std::env::var("TEST_DEDEUSERID"),
|
||||
std::env::var("TEST_AC_TIME_VALUE"),
|
||||
) {
|
||||
(Ok(sessdata), Ok(bili_jct), Ok(buvid3), Ok(dedeuserid), Ok(ac_time_value)) => {
|
||||
Some(std::sync::Arc::new(crate::bilibili::Credential {
|
||||
sessdata,
|
||||
bili_jct,
|
||||
buvid3,
|
||||
dedeuserid,
|
||||
ac_time_value,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Config {
|
||||
credential: arc_swap::ArcSwapOption::from(credential),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
139
crates/bili_sync/src/config/item.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::de::{Deserializer, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{CollectionItem, CollectionType};
|
||||
use crate::utils::filenamify::filenamify;
|
||||
|
||||
/// 稍后再看的配置
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct WatchLaterConfig {
|
||||
pub enabled: bool,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// NFO 文件使用的时间类型
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NFOTimeType {
|
||||
#[default]
|
||||
FavTime,
|
||||
PubTime,
|
||||
}
|
||||
|
||||
/// 并发下载相关的配置
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ConcurrentLimit {
|
||||
pub video: usize,
|
||||
pub page: usize,
|
||||
pub rate_limit: Option<RateLimit>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RateLimit {
|
||||
pub limit: usize,
|
||||
pub duration: u64,
|
||||
}
|
||||
|
||||
impl Default for ConcurrentLimit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
video: 3,
|
||||
page: 2,
|
||||
// 默认的限速配置,每 250ms 允许请求 4 次
|
||||
rate_limit: Some(RateLimit {
|
||||
limit: 4,
|
||||
duration: 250,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PathSafeTemplate {
|
||||
fn path_safe_register(&mut self, name: &'static str, template: &'static str) -> Result<()>;
|
||||
fn path_safe_render(&self, name: &'static str, data: &serde_json::Value) -> Result<String>;
|
||||
}
|
||||
|
||||
/// 通过将模板字符串中的分隔符替换为自定义的字符串,使得模板字符串中的分隔符得以保留
|
||||
impl PathSafeTemplate for handlebars::Handlebars<'_> {
|
||||
fn path_safe_register(&mut self, name: &'static str, template: &'static str) -> Result<()> {
|
||||
Ok(self.register_template_string(name, template.replace(std::path::MAIN_SEPARATOR_STR, "__SEP__"))?)
|
||||
}
|
||||
|
||||
fn path_safe_render(&self, name: &'static str, data: &serde_json::Value) -> Result<String> {
|
||||
Ok(filenamify(&self.render(name, data)?).replace("__SEP__", std::path::MAIN_SEPARATOR_STR))
|
||||
}
|
||||
}
|
||||
/* 后面是用于自定义 Collection 的序列化、反序列化的样板代码 */
|
||||
pub(super) fn serialize_collection_list<S>(
|
||||
collection_list: &HashMap<CollectionItem, PathBuf>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(collection_list.len()))?;
|
||||
for (k, v) in collection_list {
|
||||
let prefix = match k.collection_type {
|
||||
CollectionType::Series => "series",
|
||||
CollectionType::Season => "season",
|
||||
};
|
||||
map.serialize_entry(&[prefix, &k.mid, &k.sid].join(":"), v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
pub(super) fn deserialize_collection_list<'de, D>(deserializer: D) -> Result<HashMap<CollectionItem, PathBuf>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct CollectionListVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for CollectionListVisitor {
|
||||
type Value = HashMap<CollectionItem, PathBuf>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a map of collection list")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let mut collection_list = HashMap::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, PathBuf>()? {
|
||||
let collection_item = match key.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
[prefix, mid, sid] => {
|
||||
let collection_type = match *prefix {
|
||||
"series" => CollectionType::Series,
|
||||
"season" => CollectionType::Season,
|
||||
_ => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"invalid collection type, should be series or season",
|
||||
))
|
||||
}
|
||||
};
|
||||
CollectionItem {
|
||||
mid: mid.to_string(),
|
||||
sid: sid.to_string(),
|
||||
collection_type,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"invalid collection key, should be series:mid:sid or season:mid:sid",
|
||||
))
|
||||
}
|
||||
};
|
||||
collection_list.insert(collection_item, value);
|
||||
}
|
||||
Ok(collection_list)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(CollectionListVisitor)
|
||||
}
|
||||
@@ -5,33 +5,20 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||
mod clap;
|
||||
mod global;
|
||||
mod item;
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||
let config = Config::load().unwrap_or_else(|err| {
|
||||
if err
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.map_or(true, |e| e.kind() != std::io::ErrorKind::NotFound)
|
||||
{
|
||||
panic!("加载配置文件失败,错误为: {err}");
|
||||
}
|
||||
warn!("配置文件不存在,使用默认配置...");
|
||||
Config::new()
|
||||
});
|
||||
// 放到外面,确保新的配置项被保存
|
||||
info!("配置加载完毕,覆盖刷新原有配置");
|
||||
config.save().unwrap();
|
||||
// 检查配置文件内容
|
||||
info!("校验配置文件内容...");
|
||||
config.check();
|
||||
config
|
||||
});
|
||||
use crate::bilibili::{CollectionItem, Credential, DanmakuOption, FilterOption};
|
||||
pub use crate::config::global::{ARGS, CONFIG, CONFIG_DIR, TEMPLATE};
|
||||
use crate::config::item::{deserialize_collection_list, serialize_collection_list, ConcurrentLimit};
|
||||
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit, WatchLaterConfig};
|
||||
|
||||
pub static CONFIG_DIR: Lazy<PathBuf> =
|
||||
Lazy::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
||||
fn default_time_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
@@ -40,38 +27,76 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub danmaku_option: DanmakuOption,
|
||||
pub favorite_list: HashMap<String, PathBuf>,
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "serialize_collection_list",
|
||||
deserialize_with = "deserialize_collection_list"
|
||||
)]
|
||||
pub collection_list: HashMap<CollectionItem, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub submission_list: HashMap<String, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub watch_later: WatchLaterConfig,
|
||||
pub video_name: Cow<'static, str>,
|
||||
pub page_name: Cow<'static, str>,
|
||||
pub interval: u64,
|
||||
pub upper_path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub nfo_time_type: NFOTimeType,
|
||||
#[serde(default)]
|
||||
pub concurrent_limit: ConcurrentLimit,
|
||||
#[serde(default = "default_time_format")]
|
||||
pub time_format: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
credential: ArcSwapOption::from(Some(Arc::new(Credential::default()))),
|
||||
filter_option: FilterOption::default(),
|
||||
danmaku_option: DanmakuOption::default(),
|
||||
favorite_list: HashMap::new(),
|
||||
collection_list: HashMap::new(),
|
||||
submission_list: HashMap::new(),
|
||||
watch_later: Default::default(),
|
||||
video_name: Cow::Borrowed("{{title}}"),
|
||||
page_name: Cow::Borrowed("{{bvid}}"),
|
||||
interval: 1200,
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
nfo_time_type: NFOTimeType::FavTime,
|
||||
concurrent_limit: ConcurrentLimit::default(),
|
||||
time_format: default_time_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的预检查
|
||||
impl Config {
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
std::fs::create_dir_all(&*CONFIG_DIR)?;
|
||||
std::fs::write(config_path, toml::to_string_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn load() -> Result<Self> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
Ok(toml::from_str(&config_content)?)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn check(&self) {
|
||||
let mut ok = true;
|
||||
if self.favorite_list.is_empty() {
|
||||
if self.favorite_list.is_empty() && self.collection_list.is_empty() && !self.watch_later.enabled {
|
||||
ok = false;
|
||||
error!("未设置需监听的收藏夹,程序空转没有意义");
|
||||
error!("没有配置任何需要扫描的内容,程序空转没有意义");
|
||||
}
|
||||
if self.watch_later.enabled && !self.watch_later.path.is_absolute() {
|
||||
error!(
|
||||
"稍后再看保存的路径应为绝对路径,检测到:{}",
|
||||
self.watch_later.path.display()
|
||||
);
|
||||
}
|
||||
for path in self.favorite_list.values() {
|
||||
if !path.is_absolute() {
|
||||
@@ -109,7 +134,10 @@ impl Config {
|
||||
error!("未设置 Credential 信息");
|
||||
}
|
||||
}
|
||||
|
||||
if !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
|
||||
ok = false;
|
||||
error!("允许的并发数必须大于 0");
|
||||
}
|
||||
if !ok {
|
||||
panic!(
|
||||
"位于 {} 的配置文件不合法,请参考提示信息修复后继续运行",
|
||||
@@ -117,17 +145,4 @@ impl Config {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load() -> Result<Self> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
Ok(toml::from_str(&config_content)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
std::fs::create_dir_all(&*CONFIG_DIR)?;
|
||||
std::fs::write(config_path, toml::to_string_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
25
crates/bili_sync/src/database.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
|
||||
fn database_url() -> String {
|
||||
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
|
||||
}
|
||||
|
||||
pub async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url());
|
||||
option
|
||||
.max_connections(100)
|
||||
.min_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(90));
|
||||
Ok(Database::connect(option).await?)
|
||||
}
|
||||
|
||||
pub async fn migrate_database() -> Result<()> {
|
||||
// 注意此处使用内部构造的 DatabaseConnection,而不是通过 database_connection() 获取
|
||||
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
|
||||
let connection = Database::connect(database_url()).await?;
|
||||
Ok(Migrator::up(&connection, None).await?)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ impl Downloader {
|
||||
audio_path.to_str().unwrap(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
output_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
@@ -50,8 +51,6 @@ impl Downloader {
|
||||
_ => Err(anyhow!("ffmpeg error")),
|
||||
};
|
||||
}
|
||||
let _ = fs::remove_file(video_path).await;
|
||||
let _ = fs::remove_file(audio_path).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
85
crates/bili_sync/src/main.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
#[macro_use]
|
||||
extern crate tracing;
|
||||
|
||||
mod adapter;
|
||||
mod bilibili;
|
||||
mod config;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
mod utils;
|
||||
mod workflow;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::time;
|
||||
|
||||
use crate::adapter::Args;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::{ARGS, CONFIG};
|
||||
use crate::database::{database_connection, migrate_database};
|
||||
use crate::utils::init_logger;
|
||||
use crate::workflow::process_video_list;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_logger(&ARGS.log_level);
|
||||
Lazy::force(&CONFIG);
|
||||
migrate_database().await.expect("数据库迁移失败");
|
||||
let connection = database_connection().await.expect("获取数据库连接失败");
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
let bili_client = BiliClient::new();
|
||||
let watch_later_config = &CONFIG.watch_later;
|
||||
loop {
|
||||
'inner: {
|
||||
match bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) {
|
||||
Ok(Some(mixin_key)) => bilibili::set_global_mixin_key(mixin_key),
|
||||
Ok(_) => {
|
||||
error!("获取 mixin key 失败,无法进行 wbi 签名,等待下一轮执行");
|
||||
break 'inner;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("获取 mixin key 时遇到错误:{e},等待下一轮执行");
|
||||
break 'inner;
|
||||
}
|
||||
};
|
||||
if anchor != chrono::Local::now().date_naive() {
|
||||
if let Err(e) = bili_client.check_refresh().await {
|
||||
error!("检查刷新 Credential 遇到错误:{e},等待下一轮执行");
|
||||
break 'inner;
|
||||
}
|
||||
anchor = chrono::Local::now().date_naive();
|
||||
}
|
||||
for (fid, path) in &CONFIG.favorite_list {
|
||||
if let Err(e) = process_video_list(Args::Favorite { fid }, &bili_client, path, &connection).await {
|
||||
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有收藏夹处理完毕");
|
||||
for (collection_item, path) in &CONFIG.collection_list {
|
||||
if let Err(e) =
|
||||
process_video_list(Args::Collection { collection_item }, &bili_client, path, &connection).await
|
||||
{
|
||||
error!("处理合集 {collection_item:?} 时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有合集处理完毕");
|
||||
if watch_later_config.enabled {
|
||||
if let Err(e) =
|
||||
process_video_list(Args::WatchLater, &bili_client, &watch_later_config.path, &connection).await
|
||||
{
|
||||
error!("处理稍后再看时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("稍后再看处理完毕");
|
||||
for (upper_id, path) in &CONFIG.submission_list {
|
||||
if let Err(e) = process_video_list(Args::Submission { upper_id }, &bili_client, path, &connection).await
|
||||
{
|
||||
error!("处理 UP 主 {upper_id} 投稿时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有 UP 主投稿处理完毕");
|
||||
info!("本轮任务执行完毕,等待下一轮执行");
|
||||
}
|
||||
time::sleep(time::Duration::from_secs(CONFIG.interval)).await;
|
||||
}
|
||||
}
|
||||
215
crates/bili_sync/src/utils/convert.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use sea_orm::ActiveValue::{NotSet, Set};
|
||||
use sea_orm::IntoActiveModel;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::bilibili::VideoInfo;
|
||||
use crate::config::CONFIG;
|
||||
use crate::utils::id_time_key;
|
||||
|
||||
impl VideoInfo {
|
||||
/// 将 VideoInfo 转换为 ActiveModel
|
||||
pub fn to_model(&self, base_model: Option<bili_sync_entity::video::Model>) -> bili_sync_entity::video::ActiveModel {
|
||||
let base_model = match base_model {
|
||||
Some(base_model) => base_model.into_active_model(),
|
||||
None => {
|
||||
let mut tmp_model = bili_sync_entity::video::Model::default().into_active_model();
|
||||
// 注意此处要把 id 和 created_at 设置为 NotSet,方便在 sql 中忽略这些字段,交由数据库自动生成
|
||||
tmp_model.id = NotSet;
|
||||
tmp_model.created_at = NotSet;
|
||||
tmp_model
|
||||
}
|
||||
};
|
||||
match self {
|
||||
VideoInfo::Simple {
|
||||
bvid,
|
||||
cover,
|
||||
ctime,
|
||||
pubtime,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||
valid: Set(true),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::Detail {
|
||||
title,
|
||||
vtype,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
ctime,
|
||||
fav_time,
|
||||
pubtime,
|
||||
attr,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
category: Set(*vtype),
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(*attr == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name.clone()),
|
||||
upper_face: Set(upper.face.clone()),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::View {
|
||||
title,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
ctime,
|
||||
pubtime,
|
||||
state,
|
||||
..
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(pubtime.naive_utc()), // 合集不包括 fav_time,使用发布时间代替
|
||||
download_status: Set(0),
|
||||
valid: Set(*state == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name.clone()),
|
||||
upper_face: Set(upper.face.clone()),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::WatchLater {
|
||||
title,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
ctime,
|
||||
fav_time,
|
||||
pubtime,
|
||||
state,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
category: Set(2), // 稍后再看里的内容类型肯定是视频
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(*state == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name.clone()),
|
||||
upper_face: Set(upper.face.clone()),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::Submission {
|
||||
title,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
ctime,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
category: Set(2), // 投稿视频的内容类型肯定是视频
|
||||
valid: Set(true),
|
||||
..base_model
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_fmt_args(&self) -> Option<serde_json::Value> {
|
||||
match self {
|
||||
VideoInfo::Simple { .. } | VideoInfo::Submission { .. } => None, // 不能从简单视频信息中构造格式化参数
|
||||
VideoInfo::Detail {
|
||||
title,
|
||||
bvid,
|
||||
upper,
|
||||
pubtime,
|
||||
fav_time,
|
||||
..
|
||||
}
|
||||
| VideoInfo::WatchLater {
|
||||
title,
|
||||
bvid,
|
||||
upper,
|
||||
pubtime,
|
||||
fav_time,
|
||||
..
|
||||
} => Some(json!({
|
||||
"bvid": &bvid,
|
||||
"title": &title,
|
||||
"upper_name": &upper.name,
|
||||
"upper_mid": &upper.mid,
|
||||
"pubtime": pubtime.format(&CONFIG.time_format).to_string(),
|
||||
"fav_time": fav_time.format(&CONFIG.time_format).to_string(),
|
||||
})),
|
||||
VideoInfo::View {
|
||||
title,
|
||||
bvid,
|
||||
upper,
|
||||
pubtime,
|
||||
..
|
||||
} => {
|
||||
let pubtime = pubtime.format(&CONFIG.time_format).to_string();
|
||||
Some(json!({
|
||||
"bvid": &bvid,
|
||||
"title": &title,
|
||||
"upper_name": &upper.name,
|
||||
"upper_mid": &upper.mid,
|
||||
"pubtime": &pubtime,
|
||||
"fav_time": &pubtime,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn video_key(&self) -> String {
|
||||
match self {
|
||||
// 对于合集没有 fav_time,只能用 pubtime 代替
|
||||
VideoInfo::Simple {
|
||||
bvid, pubtime: time, ..
|
||||
}
|
||||
| VideoInfo::Detail {
|
||||
bvid, fav_time: time, ..
|
||||
}
|
||||
| VideoInfo::WatchLater {
|
||||
bvid, fav_time: time, ..
|
||||
}
|
||||
| VideoInfo::Submission { bvid, ctime: time, .. } => id_time_key(bvid, time),
|
||||
// 详情接口返回的数据仅用于填充详情,不会被作为 video_key
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bvid(&self) -> &str {
|
||||
match self {
|
||||
VideoInfo::Simple { bvid, .. }
|
||||
| VideoInfo::Detail { bvid, .. }
|
||||
| VideoInfo::WatchLater { bvid, .. }
|
||||
| VideoInfo::Submission { bvid, .. } => bvid,
|
||||
// 同上
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
61
crates/bili_sync/src/utils/filenamify.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
macro_rules! regex {
|
||||
($re:literal $(,)?) => {{
|
||||
static RE: once_cell::sync::OnceCell<regex::Regex> = once_cell::sync::OnceCell::new();
|
||||
RE.get_or_init(|| regex::Regex::new($re).unwrap())
|
||||
}};
|
||||
}
|
||||
|
||||
pub fn filenamify<S: AsRef<str>>(input: S) -> String {
|
||||
let reserved = regex!("[<>:\"/\\\\|?*\u{0000}-\u{001F}\u{007F}\u{0080}-\u{009F}]+");
|
||||
let windows_reserved = regex!("^(con|prn|aux|nul|com\\d|lpt\\d)$");
|
||||
let outer_periods = regex!("^\\.+|\\.+$");
|
||||
|
||||
let replacement = "_";
|
||||
|
||||
let input = reserved.replace_all(input.as_ref(), replacement);
|
||||
let input = outer_periods.replace_all(input.as_ref(), replacement);
|
||||
|
||||
let mut result = input.into_owned();
|
||||
if windows_reserved.is_match(result.as_str()) {
|
||||
result.push_str(replacement);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::filenamify;
|
||||
|
||||
#[test]
|
||||
fn test_filenamify() {
|
||||
assert_eq!(filenamify("foo/bar"), "foo_bar");
|
||||
assert_eq!(filenamify("foo//bar"), "foo_bar");
|
||||
assert_eq!(filenamify("//foo//bar//"), "_foo_bar_");
|
||||
assert_eq!(filenamify("foo\\bar"), "foo_bar");
|
||||
assert_eq!(filenamify("foo\\\\\\bar"), "foo_bar");
|
||||
assert_eq!(filenamify(r"foo\\bar"), "foo_bar");
|
||||
assert_eq!(filenamify(r"foo\\\\\\bar"), "foo_bar");
|
||||
assert_eq!(filenamify("////foo////bar////"), "_foo_bar_");
|
||||
assert_eq!(filenamify("foo\u{0000}bar"), "foo_bar");
|
||||
assert_eq!(filenamify("\"foo<>bar*"), "_foo_bar_");
|
||||
assert_eq!(filenamify("."), "_");
|
||||
assert_eq!(filenamify(".."), "_");
|
||||
assert_eq!(filenamify("./"), "__");
|
||||
assert_eq!(filenamify("../"), "__");
|
||||
assert_eq!(filenamify("../../foo/bar"), "__.._foo_bar");
|
||||
assert_eq!(filenamify("foo.bar."), "foo.bar_");
|
||||
assert_eq!(filenamify("foo.bar.."), "foo.bar_");
|
||||
assert_eq!(filenamify("foo.bar..."), "foo.bar_");
|
||||
assert_eq!(filenamify("con"), "con_");
|
||||
assert_eq!(filenamify("com1"), "com1_");
|
||||
assert_eq!(filenamify(":nul|"), "_nul_");
|
||||
assert_eq!(filenamify("foo/bar/nul"), "foo_bar_nul");
|
||||
assert_eq!(filenamify("file:///file.tar.gz"), "file_file.tar.gz");
|
||||
assert_eq!(filenamify("http://www.google.com"), "http_www.google.com");
|
||||
assert_eq!(
|
||||
filenamify("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
"https_www.youtube.com_watch_v=dQw4w9WgXcQ"
|
||||
);
|
||||
}
|
||||
}
|
||||
24
crates/bili_sync/src/utils/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
pub mod convert;
|
||||
pub mod filenamify;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod status;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
pub fn init_logger(log_level: &str) {
|
||||
tracing_subscriber::fmt::Subscriber::builder()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
|
||||
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
||||
"%Y-%m-%d %H:%M:%S%.3f".to_owned(),
|
||||
))
|
||||
.finish()
|
||||
.try_init()
|
||||
.expect("初始化日志失败");
|
||||
}
|
||||
|
||||
/// 生成视频的唯一标记,均由 bvid 和时间戳构成
|
||||
pub fn id_time_key(bvid: &String, time: &DateTime<Utc>) -> String {
|
||||
format!("{}-{}", bvid, time.timestamp())
|
||||
}
|
||||
50
crates/bili_sync/src/utils/model.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::OnConflict;
|
||||
|
||||
use crate::adapter::VideoListModel;
|
||||
use crate::bilibili::VideoInfo;
|
||||
|
||||
/// 尝试创建 Video Model,如果发生冲突则忽略
|
||||
pub async fn create_videos(
|
||||
videos_info: &[VideoInfo],
|
||||
video_list_model: &dyn VideoListModel,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let video_models = videos_info
|
||||
.iter()
|
||||
.map(|v| video_list_model.video_model_by_info(v, None))
|
||||
.collect::<Vec<_>>();
|
||||
video::Entity::insert_many(video_models)
|
||||
// 这里想表达的是 on 索引名,但 sea-orm 的 api 似乎只支持列名而不支持索引名,好在留空可以达到相同的目的
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频 model 的下载状态
|
||||
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
video::Entity::insert_many(videos)
|
||||
.on_conflict(
|
||||
OnConflict::column(video::Column::Id)
|
||||
.update_column(video::Column::DownloadStatus)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频页 model 的下载状态
|
||||
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
let query = page::Entity::insert_many(pages).on_conflict(
|
||||
OnConflict::column(page::Column::Id)
|
||||
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
||||
.to_owned(),
|
||||
);
|
||||
query.exec(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,41 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use entity::*;
|
||||
use filenamify::filenamify;
|
||||
use handlebars::handlebars_helper;
|
||||
use migration::OnConflict;
|
||||
use once_cell::sync::Lazy;
|
||||
use bili_sync_entity::*;
|
||||
use quick_xml::events::{BytesCData, BytesText};
|
||||
use quick_xml::writer::Writer;
|
||||
use quick_xml::Error;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::QuerySelect;
|
||||
use serde_json::json;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::bilibili::{FavoriteListInfo, PageInfo, VideoInfo};
|
||||
use crate::config::CONFIG;
|
||||
use crate::core::status::Status;
|
||||
|
||||
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||
if s.chars().count() > len {
|
||||
s.chars().take(len).collect::<String>()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars
|
||||
.register_template_string("video", &CONFIG.video_name)
|
||||
.unwrap();
|
||||
handlebars.register_template_string("page", &CONFIG.page_name).unwrap();
|
||||
handlebars
|
||||
});
|
||||
use crate::config::NFOTimeType;
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum NFOMode {
|
||||
@@ -52,229 +22,10 @@ pub enum ModelWrapper<'a> {
|
||||
|
||||
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
|
||||
|
||||
/// 根据获得的收藏夹信息,插入或更新数据库中的收藏夹,并返回收藏夹对象
|
||||
pub async fn handle_favorite_info(
|
||||
info: &FavoriteListInfo,
|
||||
path: &Path,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
favorite::Entity::insert(favorite::ActiveModel {
|
||||
f_id: Set(info.id),
|
||||
name: Set(info.title.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(favorite::Column::FId)
|
||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(favorite::Entity::find()
|
||||
.filter(favorite::Column::FId.eq(info.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// 获取数据库中存在的与该视频 favorite_id 和 bvid 重合的视频中的 bvid 和 favtime
|
||||
/// 如果 bvid 和 favtime 均相同,说明到达了上次处理到的位置
|
||||
pub async fn exist_labels(
|
||||
videos_info: &[VideoInfo],
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<(String, DateTime)>> {
|
||||
let bvids = videos_info.iter().map(|v| v.bvid.clone()).collect::<Vec<String>>();
|
||||
let exist_labels = video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Bvid.is_in(bvids)),
|
||||
)
|
||||
.select_only()
|
||||
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||
.into_tuple()
|
||||
.all(connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<HashSet<(String, DateTime)>>();
|
||||
Ok(exist_labels)
|
||||
}
|
||||
|
||||
/// 尝试创建 Video Model,如果发生冲突则忽略
|
||||
pub async fn create_videos(
|
||||
videos_info: &[VideoInfo],
|
||||
favorite: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let video_models = videos_info
|
||||
.iter()
|
||||
.map(move |v| video::ActiveModel {
|
||||
favorite_id: Set(favorite.id),
|
||||
bvid: Set(v.bvid.clone()),
|
||||
name: Set(v.title.clone()),
|
||||
path: Set(Path::new(&favorite.path)
|
||||
.join(filenamify(
|
||||
TEMPLATE
|
||||
.render(
|
||||
"video",
|
||||
&json!({
|
||||
"bvid": &v.bvid,
|
||||
"title": &v.title,
|
||||
"upper_name": &v.upper.name,
|
||||
"upper_mid": &v.upper.mid,
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|_| v.bvid.clone()),
|
||||
))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned()),
|
||||
category: Set(v.vtype),
|
||||
intro: Set(v.intro.clone()),
|
||||
cover: Set(v.cover.clone()),
|
||||
ctime: Set(v.ctime.naive_utc()),
|
||||
pubtime: Set(v.pubtime.naive_utc()),
|
||||
favtime: Set(v.fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(v.attr == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(v.upper.mid),
|
||||
upper_name: Set(v.upper.name.clone()),
|
||||
upper_face: Set(v.upper.face.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<video::ActiveModel>>();
|
||||
video::Entity::insert_many(video_models)
|
||||
.on_conflict(
|
||||
OnConflict::columns([video::Column::FavoriteId, video::Column::Bvid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn total_video_count(favorite_model: &favorite::Model, connection: &DatabaseConnection) -> Result<u64> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(video::Column::FavoriteId.eq(favorite_model.id))
|
||||
.count(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 筛选所有未
|
||||
pub async fn filter_unfilled_videos(
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<video::Model>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null()),
|
||||
)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 创建视频的所有分 P
|
||||
pub async fn create_video_pages(
|
||||
pages_info: &[PageInfo],
|
||||
video_model: &video::Model,
|
||||
connection: &impl ConnectionTrait,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.iter()
|
||||
.map(move |p| {
|
||||
let (width, height) = match &p.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
(Some(d.width), Some(d.height))
|
||||
} else {
|
||||
(Some(d.height), Some(d.width))
|
||||
}
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
cid: Set(p.cid),
|
||||
pid: Set(p.page),
|
||||
name: Set(p.name.clone()),
|
||||
width: Set(width),
|
||||
height: Set(height),
|
||||
duration: Set(p.duration),
|
||||
image: Set(p.first_frame.clone()),
|
||||
download_status: Set(0),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
page::Entity::insert_many(page_models)
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有未处理的视频和页
|
||||
pub async fn unhandled_videos_pages(
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null()),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
/// 更新视频 model 的下载状态
|
||||
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
video::Entity::insert_many(videos)
|
||||
.on_conflict(
|
||||
OnConflict::column(video::Column::Id)
|
||||
.update_column(video::Column::DownloadStatus)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频页 model 的下载状态
|
||||
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
let query = page::Entity::insert_many(pages).on_conflict(
|
||||
OnConflict::column(page::Column::Id)
|
||||
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
||||
.to_owned(),
|
||||
);
|
||||
query.exec(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// serde xml 似乎不太好用,先这么裸着写
|
||||
/// (真是又臭又长啊
|
||||
impl<'a> NFOSerializer<'a> {
|
||||
pub async fn generate_nfo(self) -> Result<String> {
|
||||
impl NFOSerializer<'_> {
|
||||
pub async fn generate_nfo(self, nfo_time_type: &NFOTimeType) -> Result<String> {
|
||||
let mut buffer = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
"#
|
||||
.as_bytes()
|
||||
@@ -283,6 +34,10 @@ impl<'a> NFOSerializer<'a> {
|
||||
let mut writer = Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
|
||||
match self {
|
||||
NFOSerializer(ModelWrapper::Video(v), NFOMode::MOVIE) => {
|
||||
let nfo_time = match nfo_time_type {
|
||||
NFOTimeType::FavTime => v.favtime,
|
||||
NFOTimeType::PubTime => v.pubtime,
|
||||
};
|
||||
writer
|
||||
.create_element("movie")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
@@ -316,7 +71,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(tags) = &v.tags {
|
||||
@@ -337,7 +92,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y-%m-%d").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(writer)
|
||||
@@ -346,6 +101,10 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
}
|
||||
NFOSerializer(ModelWrapper::Video(v), NFOMode::TVSHOW) => {
|
||||
let nfo_time = match nfo_time_type {
|
||||
NFOTimeType::FavTime => v.favtime,
|
||||
NFOTimeType::PubTime => v.pubtime,
|
||||
};
|
||||
writer
|
||||
.create_element("tvshow")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
@@ -379,7 +138,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(tags) = &v.tags {
|
||||
@@ -400,7 +159,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y-%m-%d").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(writer)
|
||||
@@ -490,8 +249,8 @@ mod tests {
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
),
|
||||
pubtime: chrono::NaiveDateTime::new(
|
||||
chrono::NaiveDate::from_ymd_opt(2022, 2, 2).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
chrono::NaiveDate::from_ymd_opt(2033, 3, 3).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||
),
|
||||
bvid: "bvid".to_string(),
|
||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||
@@ -499,7 +258,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::MOVIE)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::PubTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -511,16 +270,16 @@ mod tests {
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
</actor>
|
||||
<year>2022</year>
|
||||
<year>2033</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">bvid</uniqueid>
|
||||
<aired>2022-02-02</aired>
|
||||
<aired>2033-03-03</aired>
|
||||
</movie>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::TVSHOW)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -541,7 +300,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::UPPER)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -549,7 +308,7 @@ mod tests {
|
||||
<plot/>
|
||||
<outline/>
|
||||
<lockdata>false</lockdata>
|
||||
<dateadded>2022-02-02 02:02:02</dateadded>
|
||||
<dateadded>2033-03-03 03:03:03</dateadded>
|
||||
<title>1</title>
|
||||
<sorttitle>1</sorttitle>
|
||||
</person>"#,
|
||||
@@ -561,7 +320,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Page(&page), NFOMode::EPOSODE)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -13,7 +13,7 @@ pub struct Status(u32);
|
||||
impl Status {
|
||||
/// 如果 status 整体大于等于 1 << 31,则表示任务已经被处理过,不再需要重试。
|
||||
/// 数据库可以使用 status < Status::handled() 来筛选需要处理的内容。
|
||||
pub fn handled() -> u32 {
|
||||
pub const fn handled() -> u32 {
|
||||
1 << 31
|
||||
}
|
||||
|
||||
@@ -32,19 +32,16 @@ impl Status {
|
||||
|
||||
/// 从低到高检查状态,如果该位置的任务应该继续尝试执行,则返回 true,否则返回 false
|
||||
fn should_run(&self, size: usize) -> Vec<bool> {
|
||||
assert!(size < 10, "u32 can only store 10 status");
|
||||
(0..size).map(|x| self.check_continue(x)).collect()
|
||||
}
|
||||
|
||||
/// 如果任务的执行次数小于 STATUS_MAX_RETRY,说明可以继续运行
|
||||
fn check_continue(&self, offset: usize) -> bool {
|
||||
assert!(offset < 10, "u32 can only store 10 status");
|
||||
self.get_status(offset) < STATUS_MAX_RETRY
|
||||
}
|
||||
|
||||
/// 根据任务结果更新状态,如果任务成功,设置为 STATUS_OK,否则加一
|
||||
fn update_status(&mut self, result: &[Result<()>]) {
|
||||
assert!(result.len() < 10, "u32 can only store 10 status");
|
||||
for (i, res) in result.iter().enumerate() {
|
||||
self.set_result(res, i);
|
||||
}
|
||||
@@ -65,17 +62,6 @@ impl Status {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 mask 设置状态,如果 mask 为 false,则清除对应的状态
|
||||
fn set_mask(&mut self, mask: &[bool]) {
|
||||
assert!(mask.len() < 10, "u32 can only store 10 status");
|
||||
for (i, &m) in mask.iter().enumerate() {
|
||||
if !m {
|
||||
self.clear(i);
|
||||
self.set_flag(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plus_one(&mut self, offset: usize) {
|
||||
self.0 += 1 << (3 * offset);
|
||||
}
|
||||
@@ -84,10 +70,6 @@ impl Status {
|
||||
self.0 |= STATUS_OK << (3 * offset);
|
||||
}
|
||||
|
||||
fn clear(&mut self, offset: usize) {
|
||||
self.0 &= !(STATUS_OK << (3 * offset));
|
||||
}
|
||||
|
||||
fn get_status(&self, offset: usize) -> u32 {
|
||||
let helper = !0u32;
|
||||
(self.0 & (helper << (offset * 3)) & (helper >> (32 - 3 * offset - 3))) >> (offset * 3)
|
||||
@@ -109,11 +91,6 @@ impl VideoStatus {
|
||||
Self(Status::new(status))
|
||||
}
|
||||
|
||||
pub fn set_mask(&mut self, clear: &[bool]) {
|
||||
assert!(clear.len() == 5, "VideoStatus should have 5 status");
|
||||
self.0.set_mask(clear)
|
||||
}
|
||||
|
||||
pub fn should_run(&self) -> Vec<bool> {
|
||||
self.0.should_run(5)
|
||||
}
|
||||
@@ -139,11 +116,6 @@ impl PageStatus {
|
||||
Self(Status::new(status))
|
||||
}
|
||||
|
||||
pub fn set_mask(&mut self, clear: &[bool]) {
|
||||
assert!(clear.len() == 4, "PageStatus should have 4 status");
|
||||
self.0.set_mask(clear)
|
||||
}
|
||||
|
||||
pub fn should_run(&self) -> Vec<bool> {
|
||||
self.0.should_run(4)
|
||||
}
|
||||
@@ -1,164 +1,96 @@
|
||||
use std::collections::HashMap;
|
||||
use std::env::{args, var};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use entity::{favorite, page, video};
|
||||
use filenamify::filenamify;
|
||||
use bili_sync_entity::*;
|
||||
use futures::stream::{FuturesOrdered, FuturesUnordered};
|
||||
use futures::{pin_mut, Future, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use futures::{Future, Stream, StreamExt};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::TransactionTrait;
|
||||
use serde_json::json;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, FavoriteList, PageInfo, Video};
|
||||
use crate::config::CONFIG;
|
||||
use crate::core::status::{PageStatus, VideoStatus};
|
||||
use crate::core::utils::{
|
||||
create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count,
|
||||
unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE,
|
||||
};
|
||||
use crate::adapter::{video_list_from, Args, VideoListModel};
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Video, VideoInfo};
|
||||
use crate::config::{PathSafeTemplate, ARGS, CONFIG, TEMPLATE};
|
||||
use crate::downloader::Downloader;
|
||||
use crate::error::{DownloadAbortError, ProcessPageError};
|
||||
use crate::utils::model::{create_videos, update_pages_model, update_videos_model};
|
||||
use crate::utils::nfo::{ModelWrapper, NFOMode, NFOSerializer};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
pub static SCAN_ONLY: Lazy<bool> = Lazy::new(|| var("SCAN_ONLY").is_ok() || args().any(|arg| arg == "--scan-only"));
|
||||
|
||||
/// 处理某个收藏夹,首先刷新收藏夹信息,然后下载收藏夹中未下载成功的视频
|
||||
pub async fn process_favorite_list(
|
||||
pub async fn process_video_list(
|
||||
args: Args<'_>,
|
||||
bili_client: &BiliClient,
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let favorite_model = refresh_favorite_list(bili_client, fid, path, connection).await?;
|
||||
let favorite_model = fetch_video_details(bili_client, favorite_model, connection).await?;
|
||||
if *SCAN_ONLY {
|
||||
let (video_list_model, video_streams) = video_list_from(args, path, bili_client, connection).await?;
|
||||
let video_list_model = refresh_video_list(video_list_model, video_streams, connection).await?;
|
||||
let video_list_model = fetch_video_details(bili_client, video_list_model, connection).await?;
|
||||
if ARGS.scan_only {
|
||||
warn!("已开启仅扫描模式,跳过视频下载...");
|
||||
return Ok(());
|
||||
}
|
||||
download_unprocessed_videos(bili_client, favorite_model, connection).await
|
||||
download_unprocessed_videos(bili_client, video_list_model, connection).await
|
||||
}
|
||||
|
||||
/// 获取收藏夹 Model,从收藏夹列表中获取所有新添加的视频,将其写入数据库
|
||||
pub async fn refresh_favorite_list(
|
||||
bili_client: &BiliClient,
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
/// 请求接口,获取视频列表中所有新添加的视频信息,将其写入数据库
|
||||
pub async fn refresh_video_list<'a>(
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
video_streams: Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
let bili_favorite_list = FavoriteList::new(bili_client, fid.to_owned());
|
||||
let favorite_list_info = bili_favorite_list.get_info().await?;
|
||||
let favorite_model = handle_favorite_info(&favorite_list_info, path, connection).await?;
|
||||
info!("开始扫描收藏夹: {} - {}...", favorite_model.f_id, favorite_model.name);
|
||||
// 每十个视频一组,避免太多的数据库操作
|
||||
let video_stream = bili_favorite_list.into_video_stream().chunks(10);
|
||||
pin_mut!(video_stream);
|
||||
) -> Result<Box<dyn VideoListModel>> {
|
||||
video_list_model.log_refresh_video_start();
|
||||
let mut video_streams = video_streams.chunks(10);
|
||||
let mut got_count = 0;
|
||||
let total_count = total_video_count(&favorite_model, connection).await?;
|
||||
while let Some(videos_info) = video_stream.next().await {
|
||||
let mut new_count = video_list_model.video_count(connection).await?;
|
||||
while let Some(videos_info) = video_streams.next().await {
|
||||
got_count += videos_info.len();
|
||||
let exist_labels = exist_labels(&videos_info, &favorite_model, connection).await?;
|
||||
let exist_labels = video_list_model.exist_labels(&videos_info, connection).await?;
|
||||
// 如果发现有视频的收藏时间和 bvid 和数据库中重合,说明到达了上次处理到的地方,可以直接退出
|
||||
let should_break = videos_info
|
||||
.iter()
|
||||
.any(|v| exist_labels.contains(&(v.bvid.clone(), v.fav_time.naive_utc())));
|
||||
let should_break = videos_info.iter().any(|v| exist_labels.contains(&v.video_key()));
|
||||
// 将视频信息写入数据库
|
||||
create_videos(&videos_info, &favorite_model, connection).await?;
|
||||
create_videos(&videos_info, video_list_model.as_ref(), connection).await?;
|
||||
if should_break {
|
||||
info!("到达上一次处理的位置,提前中止");
|
||||
break;
|
||||
}
|
||||
}
|
||||
let total_count = total_video_count(&favorite_model, connection).await? - total_count;
|
||||
info!(
|
||||
"扫描收藏夹: {} - {} 完成, 获取了 {} 条视频, 其中有 {} 条新视频",
|
||||
favorite_model.f_id, favorite_model.name, got_count, total_count
|
||||
);
|
||||
Ok(favorite_model)
|
||||
new_count = video_list_model.video_count(connection).await? - new_count;
|
||||
video_list_model.log_refresh_video_end(got_count, new_count);
|
||||
Ok(video_list_model)
|
||||
}
|
||||
|
||||
/// 筛选出所有没有获取到分页信息和 tag 的视频,请求分页信息和 tag 并写入数据库
|
||||
/// 筛选出所有未获取到全部信息的视频,尝试补充其详细信息
|
||||
pub async fn fetch_video_details(
|
||||
bili_client: &BiliClient,
|
||||
favorite_model: favorite::Model,
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
info!(
|
||||
"开始获取收藏夹 {} - {} 的视频与分页信息...",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
let videos_model = filter_unfilled_videos(&favorite_model, connection).await?;
|
||||
) -> Result<Box<dyn VideoListModel>> {
|
||||
video_list_model.log_fetch_video_start();
|
||||
let videos_model = video_list_model.unfilled_videos(connection).await?;
|
||||
for video_model in videos_model {
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let tags = match bili_video.get_tags().await {
|
||||
Ok(tags) => tags,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的标签失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
||||
if *code == -404 {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pages_info = match bili_video.get_pages().await {
|
||||
Ok(pages) => pages,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的分页信息失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
||||
if *code == -404 {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
let video = Video::new(bili_client, video_model.bvid.clone());
|
||||
video_list_model
|
||||
.fetch_videos_detail(video, video_model, connection)
|
||||
.await?;
|
||||
}
|
||||
info!(
|
||||
"获取收藏夹 {} - {} 的视频与分页信息完成",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
Ok(favorite_model)
|
||||
video_list_model.log_fetch_video_end();
|
||||
Ok(video_list_model)
|
||||
}
|
||||
|
||||
/// 下载所有未处理成功的视频
|
||||
pub async fn download_unprocessed_videos(
|
||||
bili_client: &BiliClient,
|
||||
favorite_model: favorite::Model,
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
info!(
|
||||
"开始下载收藏夹: {} - {} 中所有未处理过的视频...",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
let unhandled_videos_pages = unhandled_videos_pages(&favorite_model, connection).await?;
|
||||
// 对于视频,允许五个同时下载(视频内还有分页、不同分页还有多种下载任务)
|
||||
let semaphore = Semaphore::new(5);
|
||||
video_list_model.log_download_video_start();
|
||||
let unhandled_videos_pages = video_list_model.unhandled_video_pages(connection).await?;
|
||||
let semaphore = Semaphore::new(CONFIG.concurrent_limit.video);
|
||||
let downloader = Downloader::new(bili_client.client.clone());
|
||||
let mut uppers_mutex: HashMap<i64, (Mutex<()>, Mutex<()>)> = HashMap::new();
|
||||
for (video_model, _) in &unhandled_videos_pages {
|
||||
@@ -201,10 +133,7 @@ pub async fn download_unprocessed_videos(
|
||||
if !models.is_empty() {
|
||||
update_videos_model(models, connection).await?;
|
||||
}
|
||||
info!(
|
||||
"下载收藏夹: {} - {} 中未处理过的视频完成",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
video_list_model.log_download_video_end();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -241,6 +170,7 @@ pub async fn download_video_pages(
|
||||
&video_model,
|
||||
downloader,
|
||||
base_path.join("poster.jpg"),
|
||||
base_path.join("fanart.jpg"),
|
||||
)),
|
||||
// 生成视频信息的 nfo
|
||||
Box::pin(generate_video_nfo(
|
||||
@@ -311,8 +241,7 @@ pub async fn dispatch_download_page(
|
||||
if !should_run {
|
||||
return Ok(());
|
||||
}
|
||||
// 对于视频的分页,允许同时下载三个同时下载(绝大部分是单页视频)
|
||||
let child_semaphore = Semaphore::new(5);
|
||||
let child_semaphore = Semaphore::new(CONFIG.concurrent_limit.page);
|
||||
let mut tasks = pages
|
||||
.into_iter()
|
||||
.map(|page_model| download_page(bili_client, video_model, page_model, &child_semaphore, downloader))
|
||||
@@ -379,7 +308,7 @@ pub async fn download_page(
|
||||
let seprate_status = status.should_run();
|
||||
let is_single_page = video_model.single_page.unwrap();
|
||||
let base_path = Path::new(&video_model.path);
|
||||
let base_name = filenamify(TEMPLATE.render(
|
||||
let base_name = TEMPLATE.path_safe_render(
|
||||
"page",
|
||||
&json!({
|
||||
"bvid": &video_model.bvid,
|
||||
@@ -388,14 +317,17 @@ pub async fn download_page(
|
||||
"upper_mid": &video_model.upper_id,
|
||||
"ptitle": &page_model.name,
|
||||
"pid": page_model.pid,
|
||||
"pubtime": video_model.pubtime.format(&CONFIG.time_format).to_string(),
|
||||
"fav_time": video_model.favtime.format(&CONFIG.time_format).to_string(),
|
||||
}),
|
||||
)?);
|
||||
let (poster_path, video_path, nfo_path, danmaku_path) = if is_single_page {
|
||||
)?;
|
||||
let (poster_path, video_path, nfo_path, danmaku_path, fanart_path) = if is_single_page {
|
||||
(
|
||||
base_path.join(format!("{}-poster.jpg", &base_name)),
|
||||
base_path.join(format!("{}.mp4", &base_name)),
|
||||
base_path.join(format!("{}.nfo", &base_name)),
|
||||
base_path.join(format!("{}.zh-CN.default.ass", &base_name)),
|
||||
Some(base_path.join(format!("{}-fanart.jpg", &base_name))),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -411,6 +343,8 @@ pub async fn download_page(
|
||||
base_path
|
||||
.join("Season 1")
|
||||
.join(format!("{} - S01E{:0>2}.zh-CN.default.ass", &base_name, page_model.pid)),
|
||||
// 对于多页视频,会在上一步 fetch_video_poster 中获取剧集的 fanart,无需在此处下载单集的
|
||||
None,
|
||||
)
|
||||
};
|
||||
let dimension = if page_model.width.is_some() && page_model.height.is_some() {
|
||||
@@ -435,6 +369,7 @@ pub async fn download_page(
|
||||
&page_model,
|
||||
downloader,
|
||||
poster_path,
|
||||
fanart_path,
|
||||
)),
|
||||
Box::pin(fetch_page_video(
|
||||
seprate_status[1],
|
||||
@@ -487,6 +422,7 @@ pub async fn fetch_page_poster(
|
||||
page_model: &page::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
if !should_run {
|
||||
return Ok(());
|
||||
@@ -502,7 +438,11 @@ pub async fn fetch_page_poster(
|
||||
None => video_model.cover.as_str(),
|
||||
}
|
||||
};
|
||||
downloader.fetch(url, &poster_path).await
|
||||
downloader.fetch(url, &poster_path).await?;
|
||||
if let Some(fanart_path) = fanart_path {
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_page_video(
|
||||
@@ -522,15 +462,11 @@ pub async fn fetch_page_video(
|
||||
.await?
|
||||
.best_stream(&CONFIG.filter_option)?;
|
||||
match streams {
|
||||
BestStream::Mixed(mix_stream) => {
|
||||
downloader.fetch(mix_stream.url(), &page_path).await?;
|
||||
}
|
||||
BestStream::Mixed(mix_stream) => downloader.fetch(mix_stream.url(), &page_path).await,
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: None,
|
||||
} => {
|
||||
downloader.fetch(video_stream.url(), &page_path).await?;
|
||||
}
|
||||
} => downloader.fetch(video_stream.url(), &page_path).await,
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: Some(audio_stream),
|
||||
@@ -539,12 +475,17 @@ pub async fn fetch_page_video(
|
||||
page_path.with_extension("tmp_video"),
|
||||
page_path.with_extension("tmp_audio"),
|
||||
);
|
||||
downloader.fetch(video_stream.url(), &tmp_video_path).await?;
|
||||
downloader.fetch(audio_stream.url(), &tmp_audio_path).await?;
|
||||
downloader.merge(&tmp_video_path, &tmp_audio_path, &page_path).await?;
|
||||
let res = async {
|
||||
downloader.fetch(video_stream.url(), &tmp_video_path).await?;
|
||||
downloader.fetch(audio_stream.url(), &tmp_audio_path).await?;
|
||||
downloader.merge(&tmp_video_path, &tmp_audio_path, &page_path).await
|
||||
}
|
||||
.await;
|
||||
let _ = fs::remove_file(tmp_video_path).await;
|
||||
let _ = fs::remove_file(tmp_audio_path).await;
|
||||
res
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_page_danmaku(
|
||||
@@ -562,8 +503,7 @@ pub async fn fetch_page_danmaku(
|
||||
.get_danmaku_writer(page_info)
|
||||
.await?
|
||||
.write(danmaku_path)
|
||||
.await?;
|
||||
Ok(())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn generate_page_nfo(
|
||||
@@ -589,11 +529,14 @@ pub async fn fetch_video_poster(
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: PathBuf,
|
||||
) -> Result<()> {
|
||||
if !should_run {
|
||||
return Ok(());
|
||||
}
|
||||
downloader.fetch(&video_model.cover, &poster_path).await
|
||||
downloader.fetch(&video_model.cover, &poster_path).await?;
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_upper_face(
|
||||
@@ -644,7 +587,11 @@ async fn generate_nfo(serializer: NFOSerializer<'_>, nfo_path: PathBuf) -> Resul
|
||||
if let Some(parent) = nfo_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(nfo_path, serializer.generate_nfo().await?.as_bytes()).await?;
|
||||
fs::write(
|
||||
nfo_path,
|
||||
serializer.generate_nfo(&CONFIG.nfo_time_type).await?.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -665,15 +612,49 @@ mod tests {
|
||||
}
|
||||
});
|
||||
template.register_helper("truncate", Box::new(truncate));
|
||||
let _ = template.register_template_string("video", "test{{bvid}}test");
|
||||
let _ = template.register_template_string("test_truncate", "哈哈,{{ truncate title 30 }}");
|
||||
let _ = template.path_safe_register("video", "test{{bvid}}test");
|
||||
let _ = template.path_safe_register("test_truncate", "哈哈,{{ truncate title 30 }}");
|
||||
let _ = template.path_safe_register("test_path_unix", "{{ truncate title 7 }}/test/a");
|
||||
let _ = template.path_safe_register("test_path_windows", r"{{ truncate title 7 }}\\test\\a");
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render("test_path_unix", &json!({"title": "关注/永雏塔菲喵"}))
|
||||
.unwrap(),
|
||||
"关注_永雏塔菲/test/a"
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render("test_path_windows", &json!({"title": "关注/永雏塔菲喵"}))
|
||||
.unwrap(),
|
||||
"关注_永雏塔菲_test_a"
|
||||
);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render("test_path_unix", &json!({"title": "关注/永雏塔菲喵"}))
|
||||
.unwrap(),
|
||||
"关注_永雏塔菲_test_a"
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render("test_path_windows", &json!({"title": "关注/永雏塔菲喵"}))
|
||||
.unwrap(),
|
||||
r"关注_永雏塔菲\\test\\a"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
template.render("video", &json!({"bvid": "BV1b5411h7g7"})).unwrap(),
|
||||
template
|
||||
.path_safe_render("video", &json!({"bvid": "BV1b5411h7g7"}))
|
||||
.unwrap(),
|
||||
"testBV1b5411h7g7test"
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.render(
|
||||
.path_safe_render(
|
||||
"test_truncate",
|
||||
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
|
||||
编译将发生在一个被称作「Cargo」的构建系统中。在这里,被引用的指针将被授予「生命周期」之力,导引对象安全。\
|
||||
9
crates/bili_sync_entity/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "bili_sync_entity"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
sea-orm = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
21
crates/bili_sync_entity/src/entities/collection.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "collection")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub s_id: i64,
|
||||
pub m_id: i64,
|
||||
pub name: String,
|
||||
pub r#type: i32,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod collection;
|
||||
pub mod favorite;
|
||||
pub mod page;
|
||||
pub mod submission;
|
||||
pub mod video;
|
||||
pub mod watch_later;
|
||||
@@ -8,7 +8,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub cid: i32,
|
||||
pub cid: i64,
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
pub width: Option<u32>,
|
||||
19
crates/bili_sync_entity/src/entities/submission.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "submission")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: String,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -7,7 +7,10 @@ use sea_orm::entity::prelude::*;
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub favorite_id: i32,
|
||||
pub collection_id: Option<i32>,
|
||||
pub favorite_id: Option<i32>,
|
||||
pub watch_later_id: Option<i32>,
|
||||
pub submission_id: Option<i32>,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: String,
|
||||
pub upper_face: String,
|
||||
17
crates/bili_sync_entity/src/entities/watch_later.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "watch_later")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
9
crates/bili_sync_migration/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "bili_sync_migration"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
async-std = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
20
crates/bili_sync_migration/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20240322_000001_create_table;
|
||||
mod m20240505_130850_add_collection;
|
||||
mod m20240709_130914_watch_later;
|
||||
mod m20240724_161008_submission;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20240322_000001_create_table::Migration),
|
||||
Box::new(m20240505_130850_add_collection::Migration),
|
||||
Box::new(m20240709_130914_watch_later::Migration),
|
||||
Box::new(m20240724_161008_submission::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Collection::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Collection::Id)
|
||||
.unsigned()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Collection::SId).unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::MId).unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::Name).string().not_null())
|
||||
.col(ColumnDef::new(Collection::Type).small_unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::Path).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Collection::CreatedAt)
|
||||
.timestamp()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.table(Collection::Table)
|
||||
.name("idx_collection_sid_mid_type")
|
||||
.col(Collection::SId)
|
||||
.col(Collection::MId)
|
||||
.col(Collection::Type)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_favorite_id_bvid")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::CollectionId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
// 在唯一索引中,NULL 不等于 NULL,所以需要使用 ifnull 函数排除空的情况
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_cid_fid_bvid` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_cid_fid_bvid")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("DELETE FROM video WHERE favorite_id IS NULL")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
// 向存在记录的表中添加非空列时,必须提供默认值
|
||||
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().not_null().default(0))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::CollectionId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_favorite_id_bvid")
|
||||
.col(Video::FavoriteId)
|
||||
.col(Video::Bvid)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Collection::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Collection {
|
||||
Table,
|
||||
Id,
|
||||
SId,
|
||||
MId,
|
||||
Name,
|
||||
Type,
|
||||
Path,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
FavoriteId,
|
||||
TempFavoriteId,
|
||||
CollectionId,
|
||||
Bvid,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(WatchLater::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(WatchLater::Id)
|
||||
.unsigned()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(WatchLater::Path).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(WatchLater::CreatedAt)
|
||||
.timestamp()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_cid_fid_bvid")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::WatchLaterId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_unique` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), ifnull(`watch_later_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.drop_index(Index::drop().table(Video::Table).name("idx_video_unique").to_owned())
|
||||
.await?;
|
||||
db.execute_unprepared("DELETE FROM video WHERE watch_later_id IS NOT NULL")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::WatchLaterId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_cid_fid_bvid` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(WatchLater::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum WatchLater {
|
||||
Table,
|
||||
Id,
|
||||
Path,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
WatchLaterId,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Submission::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Submission::Id)
|
||||
.unsigned()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Submission::UpperId).unique_key().unsigned().not_null())
|
||||
.col(ColumnDef::new(Submission::UpperName).string().not_null())
|
||||
.col(ColumnDef::new(Submission::Path).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Submission::CreatedAt)
|
||||
.timestamp()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(Index::drop().table(Video::Table).name("idx_video_unique").to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::SubmissionId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_unique` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), ifnull(`watch_later_id`, -1), ifnull(`submission_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.drop_index(Index::drop().table(Video::Table).name("idx_video_unique").to_owned())
|
||||
.await?;
|
||||
db.execute_unprepared("DELETE FROM video WHERE submission_id IS NOT NULL")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::SubmissionId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_unique` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), ifnull(`watch_later_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Submission::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
Id,
|
||||
UpperId,
|
||||
UpperName,
|
||||
Path,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
SubmissionId,
|
||||
}
|
||||
@@ -2,5 +2,5 @@ use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
cli::run_cli(bili_sync_migration::Migrator).await;
|
||||
}
|
||||
102
docs/.vitepress/config.mts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import taskLists from "markdown-it-task-lists";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "bili-sync",
|
||||
description: "由 Rust & Tokio 驱动的哔哩哔哩同步工具",
|
||||
lang: "zh-Hans",
|
||||
sitemap: {
|
||||
hostname: "https://bili-sync.github.io",
|
||||
},
|
||||
lastUpdated: true,
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
themeConfig: {
|
||||
outline: {
|
||||
label: "页面导航",
|
||||
level: "deep",
|
||||
},
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.2.0",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
link: "https://github.com/amtoaer/bili-sync/releases",
|
||||
},
|
||||
{
|
||||
text: "文档更新",
|
||||
link: "https://github.com/search?q=repo:amtoaer/bili-sync+docs&type=commits",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "简介",
|
||||
items: [
|
||||
{ text: "什么是 bili-sync?", link: "/introduction" },
|
||||
{ text: "快速开始", link: "/quick-start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "细节",
|
||||
items: [
|
||||
{ text: "配置文件", link: "/configuration" },
|
||||
{ text: "命令行参数", link: "/args" },
|
||||
{ text: "工作原理", link: "/design" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "参考",
|
||||
items: [
|
||||
{ text: "获取收藏夹信息", link: "/favorite" },
|
||||
{
|
||||
text: "获取视频合集/视频列表信息",
|
||||
link: "/collection",
|
||||
},
|
||||
{ text: "获取投稿信息", link: "/submission" },
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/amtoaer/bili-sync" },
|
||||
],
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
notFound: {
|
||||
title: "你来到了没有知识的荒原",
|
||||
quote: "这里什么都没有",
|
||||
linkText: "返回首页",
|
||||
},
|
||||
docFooter: {
|
||||
prev: "上一页",
|
||||
next: "下一页",
|
||||
},
|
||||
lastUpdated: {
|
||||
text: "上次更新于",
|
||||
},
|
||||
returnToTopLabel: "回到顶部",
|
||||
sidebarMenuLabel: "菜单",
|
||||
darkModeSwitchLabel: "主题",
|
||||
lightModeSwitchTitle: "切换到浅色模式",
|
||||
darkModeSwitchTitle: "切换到深色模式",
|
||||
},
|
||||
markdown: {
|
||||
config: (md) => {
|
||||
md.use(taskLists);
|
||||
},
|
||||
theme: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
},
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/svg+xml", href: "/icon.svg" }],
|
||||
["link", { rel: "icon", type: "image/png", href: "/icon.png" }],
|
||||
],
|
||||
});
|
||||
7
docs/.vitepress/theme/index.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.medium-zoom-overlay {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 31;
|
||||
}
|
||||
22
docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { onMounted, watch, nextTick } from "vue";
|
||||
import { useRoute } from "vitepress";
|
||||
import mediumZoom from "medium-zoom";
|
||||
import "./index.css";
|
||||
|
||||
export default {
|
||||
...DefaultTheme,
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const initZoom = () => {
|
||||
mediumZoom(".main img", { background: "var(--vp-c-bg)" });
|
||||
};
|
||||
onMounted(() => {
|
||||
initZoom();
|
||||
});
|
||||
watch(
|
||||
() => route.path,
|
||||
() => nextTick(() => initZoom()),
|
||||
);
|
||||
},
|
||||
};
|
||||
27
docs/args.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 命令行参数
|
||||
|
||||
程序支持有限的命令行参数,可以通过执行 `bili-sync-rs --help` 查看说明。
|
||||
|
||||
```shell
|
||||
bili-sync/target/debug main* ⇡
|
||||
❯ ./bili-sync-rs --help
|
||||
由 Rust & Tokio 驱动的哔哩哔哩同步工具
|
||||
|
||||
Usage: bili-sync-rs [OPTIONS]
|
||||
|
||||
Options:
|
||||
-s, --scan-only [env: SCAN_ONLY=]
|
||||
-l, --log-level <LOG_LEVEL> [env: RUST_LOG=] [default: None,bili_sync=info]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
可以看到除版本和帮助信息外,程序仅支持两个参数,参数除可以通过命令行设置外,还可通过环境变量设置。
|
||||
|
||||
## `--scan-only`
|
||||
|
||||
`--scan-only` 参数用于仅扫描列表,而不实际执行下载操作。该参数的主要目的是[方便用户从 v1 迁移](https://github.com/amtoaer/bili-sync/issues/66#issuecomment-2066642481),新用户不需要关注。
|
||||
|
||||
## `--log-level`
|
||||
|
||||
`--log-level` 参数用于设置日志级别,一般可以维持默认。该参数与 Rust 程序中 `RUST_LOG` 的语义相同,可以查看[相关文档](https://docs.rs/env_logger/latest/env_logger/#enabling-logging)获取详细信息。
|
||||
BIN
docs/assets/bili_collection.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/assets/bili_video.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/assets/collection.webp
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
docs/assets/detail.webp
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
docs/assets/dir.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/assets/favorite.webp
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
docs/assets/multi_page.webp
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/assets/multi_page_detail.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/assets/overview.webp
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
docs/assets/play.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/assets/season.webp
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
docs/assets/series.webp
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
docs/assets/single_page.webp
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/assets/submission.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
docs/bun.lockb
Executable file
32
docs/collection.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 获取视频合集/视频列表信息
|
||||
|
||||
要说明的是,视频合集和视频列表虽然在哔哩哔哩网站交互上行为类似,但在接口层级是两个不同的概念。可以简单将视频列表理解为一个老旧版本的视频合集。
|
||||
|
||||
在调试过程中我注意到视频列表的 ID 可以通过某种规则转换为视频合集的 ID,从而成功调用视频合集的接口,但由于不清楚具体的转换策略,在 bili-sync 的实现中还是将其当成两种类型处理。
|
||||
|
||||
## 区分方法
|
||||
|
||||
这两种类型可以很容易地通过如下手段区分:
|
||||
1. 两者的名称前缀不同,视频合集会有显式的“合集”字样
|
||||
2. 两者的图标不同
|
||||
|
||||
如下图所示,“合集【命运方舟全剧情解说】”是视频合集,而“阿拉德冒险记”是视频列表。
|
||||

|
||||
|
||||
在 bili-sync 的设计中,视频合集的 key 为 `season:{mid}:{season_id}`,而视频列表的 key 为 `series:{mid}:{series_id}`。
|
||||
|
||||
## 参数获取
|
||||
|
||||
了解了区分方法后,我们可以通过如下步骤获取视频合集/视频列表的信息。
|
||||
|
||||
### 视频合集
|
||||
|
||||

|
||||
|
||||
该视频合集的 key 为 `season:521722088:1987140`。
|
||||
|
||||
### 视频列表
|
||||
|
||||

|
||||
|
||||
该视频列表的 key 为 `series:521722088:387214`。
|
||||
238
docs/configuration.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 配置文件
|
||||
|
||||
默认的配置文件已经在[快速开始](/quick-start)中给出,该文档对配置文件的各个参数依次详细解释。
|
||||
|
||||
## video_name 与 page_name
|
||||
|
||||
`video_name` 与 `page_name` 用于设置下载文件的命名规则,对于所有下载的内容,将会维持如下的目录结构:
|
||||
|
||||
1. 单页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── {page_name}.mp4
|
||||
│ ├── {page_name}.nfo
|
||||
│ └── {page_name}-poster.jpg
|
||||
```
|
||||
|
||||
2. 多页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── poster.jpg
|
||||
│ ├── Season 1
|
||||
│ │ ├── {page_name} - S01E01.mp4
|
||||
│ │ ├── {page_name} - S01E01.nfo
|
||||
│ │ ├── {page_name} - S01E01-thumb.jpg
|
||||
│ │ ├── {page_name} - S01E02.mp4
|
||||
│ │ ├── {page_name} - S01E02.nfo
|
||||
│ │ └── {page_name} - S01E02-thumb.jpg
|
||||
│ └── tvshow.nfo
|
||||
```
|
||||
|
||||
这两个参数支持使用模板,其中用 <code v-pre>{{ }}</code> 包裹的模板变量在执行时会被动态替换为对应的内容。
|
||||
|
||||
对于 `video_name`,支持设置 bvid(视频编号)、title(视频标题)、upper_name(up 主名称)、upper_mid(up 主 id)、pubtime(视频发布时间)、fav_time(视频收藏时间)。
|
||||
|
||||
对于 `page_name`,除支持 video 的全部参数外,还支持 ptitle(分 P 标题)、pid(分 P 页号)。
|
||||
|
||||
为了解决文件名可能过长的问题,程序为模板引入了 `truncate` 函数。如 <code v-pre>{{ truncate title 10 }}</code> 表示截取 `title` 的前 10 个字符。
|
||||
|
||||
> [!TIP]
|
||||
> 1. 仅收藏夹视频会区分 `fav_time` 和 `pubtime`,其它类型下载两者的取值是完全相同的;
|
||||
> 2. `fav_time` 和 `pubtime` 的格式受 `time_format` 参数控制,详情可参考 [time_format 小节](#time-format)。
|
||||
|
||||
此外,`video_name` 和 `page_name` 还支持使用路径分割符,如 <code v-pre>{{ upper_mid }}/{{ title }}_{{ pubtime }}</code> 表示视频会根据 UP 主 id 将视频分到不同的文件夹中。
|
||||
|
||||
推荐仅在 `video_name` 中使用路径分割符,暂不清楚在 `page_name` 中使用路径分割符导致分页存储到子文件夹后是否还能被媒体服务器正确识别。
|
||||
|
||||
> [!CAUTION]
|
||||
> **路径分隔符**在不同平台定义不同,Windows 下为 `\`,MacOS 和 Linux 下为 `/`。
|
||||
|
||||
## `interval`
|
||||
|
||||
表示程序每次执行扫描下载的间隔时间,单位为秒。
|
||||
|
||||
## `upper_path`
|
||||
|
||||
UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务器的用户,需确保此处路径指向 Emby、Jellyfin 配置中的 `/metadata/people/` 才能够正常在媒体服务器中显示 UP 主的头像。
|
||||
|
||||
## `nfo_time_type`
|
||||
|
||||
表示在视频信息中使用的时间类型,可选值为 `favtime`(收藏时间)和 `pubtime`(发布时间)。
|
||||
|
||||
仅收藏夹视频会区分 `fav_time` 和 `pubtime`,其它类型下载两者取值相同。
|
||||
|
||||
## `time_format`
|
||||
|
||||
时间格式,用于设置 `fav_time` 和 `pubtime` 在 `video_name`、 `page_name` 中使用时的显示格式,支持的格式符号可以参考 [chrono strftime 文档](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)。
|
||||
|
||||
## `credential`
|
||||
|
||||
哔哩哔哩账号的身份凭据,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)获取并对应填写至配置文件中,后续 bili-sync 会在必要时自动刷新身份凭据,不再需要手动管理。
|
||||
|
||||
推荐使用匿名窗口获取,避免潜在的冲突。
|
||||
|
||||
## `filter_option`
|
||||
|
||||
过滤选项,用于设置程序的过滤规则,程序会从过滤结果中选择最优的视频、音频流下载。
|
||||
|
||||
这些内容的可选值可前往 [analyzer.rs](https://github.com/amtoaer/bili-sync/blob/24d0da0bf3ea65fd45d07587e4dcdbb24d11a589/crates/bili_sync/src/bilibili/analyzer.rs#L10-L55) 中查看。
|
||||
|
||||
注意将过滤范围设置过小可能导致筛选不到符合要求的流导致下载失败,建议谨慎修改。
|
||||
|
||||
### `video_max_quality`
|
||||
|
||||
视频流允许的最高质量。
|
||||
|
||||
### `video_min_quality`
|
||||
|
||||
视频流允许的最低质量。
|
||||
|
||||
### `audio_max_quality`
|
||||
|
||||
音频流允许的最高质量。
|
||||
|
||||
### `audio_min_quality`
|
||||
|
||||
音频流允许的最低质量。
|
||||
|
||||
### `codecs`
|
||||
|
||||
这是 bili-sync 选择视频编码的优先级顺序,优先级按顺序从高到低。此处对编码格式做一个简单说明:
|
||||
|
||||
+ AVC 又称 H.264,是目前使用最广泛的视频编码格式,绝大部分设备可以使用硬件解码播放该格式的视频(也因此播放普遍流畅),但是同等画质下视频体积较大。
|
||||
|
||||
+ HEV(C) 又称 H.265,与 AV1 都是新一代的视频编码格式。这两种编码相比 AVC 有更好的压缩率,同等画质下视频体积更小,但由于相对较新,硬件解码支持不如 AVC 广泛。如果你的播放设备不支持则只能使用软件解码播放,这种情况下可能导致播放卡顿、机器发热等问题。
|
||||
|
||||
建议查阅自己常用播放设备对这三种编码的硬件解码支持情况以选择合适的编码格式,如果硬件支持 HEV 或 AV1,那么可以将其优先级调高。
|
||||
|
||||
而如果你的设备不支持,或者单纯懒得查询,那么推荐将 AVC 放在第一位以获得最好的兼容性。
|
||||
|
||||
### `no_dolby_video`
|
||||
|
||||
是否禁用杜比视频流。
|
||||
|
||||
### `no_dolby_audio`
|
||||
|
||||
是否禁用杜比音频流。
|
||||
|
||||
### `no_hdr`
|
||||
|
||||
是否禁用 HDR 视频流。
|
||||
|
||||
### `no_hires`
|
||||
|
||||
是否禁用 Hi-Res 音频流。
|
||||
|
||||
## `danmaku_option`
|
||||
|
||||
弹幕的设置选项,用于设置下载弹幕的样式,几乎全部取自[上游仓库](https://github.com/gwy15/danmu2ass)。
|
||||
|
||||
### `duration`
|
||||
|
||||
弹幕在屏幕上的持续时间,单位为秒。
|
||||
|
||||
### `font`
|
||||
|
||||
弹幕的字体。
|
||||
|
||||
### `font_size`
|
||||
|
||||
弹幕的字体大小。
|
||||
|
||||
### `width_ratio`
|
||||
|
||||
计算弹幕宽度的比例,为避免重叠可以调大这个数值。
|
||||
|
||||
### `horizontal_gap`
|
||||
|
||||
两条弹幕之间最小的水平距离。
|
||||
|
||||
### `lane_size`
|
||||
|
||||
弹幕所占据的高度,即“行高度/行间距”。
|
||||
|
||||
### `float_percentage`
|
||||
|
||||
屏幕上滚动弹幕最多高度百分比。
|
||||
|
||||
### `bottom_percentage`
|
||||
|
||||
屏幕上底部弹幕最多高度百分比。
|
||||
|
||||
### `opacity`
|
||||
|
||||
透明度,取值范围为 0-255。透明度可以通过 opacity / 255 计算得到。
|
||||
|
||||
### `bold`
|
||||
|
||||
是否加粗。
|
||||
|
||||
### `outline`
|
||||
|
||||
描边宽度。
|
||||
|
||||
### `time_offset`
|
||||
|
||||
时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒。
|
||||
|
||||
## `favorite_list`
|
||||
|
||||
你想要下载的收藏夹与想要保存的位置。简单示例:
|
||||
```toml
|
||||
3115878158 = "/home/amtoaer/Downloads/bili-sync/测试收藏夹"
|
||||
```
|
||||
收藏夹 ID 的获取方式可以参考[这里](/favorite)。
|
||||
|
||||
## `collection_list`
|
||||
|
||||
你想要下载的视频合集/视频列表与想要保存的位置。注意“视频合集”与“视频列表”是两种不同的类型。在配置文件中需要做区分:
|
||||
```toml
|
||||
"series:387051756:432248" = "/home/amtoaer/Downloads/bili-sync/测试视频列表"
|
||||
"season:1728547:101343" = "/home/amtoaer/Downloads/bili-sync/测试合集"
|
||||
```
|
||||
|
||||
具体说明可以参考[这里](/collection)。
|
||||
|
||||
## `submission_list`
|
||||
|
||||
你想要下载的 UP 主投稿与想要保存的位置。简单示例:
|
||||
```toml
|
||||
9183758 = "/home/amtoaer/Downloads/bili-sync/测试投稿"
|
||||
```
|
||||
UP 主 ID 的获取方式可以参考[这里](/submission)。
|
||||
|
||||
## `watch_later`
|
||||
|
||||
设置稍后再看的扫描开关与保存位置。
|
||||
|
||||
如果你希望下载稍后再看列表中的视频,可以将 `enabled` 设置为 `true`,并填写 `path`。
|
||||
|
||||
```toml
|
||||
enabled = true
|
||||
path = "/home/amtoaer/Downloads/bili-sync/稍后再看"
|
||||
```
|
||||
|
||||
## `concurrent_limit`
|
||||
|
||||
对 bili-sync 的并发下载进行多方面的限制,避免 api 请求过于频繁导致的风控。其中 video 和 page 表示下载任务的并发数,rate_limit 表示 api 请求的流量限制。默认取值为:
|
||||
```toml
|
||||
[concurrent_limit]
|
||||
video = 3
|
||||
page = 2
|
||||
|
||||
[concurrent_limit.rate_limit]
|
||||
limit = 4
|
||||
duration = 250
|
||||
```
|
||||
|
||||
具体来说,程序的处理逻辑是严格从上到下的,即程序会首先并发处理多个 video,每个 video 内再并发处理多个 page,程序的并行度可以简单衡量为 `video * page`(很多 video 都只有单个 page,实际会更接近 `video * 1`),配置项中的 `video` 和 `page` 两个参数就是控制此处的,调节这两个参数可以宏观上控制程序的并行度。
|
||||
|
||||
另一方面,每个执行的任务内部都会发起若干 api 请求以获取信息,这些请求的整体频率受到 `rate_limit` 的限制,使用漏桶算法实现。如默认配置表示的是每 250ms 允许 4 个 api 请求,超过这个频率的请求会被暂时阻塞,直到漏桶中有空间为止。调节 `rate_limit` 可以从微观上控制程序的并行度,同时也是最直接、最显著的控制 api 请求频率的方法。
|
||||
|
||||
据观察 b 站风控限制大多集中在主站,因此目前 `rate_limit` 仅作用于主站的各类请求,如请求各类视频列表、视频信息、获取流下载地址等,对实际的视频、图片下载过程不做限制。
|
||||
|
||||
> [!TIP]
|
||||
> 1. 一般来说,`video` 和 `page` 的值不需要过大;
|
||||
> 2. `rate_limit` 的值可以根据网络环境和 api 请求频率进行调整,如果经常遇到风控可以优先调小 limit。
|
||||
110
docs/design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 工作原理
|
||||
|
||||
本节会尽可能简单明了地介绍 `bili-sync` 的工作原理,让用户了解程序的整体执行过程。
|
||||
|
||||
## b 站的视频结构
|
||||
|
||||
在了解程序工作原理之前,我们需要先对 b 站视频的组织结构有一个大概的了解。简单来说:
|
||||
|
||||
- 收藏夹、稍后再看、视频合集、视频列表等结构都是由一系列视频构成的列表;
|
||||
- 每个视频都有唯一的 bvid,包含了封面、描述和标签信息,并包含了一个或多个分页;
|
||||
- 每个分页都有一个唯一的 cid,包含了封面、视频、音频、弹幕。
|
||||
|
||||
为了描述方便,在后文会将收藏夹、稍后再看这类结构统称为 video list,将视频称为 video,将分页称为 page。不难看出这三者有着很明显的层级关系:**video list 包含若干 video,video 包含若干 page**。
|
||||
|
||||
一个非常容易混淆的点是视频合集/视频列表与多页视频的区别:
|
||||
|
||||
> [!NOTE]
|
||||
> 
|
||||
>
|
||||
>
|
||||
|
||||
这两张图中,上图是视频合集,下图是多页视频。这两者在展示上区别较小,但在结构上有相当大的不同。结合上面对 b 站视频结构的介绍,这个区别可以简单总结为:
|
||||
|
||||
+ **视频合集是由多个仅包含单个 page 的 video 组成的 video list**;
|
||||
|
||||
+ **多页视频是由多个 page 组成的 video**。
|
||||
|
||||
这说明它们是处于两个不同层级的结构,因此程序对其的处理方式也有着相当大的不同。
|
||||
|
||||
## 与 EMBY 媒体库的对应关系
|
||||
|
||||
EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/分集`,方便起见,我采用了如下的对应关系:
|
||||
|
||||
1. **文件夹**:对应 b 站的 video list;
|
||||
2. **电视剧**: 对应 b 站的 video;
|
||||
3. **第一季的所有分集**:对应 b 站的 page。
|
||||
|
||||
特别的,当 video 仅有一个 page 时,为了避免过多的层级,bili-sync 会将 page 展开到第二层级,变成与电视剧同级的电影。
|
||||
|
||||
因此,**需要将媒体库类型设置为“混合内容”以支持在同个媒体库中同时显示电视剧与电影**。
|
||||
|
||||
### 单 page 的 video
|
||||
|
||||

|
||||
|
||||
### 多 page 的 video
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 数据库设计
|
||||
|
||||
> [!NOTE]
|
||||
> 可以[前往此处](https://github.com/amtoaer/bili-sync/tree/main/crates/bili_sync_entity/src/entities)实时查看当前版本的数据库表结构。
|
||||
|
||||
既然拥有着明显的层级关系,那数据库表就很容易设计了。为了简化实现,程序没有额外考虑单个 video 被多个 video list 引用的情况(如一个视频同时在收藏夹和稍后再看中)。而是简单的将其设计为了不交叉的层级结构。
|
||||
|
||||
### video list 表
|
||||
|
||||
从上面的介绍可以看出,video list 并不是一个具体的结构,而是拥有多种实现的抽象概念。我选择将其特化实现为多张表:
|
||||
|
||||
1. favorite:收藏夹;
|
||||
2. watch_later:稍后再看;
|
||||
3. collection: 视频合集/视频列表;
|
||||
4. ....
|
||||
|
||||
### video 表
|
||||
|
||||
video 表包含了 video 的基本信息,如 bvid、标题、封面、描述、标签等。此外,video 表还包含了与 video list 的关联。
|
||||
|
||||
具体来说,每一种 video list 都在 video 表中有一个对应的列,指向 video list 表中的 id,如 favorite_id、collection_id 等。接下来将这些键与 video 的 bvid 绑在一起建立唯一索引,就可以保证在同一个 video list 中不会有重复的 video。
|
||||
|
||||
### page 表
|
||||
|
||||
page 表包含了 page 的基本信息,如 cid、标题、封面等。与 video 类似但更简单,page 表仅包含了与 video 的关联。
|
||||
|
||||
## 执行过程
|
||||
|
||||
### 初始化
|
||||
|
||||
程序启动时会读取配置文件、迁移数据库、初始化日志等操作。如果发现需要的文件不存在,程序会自动创建。
|
||||
|
||||
### 扫描 video list
|
||||
|
||||
> [!WARNING]
|
||||
> b 站实现接口时为了节省资源,通过 video list 获取到的 video 列表通常是分页且不包含详细信息的。
|
||||
|
||||
程序会扫描所有配置文件中包含的 video list,获取其中包含的 video 的简单信息并填充到数据库。在实现时需要避免频繁的全量扫描。
|
||||
|
||||
具体到 bili-sync 的实现中,程序在请求接口时会设置按时间顺序排序的参数,确保新发布的视频位于前面。拉取过程会逐页请求,使用视频的 bvid 与 time 字段来检验视频是否已经存在于数据库中。一旦发现 bvid 与 time 均相同的记录则认为已经到达扫描过的位置,停止拉取。
|
||||
|
||||
### 填充 video 详情
|
||||
|
||||
将新增视频的简单信息写入数据库后,下一步会填充 video 详情。正如上文所述:**通过 video list 获取到的 video 列表通常是不包含详细信息的**,因此需要额外的请求来填充这些信息。
|
||||
|
||||
这一步会筛选出所有未完全填充信息的 video,逐个获取 video 的详细信息(如标签、视频分页等)并填充到数据库中。
|
||||
|
||||
在这个过程中,如果遇到 -404 错误码则说明视频无法被正常访问,程序会将该视频标记为无效并跳过。
|
||||
|
||||
|
||||
### 下载未处理的视频
|
||||
|
||||
经过上面处理后,数据库中已经包含了所有需要的 video 信息,接下来只需要筛选其中“未完全下载”、“成功填充详细信息”的所有视频,并发下载即可。程序在 video 层级最多允许 3 个任务同时下载,page 层级最多允许 2 个任务同时下载。
|
||||
|
||||
数据库中的 status 字段用于标记 video 和 page 的下载状态,视频的各个部分(封面、视频、nfo 等)包含在 status 的不同位中。程序会根据 status 的不同位来判断视频的下载状态,以此来决定是否需要下载。
|
||||
|
||||
如果某些部分下载失败,status 字段会记录这些部分的失败次数,程序会在下次下载时重试。如果重试次数超过了设定的阈值,那么视频会被标记为下载失败,后续直接忽略。
|
||||
|
||||
此处程序对风控做了额外的处理,一般风控发生时接下来的所有请求都会失败,因此程序检测到风控时不会认为是某个视频下载失败,而是直接终止 video list 的全部下载任务,等待下次扫描时重试。
|
||||
5
docs/favorite.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 获取收藏夹信息
|
||||
|
||||
收藏夹的 ID 获取非常简单,在网页端打开自己的收藏夹列表,切换到你想要获取的收藏夹,然后查看 URL 地址栏中的 `fid` 参数内容即可。
|
||||
|
||||

|
||||
58
docs/index.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
title: bili-sync
|
||||
titleTemplate: 由 Rust & Tokio 驱动的哔哩哔哩同步工具
|
||||
|
||||
hero:
|
||||
name: "bili-sync"
|
||||
text: "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
# tagline: My great project tagline
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 什么是 bili-sync?
|
||||
link: /introduction
|
||||
- theme: alt
|
||||
text: 快速开始
|
||||
link: /quick-start
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/amtoaer/bili-sync
|
||||
image:
|
||||
src: /logo.webp
|
||||
alt: bili-sync
|
||||
|
||||
features:
|
||||
- icon: 🤖
|
||||
title: 无需干预
|
||||
details: 自动选择最优的视频和音频配置
|
||||
- icon: 💾
|
||||
title: 专为 NAS 设计
|
||||
details: 可被 Emby、Jellyfin 等媒体服务器一键识别
|
||||
- icon: 🐳
|
||||
title: 部署简单
|
||||
details: 提供简单易用的 docker 镜像
|
||||
---
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(-45deg, #bd34fe 50%, #47caff 50%);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
docs/introduction.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.2.0,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
它的基本的工作原理是使用用户填写的凭据定期扫描视频合集、收藏夹等,获取到本地未下载过的内容并保存到本地,维持本地视频库与哔哩哔哩网站的同步。
|
||||
|
||||
下载的内容包括视频、封面、弹幕、标签与简介信息等,这些文件整体保持与 Emby、Jellyfin 等媒体服务器软件兼容的文件布局,使得目的文件夹可以直接被作为媒体库添加到这些软件中,无需干预自动识别。
|
||||
|
||||
## 使用截图
|
||||
|
||||
> [!WARNING]
|
||||
> 媒体库类型请选择“混合内容”,否则可能导致多页视频无法正常显示。
|
||||
|
||||
|
||||
|
||||
### 概览
|
||||

|
||||
### 详情
|
||||

|
||||
### 播放(使用 infuse)
|
||||

|
||||
### 文件排布
|
||||

|
||||
|
||||
## 功能与路线图
|
||||
|
||||
- [x] 使用用户填写的凭据认证,并在必要时自动刷新
|
||||
- [x] 支持收藏夹与视频列表/视频合集的下载
|
||||
- [x] 自动选择用户设置范围内最优的视频和音频流,并在下载完成后使用 FFmpeg 合并
|
||||
- [x] 使用 Tokio 与 Reqwest,对视频、视频分页进行异步并发下载
|
||||
- [x] 使用媒体服务器支持的文件命名,方便一键作为媒体库导入
|
||||
- [x] 当前轮次下载失败会在下一轮下载时重试,失败次数过多自动丢弃
|
||||
- [x] 使用数据库保存媒体信息,避免对同个视频的多次请求
|
||||
- [x] 打印日志,并在请求出现风控时自动终止,等待下一轮执行
|
||||
- [x] 提供多平台的二进制可执行文件,为 Linux 平台提供了立即可用的 Docker 镜像
|
||||
- [x] 支持对“稍后再看”内视频的自动扫描与下载
|
||||
- [x] 支持对 UP 主投稿视频的自动扫描与下载
|
||||
- [x] 支持限制任务的并行度和接口请求频率
|
||||
- [ ] 下载单个文件时支持断点续传与并发下载
|
||||
13
docs/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"vitepress": "^1.2.3"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
"docs:build": "vitepress build",
|
||||
"docs:preview": "vitepress preview"
|
||||
}
|
||||
}
|
||||