mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-08 09:12:56 +08:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d9dc8b87 | ||
|
|
7f09a98d6c | ||
|
|
269647ac22 | ||
|
|
e0189c5b36 | ||
|
|
4c1abcf48c | ||
|
|
c05463285b | ||
|
|
264de2487e | ||
|
|
ea575b04e6 | ||
|
|
f122b9756b | ||
|
|
26514f7174 | ||
|
|
5944298f10 | ||
|
|
64eecaa822 | ||
|
|
18d06c51ba | ||
|
|
ffa5c1e860 | ||
|
|
97e1b6285e | ||
|
|
e2a24eff29 | ||
|
|
56f5ed8e01 | ||
|
|
0b5ae3d664 | ||
|
|
f24ee97b28 | ||
|
|
96c11bb077 | ||
|
|
2455f7c83d | ||
|
|
4faf5a7cf9 | ||
|
|
c2c732093d | ||
|
|
4103122f6b | ||
|
|
14b8f877cf | ||
|
|
8dfc7ddf5c | ||
|
|
9a63e1eb6f | ||
|
|
d1b279ed7f | ||
|
|
128ca49225 | ||
|
|
8c2e8da2b0 | ||
|
|
5dd7486b12 | ||
|
|
b7d9e5dc0c | ||
|
|
d1eac3e298 | ||
|
|
3f047771cb | ||
|
|
f1703096fd | ||
|
|
930660045f | ||
|
|
6391aa67c0 | ||
|
|
b5ef76b0ed | ||
|
|
f37d9af678 | ||
|
|
7ef38a38ed | ||
|
|
e76673d076 | ||
|
|
f3822dd536 | ||
|
|
688c8cec6a | ||
|
|
c854e4e889 | ||
|
|
645e686822 | ||
|
|
670f21a725 | ||
|
|
8931cb5d2a | ||
|
|
66996a77c6 | ||
|
|
170bd14fe3 | ||
|
|
c69a88f1da | ||
|
|
8ac6829e61 | ||
|
|
a871db655f | ||
|
|
854d39cf88 | ||
|
|
b6cba69e11 | ||
|
|
ff6db0ad97 | ||
|
|
84d353365a | ||
|
|
c7e0d31811 | ||
|
|
2fff5134cf | ||
|
|
8a1569d085 | ||
|
|
de702435af | ||
|
|
eb2606f120 | ||
|
|
02c42861ab | ||
|
|
ed54ca13b8 | ||
|
|
4d6669a48a | ||
|
|
eadb464363 | ||
|
|
2b046362d7 | ||
|
|
61c9e7de88 | ||
|
|
3d25c6b321 | ||
|
|
d35858790b | ||
|
|
b441f04cdf | ||
|
|
4db7e6763a | ||
|
|
bbbb7d0c5b | ||
|
|
210c94398a | ||
|
|
6c7d295fe6 | ||
|
|
71519af2f3 | ||
|
|
8ed2fbae24 | ||
|
|
fd90bc8b73 | ||
|
|
66bd3d6a41 | ||
|
|
5ef23a678f | ||
|
|
66079f3adc | ||
|
|
4f780faf64 | ||
|
|
dbcb1fa78b | ||
|
|
386dac7735 | ||
|
|
5537c621be | ||
|
|
c7978e20da | ||
|
|
6e4af47bda | ||
|
|
791e4997a0 | ||
|
|
05ab83fc93 | ||
|
|
18ed9e09b1 | ||
|
|
e196afa8ce | ||
|
|
9b2da75391 | ||
|
|
664e1d9f21 | ||
|
|
31c26f033e | ||
|
|
29d78dabdd | ||
|
|
87fb597ba4 | ||
|
|
c8f7a2267d | ||
|
|
2837bb5234 | ||
|
|
0990a276ff | ||
|
|
adc2e32e58 | ||
|
|
267e9373f9 | ||
|
|
dd23d1db58 | ||
|
|
cc25749445 | ||
|
|
655b4389b7 | ||
|
|
486dab5355 | ||
|
|
74a45526f0 | ||
|
|
ce60838244 | ||
|
|
35866888e8 | ||
|
|
fbb7623ee1 | ||
|
|
1affe4d594 | ||
|
|
7c73a2f01a | ||
|
|
a627584fb0 | ||
|
|
636a843bda | ||
|
|
7bb4e7bc44 | ||
|
|
e50318870e | ||
|
|
28971c3ff3 | ||
|
|
f47ce92a51 | ||
|
|
a35794ed7a | ||
|
|
bad00af147 | ||
|
|
4539e9379d | ||
|
|
a46c2572b1 | ||
|
|
a41efdbe78 | ||
|
|
a98e49347b | ||
|
|
586d5ec4ee | ||
|
|
65a047b0fa | ||
|
|
c0ed37750f | ||
|
|
0e98f484ef | ||
|
|
6226fa7c4d | ||
|
|
c528152986 | ||
|
|
45849957ff | ||
|
|
8510aa318e | ||
|
|
c07e475fe6 | ||
|
|
a574d005c3 | ||
|
|
e9d1c9eadb | ||
|
|
a9f604a07d | ||
|
|
6383730706 | ||
|
|
34d3e47b2d | ||
|
|
d7ec0584bc | ||
|
|
1ec015856b | ||
|
|
99d4d900e6 | ||
|
|
f85f105e69 | ||
|
|
8a1395458c | ||
|
|
bafb4af8dd |
37
.github/workflows/pr-check.yaml
vendored
37
.github/workflows/pr-check.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: ['opened', 'reopened', 'synchronize', 'ready_for_review']
|
||||
types: ["opened", "reopened", "synchronize", "ready_for_review"]
|
||||
|
||||
concurrency:
|
||||
# Allow only one workflow per any non-`main` branch.
|
||||
@@ -18,15 +18,15 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Run Clippy and tests
|
||||
check-backend:
|
||||
name: Run backend checks
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- run: rustup default nightly && rustup component add rustfmt clippy
|
||||
- run: rustup default stable && rustup component add clippy && rustup component add rustfmt --toolchain nightly
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@v2
|
||||
@@ -34,10 +34,35 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: cargo fmt check
|
||||
run: cargo fmt --check
|
||||
run: cargo +nightly fmt --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test
|
||||
check-frontend:
|
||||
name: Run frontend checks
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
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: Check Frontend
|
||||
run: bun run lint
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
**/target
|
||||
auth_data
|
||||
*.sqlite
|
||||
video
|
||||
*.sqlite*
|
||||
debug*
|
||||
node_modules
|
||||
docs/.vitepress/cache
|
||||
|
||||
3089
Cargo.lock
generated
3089
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
90
Cargo.toml
90
Cargo.toml
@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.5.0"
|
||||
version = "2.10.2"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
@@ -15,63 +15,75 @@ publish = false
|
||||
bili_sync_entity = { path = "crates/bili_sync_entity" }
|
||||
bili_sync_migration = { path = "crates/bili_sync_migration" }
|
||||
|
||||
anyhow = { version = "1.0.96", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7.1", features = ["serde"] }
|
||||
assert_matches = "1.5.0"
|
||||
async-std = { version = "1.13.0", features = ["attributes", "tokio1"] }
|
||||
anyhow = { version = "1.0.100", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.8.0", features = ["serde"] }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.86"
|
||||
axum = { version = "0.8.1", features = ["macros"] }
|
||||
async-tempfile = { version = "0.7.0", features = ["uuid"] }
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.8", features = ["macros", "ws"] }
|
||||
base64 = "0.22.1"
|
||||
built = { version = "0.7.7", features = ["git2", "chrono"] }
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
clap = { version = "4.5.30", features = ["env", "string"] }
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
clap = { version = "4.5.54", features = ["env", "string"] }
|
||||
cookie = "0.18.1"
|
||||
cow-utils = "0.1.3"
|
||||
croner = "3.0.1"
|
||||
dashmap = "6.1.0"
|
||||
derivative = "2.2.0"
|
||||
dirs = "6.0.0"
|
||||
enum_dispatch = "0.3.13"
|
||||
float-ord = "0.3.2"
|
||||
futures = "0.3.31"
|
||||
handlebars = "6.3.1"
|
||||
git2 = { version = "0.20.3", features = [], default-features = false }
|
||||
handlebars = "6.4.0"
|
||||
hex = "0.4.3"
|
||||
itertools = "0.14.0"
|
||||
leaky-bucket = "1.1.2"
|
||||
md5 = "0.7.0"
|
||||
memchr = "2.7.4"
|
||||
mime_guess = "2.0.5"
|
||||
once_cell = "1.20.3"
|
||||
prost = "0.13.5"
|
||||
quick-xml = { version = "0.37.2", features = ["async-tokio"] }
|
||||
rand = "0.8.5"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.12", features = [
|
||||
md5 = "0.8.0"
|
||||
memchr = "2.7.6"
|
||||
once_cell = "1.21.3"
|
||||
parking_lot = "0.12.5"
|
||||
prost = "0.14.1"
|
||||
quick-xml = { version = "0.38.4", features = ["async-tokio"] }
|
||||
rand = "0.9.2"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.13.1", features = [
|
||||
"query",
|
||||
"form",
|
||||
"charset",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"http2",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"rustls-no-provider",
|
||||
"stream",
|
||||
], default-features = false }
|
||||
rsa = { version = "0.9.7", features = ["sha2"] }
|
||||
rust-embed = "8.5.0"
|
||||
sea-orm = { version = "1.1.5", features = [
|
||||
rsa = { version = "0.10.0-rc.9", features = ["sha2"] }
|
||||
rust-embed-for-web = { git = "https://github.com/amtoaer/rust-embed-for-web", tag = "v1.0.0" }
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
|
||||
sea-orm = { version = "1.1.19", features = [
|
||||
"macros",
|
||||
"runtime-tokio-rustls",
|
||||
"runtime-tokio",
|
||||
"sqlx-sqlite",
|
||||
"sqlite-use-returning-for-3_35",
|
||||
] }
|
||||
sea-orm-migration = { version = "1.1.5", features = [] }
|
||||
serde = { version = "1.0.218", features = ["derive"] }
|
||||
serde_json = "1.0.139"
|
||||
sea-orm-migration = { version = "1.1.19", features = [] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
serde_urlencoded = "0.7.1"
|
||||
strum = { version = "0.27.1", features = ["derive"] }
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.13", features = ["io", "rt"] }
|
||||
toml = "0.8.20"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sysinfo = "0.37.2"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-cron-scheduler = "0.15.1"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
tokio-util = { version = "0.7.18", features = ["io", "rt"] }
|
||||
toml = "0.9.10"
|
||||
tower = "0.5.2"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono"] }
|
||||
utoipa = { version = "5.3.1", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.0", features = ["axum", "vendored"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["chrono", "json"] }
|
||||
ua_generator = { version = "0.5.42", default-features = false }
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
release = false
|
||||
@@ -82,8 +94,12 @@ 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 },
|
||||
{ file = "../../web/package.json", search = "\"version\": \"[0-9\\.]+\"", replace = "\"version\": \"{{version}}\"", exactly = 1 },
|
||||
]
|
||||
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
|
||||
13
README.md
13
README.md
@@ -9,10 +9,12 @@ bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust
|
||||
|
||||
## 效果演示
|
||||
|
||||
### 概览
|
||||

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

|
||||
### 管理页
|
||||

|
||||
### 媒体库概览
|
||||

|
||||
### 媒体库详情
|
||||

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

|
||||
### 文件排布
|
||||
@@ -33,7 +35,8 @@ bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust
|
||||
- [x] 支持对“稍后再看”内视频的自动扫描与下载
|
||||
- [x] 支持对 UP 主投稿视频的自动扫描与下载
|
||||
- [x] 支持限制任务的并行度和接口请求频率
|
||||
- [ ] 下载单个文件时支持断点续传与并发下载
|
||||
- [x] 支持单个文件的分块并行下载
|
||||
- [x] 支持使用 Web UI 配置,查看并管理视频、视频源
|
||||
|
||||
|
||||
## 参考与借鉴
|
||||
|
||||
BIN
assets/webui.webp
Normal file
BIN
assets/webui.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -13,51 +13,58 @@ build = "build.rs"
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-tempfile = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bili_sync_entity = { workspace = true }
|
||||
bili_sync_migration = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
cookie = { workspace = true }
|
||||
cow-utils = { workspace = true }
|
||||
croner = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
enum_dispatch = { workspace = true }
|
||||
float-ord = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
handlebars = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
leaky-bucket = { workspace = true }
|
||||
md5 = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
rust-embed-for-web = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-cron-scheduler = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-swagger-ui = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_matches = { workspace = true }
|
||||
ua_generator = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
built = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
|
||||
[package.metadata.release]
|
||||
release = true
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, Credential, VideoInfo};
|
||||
|
||||
impl VideoSource for collection::Model {
|
||||
fn display_name(&self) -> Cow<'static, str> {
|
||||
format!("{}「{}」", CollectionType::from_expected(self.r#type), self.name).into()
|
||||
}
|
||||
|
||||
fn filter_expr(&self) -> SimpleExpr {
|
||||
video::Column::CollectionId.eq(self.id)
|
||||
}
|
||||
@@ -38,88 +44,75 @@ impl VideoSource for collection::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn should_take(&self, _release_datetime: &chrono::DateTime<Utc>, _latest_row_at: &chrono::DateTime<Utc>) -> bool {
|
||||
fn should_take(
|
||||
&self,
|
||||
_idx: usize,
|
||||
_release_datetime: &chrono::DateTime<Utc>,
|
||||
_latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
// collection(视频合集/视频列表)返回的内容似乎并非严格按照时间排序,并且不同 collection 的排序方式也不同
|
||||
// 为了保证程序正确性,collection 不根据时间提前 break,而是每次都全量拉取
|
||||
true
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描{}「{}」..", CollectionType::from(self.r#type), self.name);
|
||||
fn should_filter(
|
||||
&self,
|
||||
_idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
// 由于 collection 的视频无固定时间顺序,should_take 无法提前中断拉取,因此 should_filter 环节需要进行额外过滤
|
||||
if let Ok(video_info) = video_info
|
||||
&& video_info.release_datetime() > latest_row_at
|
||||
{
|
||||
return Some(video_info);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, count: usize) {
|
||||
info!(
|
||||
"扫描{}「{}」完成,已拉取 {} 条视频",
|
||||
CollectionType::from(self.r#type),
|
||||
self.name,
|
||||
count,
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let collection = Collection::new(
|
||||
bili_client,
|
||||
CollectionItem {
|
||||
sid: self.s_id.to_string(),
|
||||
mid: self.m_id.to_string(),
|
||||
collection_type: CollectionType::from_expected(self.r#type),
|
||||
},
|
||||
credential,
|
||||
);
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!(
|
||||
"开始填充{}「{}」视频详情..",
|
||||
CollectionType::from(self.r#type),
|
||||
self.name
|
||||
let collection_info = collection.get_info().await?;
|
||||
ensure!(
|
||||
collection_info.sid == self.s_id
|
||||
&& collection_info.mid == self.m_id
|
||||
&& collection_info.collection_type == CollectionType::from_expected(self.r#type),
|
||||
"collection info mismatch: {:?} != {:?}",
|
||||
collection_info,
|
||||
collection.collection
|
||||
);
|
||||
let updated_model = collection::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
name: Set(collection_info.name),
|
||||
..Default::default()
|
||||
}
|
||||
.update(connection)
|
||||
.await?;
|
||||
Ok((updated_model.into(), Box::pin(collection.into_video_stream())))
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("填充{}「{}」视频详情完成", CollectionType::from(self.r#type), self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载{}「{}」视频..", CollectionType::from(self.r#type), self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载{}「{}」视频完成", CollectionType::from(self.r#type), self.name);
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn collection_from<'a>(
|
||||
collection_item: &'a CollectionItem,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + 'a + Send>>,
|
||||
)> {
|
||||
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((
|
||||
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?
|
||||
.context("collection not found")?
|
||||
.into(),
|
||||
Box::pin(collection.into_video_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, FavoriteList, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, FavoriteList, VideoInfo};
|
||||
|
||||
impl VideoSource for favorite::Model {
|
||||
fn display_name(&self) -> Cow<'static, str> {
|
||||
format!("收藏夹「{}」", self.name).into()
|
||||
}
|
||||
|
||||
fn filter_expr(&self) -> SimpleExpr {
|
||||
video::Column::FavoriteId.eq(self.id)
|
||||
}
|
||||
@@ -37,62 +43,39 @@ impl VideoSource for favorite::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描收藏夹「{}」..", self.name);
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, count: usize) {
|
||||
info!("扫描收藏夹「{}」完成,获取到 {} 条新视频", self.name, count);
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let favorite = FavoriteList::new(bili_client, self.f_id.to_string(), credential);
|
||||
let favorite_info = favorite.get_info().await?;
|
||||
ensure!(
|
||||
favorite_info.id == self.f_id,
|
||||
"favorite id mismatch: {} != {}",
|
||||
favorite_info.id,
|
||||
self.f_id
|
||||
);
|
||||
let updated_model = favorite::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
name: Set(favorite_info.title),
|
||||
..Default::default()
|
||||
}
|
||||
.update(connection)
|
||||
.await?;
|
||||
Ok((updated_model.into(), Box::pin(favorite.into_video_stream())))
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始填充收藏夹「{}」视频详情..", self.name);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("填充收藏夹「{}」视频详情完成", self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载收藏夹「{}」视频..", self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载收藏夹「{}」视频完成", self.name);
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn favorite_from<'a>(
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + 'a + Send>>,
|
||||
)> {
|
||||
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((
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::FId.eq(favorite_info.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("favorite not found")?
|
||||
.into(),
|
||||
Box::pin(favorite.into_video_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ mod favorite;
|
||||
mod submission;
|
||||
mod watch_later;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::SimpleExpr;
|
||||
@@ -17,14 +19,11 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
#[rustfmt::skip]
|
||||
use bili_sync_entity::collection::Model as Collection;
|
||||
use bili_sync_entity::favorite::Model as Favorite;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::submission::Model as Submission;
|
||||
use bili_sync_entity::watch_later::Model as WatchLater;
|
||||
|
||||
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::{BiliClient, CollectionItem, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, VideoInfo};
|
||||
|
||||
#[enum_dispatch]
|
||||
pub enum VideoSourceEnum {
|
||||
@@ -36,6 +35,9 @@ pub enum VideoSourceEnum {
|
||||
|
||||
#[enum_dispatch(VideoSourceEnum)]
|
||||
pub trait VideoSource {
|
||||
/// 获取视频源的名称
|
||||
fn display_name(&self) -> Cow<'static, str>;
|
||||
|
||||
/// 获取特定视频列表的筛选条件
|
||||
fn filter_expr(&self) -> SimpleExpr;
|
||||
|
||||
@@ -54,52 +56,73 @@ pub trait VideoSource {
|
||||
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel;
|
||||
|
||||
// 判断是否应该继续拉取视频
|
||||
fn should_take(&self, release_datetime: &chrono::DateTime<Utc>, latest_row_at: &chrono::DateTime<Utc>) -> bool {
|
||||
fn should_take(
|
||||
&self,
|
||||
_idx: usize,
|
||||
release_datetime: &chrono::DateTime<Utc>,
|
||||
latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
release_datetime > latest_row_at
|
||||
}
|
||||
|
||||
/// 开始刷新视频
|
||||
fn log_refresh_video_start(&self);
|
||||
|
||||
/// 结束刷新视频
|
||||
fn log_refresh_video_end(&self, count: usize);
|
||||
|
||||
/// 开始填充视频
|
||||
fn log_fetch_video_start(&self);
|
||||
|
||||
/// 结束填充视频
|
||||
fn log_fetch_video_end(&self);
|
||||
|
||||
/// 开始下载视频
|
||||
fn log_download_video_start(&self);
|
||||
|
||||
/// 结束下载视频
|
||||
fn log_download_video_end(&self);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Args<'a> {
|
||||
Favorite { fid: &'a str },
|
||||
Collection { collection_item: &'a CollectionItem },
|
||||
WatchLater,
|
||||
Submission { upper_id: &'a str },
|
||||
}
|
||||
|
||||
pub async fn video_source_from<'a>(
|
||||
args: Args<'a>,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + 'a + Send>>,
|
||||
)> {
|
||||
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,
|
||||
fn should_filter(
|
||||
&self,
|
||||
_idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
_latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
// 视频按照时间顺序拉取,should_take 已经获取了所有需要处理的视频,should_filter 无需额外处理
|
||||
video_info.ok()
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule>;
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描{}..", self.display_name());
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, count: usize) {
|
||||
info!("扫描{}完成,获取到 {} 条新视频", self.display_name(), count);
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始填充{}视频详情..", self.display_name());
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("填充{}视频详情完成", self.display_name());
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载{}视频..", self.display_name());
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载{}视频完成", self.display_name());
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)>;
|
||||
|
||||
async fn create_dir_all(&self) -> Result<()> {
|
||||
let video_source_path = self.path();
|
||||
tokio::fs::create_dir_all(video_source_path).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create video source directory {}",
|
||||
video_source_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()>;
|
||||
}
|
||||
|
||||
pub enum _ActiveModel {
|
||||
@@ -121,8 +144,13 @@ impl _ActiveModel {
|
||||
_ActiveModel::Submission(model) => {
|
||||
model.save(connection).await?;
|
||||
}
|
||||
_ActiveModel::WatchLater(model) => {
|
||||
model.save(connection).await?;
|
||||
_ActiveModel::WatchLater(mut model) => {
|
||||
if model.id.is_not_set() {
|
||||
model.id = Set(1);
|
||||
model.insert(connection).await?;
|
||||
} else {
|
||||
model.save(connection).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, Submission, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Dynamic, Submission, VideoInfo};
|
||||
|
||||
impl VideoSource for submission::Model {
|
||||
fn display_name(&self) -> std::borrow::Cow<'static, str> {
|
||||
format!("「{}」投稿", self.upper_name).into()
|
||||
}
|
||||
|
||||
fn filter_expr(&self) -> SimpleExpr {
|
||||
video::Column::SubmissionId.eq(self.id)
|
||||
}
|
||||
@@ -37,62 +42,81 @@ impl VideoSource for submission::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描「{}」投稿..", self.upper_name);
|
||||
fn should_take(
|
||||
&self,
|
||||
idx: usize,
|
||||
release_datetime: &chrono::DateTime<chrono::Utc>,
|
||||
latest_row_at: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
// 如果使用动态 API,那么可能出现用户置顶了一个很久以前的视频在动态顶部的情况
|
||||
// 这种情况应该继续拉取下去,不能因为第一条不满足条件就停止
|
||||
// 后续的非置顶内容是正常由新到旧排序的,可以继续使用常规方式处理
|
||||
if idx == 0 && self.use_dynamic_api {
|
||||
return true;
|
||||
}
|
||||
release_datetime > latest_row_at
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, count: usize) {
|
||||
info!("扫描「{}」投稿完成,获取到 {} 条新视频", self.upper_name, count,);
|
||||
fn should_filter(
|
||||
&self,
|
||||
idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
latest_row_at: &chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
if idx == 0 && self.use_dynamic_api {
|
||||
// 同理,动态 API 的第一条内容可能是置顶的老视频,单独做个过滤
|
||||
// 其实不过滤也不影响逻辑正确性,因为后续 insert 发生冲突仍然会忽略掉
|
||||
// 此处主要是出于性能考虑,减少不必要的数据库操作
|
||||
if let Ok(video_info) = video_info
|
||||
&& video_info.release_datetime() > latest_row_at
|
||||
{
|
||||
return Some(video_info);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
video_info.ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始填充「{}」投稿视频详情..", self.upper_name);
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("填充「{}」投稿视频详情完成", self.upper_name);
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let submission = Submission::new(bili_client, self.upper_id.to_string(), credential);
|
||||
let upper = submission.get_info().await?;
|
||||
ensure!(
|
||||
upper.mid == submission.upper_id,
|
||||
"submission upper id mismatch: {} != {}",
|
||||
upper.mid,
|
||||
submission.upper_id
|
||||
);
|
||||
let updated_model = submission::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
upper_name: Set(upper.name),
|
||||
..Default::default()
|
||||
}
|
||||
.update(connection)
|
||||
.await?;
|
||||
let video_stream = if self.use_dynamic_api {
|
||||
// 必须显式写出 dyn,否则 rust 会自动推导到 impl 从而认为 if else 返回类型不一致
|
||||
Box::pin(Dynamic::from(submission).into_video_stream()) as Pin<Box<dyn Stream<Item = _> + Send + 'a>>
|
||||
} else {
|
||||
Box::pin(submission.into_video_stream())
|
||||
};
|
||||
Ok((updated_model.into(), video_stream))
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载「{}」投稿视频..", self.upper_name);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载「{}」投稿视频完成", self.upper_name);
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn submission_from<'a>(
|
||||
upper_id: &str,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + 'a + Send>>,
|
||||
)> {
|
||||
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((
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::UpperId.eq(upper.mid))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("submission not found")?
|
||||
.into(),
|
||||
Box::pin(submission.into_video_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, VideoInfo, WatchLater};
|
||||
use crate::bilibili::{BiliClient, Credential, VideoInfo, WatchLater};
|
||||
|
||||
impl VideoSource for watch_later::Model {
|
||||
fn display_name(&self) -> std::borrow::Cow<'static, str> {
|
||||
"稍后再看".into()
|
||||
}
|
||||
|
||||
fn filter_expr(&self) -> SimpleExpr {
|
||||
video::Column::WatchLaterId.eq(self.id)
|
||||
}
|
||||
@@ -37,59 +42,25 @@ impl VideoSource for watch_later::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描稍后再看..");
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, count: usize) {
|
||||
info!("扫描稍后再看完成,获取到 {} 条新视频", count);
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
_connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let watch_later = WatchLater::new(bili_client, credential);
|
||||
Ok((self.into(), Box::pin(watch_later.into_video_stream())))
|
||||
}
|
||||
|
||||
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!("下载稍后再看视频完成");
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn watch_later_from<'a>(
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + 'a + Send>>,
|
||||
)> {
|
||||
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((
|
||||
watch_later::Entity::find()
|
||||
.filter(watch_later::Column::Id.eq(1))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("watch_later not found")?
|
||||
.into(),
|
||||
Box::pin(watch_later.into_video_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use reqwest::StatusCode;
|
||||
use utoipa::Modify;
|
||||
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
||||
|
||||
use crate::api::wrapper::ApiResponse;
|
||||
use crate::config::CONFIG;
|
||||
|
||||
pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||
if request.uri().path().starts_with("/api/") && get_token(&headers) != CONFIG.auth_token {
|
||||
return Ok(ApiResponse::unauthorized(()).into_response());
|
||||
}
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
fn get_token(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
pub(super) struct OpenAPIAuth;
|
||||
|
||||
impl Modify for OpenAPIAuth {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(schema) = openapi.components.as_mut() {
|
||||
schema.add_security_scheme(
|
||||
"Token",
|
||||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description(
|
||||
"Authorization",
|
||||
"与配置文件中的 auth_token 相同",
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,6 @@ use thiserror::Error;
|
||||
pub enum InnerApiError {
|
||||
#[error("Primary key not found: {0}")]
|
||||
NotFound(i32),
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::{Expr, OnConflict};
|
||||
use sea_orm::{
|
||||
ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, Set, TransactionTrait, Unchanged,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::api::auth::OpenAPIAuth;
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::VideosRequest;
|
||||
use crate::api::response::{
|
||||
PageInfo, ResetVideoResponse, VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(get_video_sources, get_videos, get_video, reset_video),
|
||||
modifiers(&OpenAPIAuth),
|
||||
security(
|
||||
("Token" = []),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// 列出所有视频来源
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/video-sources",
|
||||
responses(
|
||||
(status = 200, body = ApiResponse<VideoSourcesResponse>),
|
||||
)
|
||||
)]
|
||||
pub async fn get_video_sources(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
|
||||
Ok(ApiResponse::ok(VideoSourcesResponse {
|
||||
collection: collection::Entity::find()
|
||||
.select_only()
|
||||
.columns([collection::Column::Id, collection::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref())
|
||||
.await?,
|
||||
favorite: favorite::Entity::find()
|
||||
.select_only()
|
||||
.columns([favorite::Column::Id, favorite::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref())
|
||||
.await?,
|
||||
submission: submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::Id)
|
||||
.column_as(submission::Column::UpperName, "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref())
|
||||
.await?,
|
||||
watch_later: watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column(watch_later::Column::Id)
|
||||
.column_as(Expr::value("稍后再看"), "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref())
|
||||
.await?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/videos",
|
||||
params(
|
||||
VideosRequest,
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ApiResponse<VideosResponse>),
|
||||
)
|
||||
)]
|
||||
pub async fn get_videos(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Query(params): Query<VideosRequest>,
|
||||
) -> Result<ApiResponse<VideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(params.collection, video::Column::CollectionId),
|
||||
(params.favorite, video::Column::FavoriteId),
|
||||
(params.submission, video::Column::SubmissionId),
|
||||
(params.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = params.query {
|
||||
query = query.filter(video::Column::Name.contains(query_word));
|
||||
}
|
||||
let total_count = query.clone().count(db.as_ref()).await?;
|
||||
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) {
|
||||
(page, page_size)
|
||||
} else {
|
||||
(1, 10)
|
||||
};
|
||||
Ok(ApiResponse::ok(VideosResponse {
|
||||
videos: query
|
||||
.order_by_desc(video::Column::Id)
|
||||
.select_only()
|
||||
.columns([
|
||||
video::Column::Id,
|
||||
video::Column::Name,
|
||||
video::Column::UpperName,
|
||||
video::Column::DownloadStatus,
|
||||
])
|
||||
.into_tuple::<(i32, String, String, u32)>()
|
||||
.paginate(db.as_ref(), page_size)
|
||||
.fetch_page(page)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(VideoInfo::from)
|
||||
.collect(),
|
||||
total_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 获取视频详细信息,包括关联的所有 page
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/videos/{id}",
|
||||
responses(
|
||||
(status = 200, body = ApiResponse<VideoResponse>),
|
||||
)
|
||||
)]
|
||||
pub async fn get_video(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
) -> Result<ApiResponse<VideoResponse>, ApiError> {
|
||||
let video_info = video::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.columns([
|
||||
video::Column::Id,
|
||||
video::Column::Name,
|
||||
video::Column::UpperName,
|
||||
video::Column::DownloadStatus,
|
||||
])
|
||||
.into_tuple::<(i32, String, String, u32)>()
|
||||
.one(db.as_ref())
|
||||
.await?
|
||||
.map(VideoInfo::from);
|
||||
let Some(video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Pid)
|
||||
.select_only()
|
||||
.columns([
|
||||
page::Column::Id,
|
||||
page::Column::Pid,
|
||||
page::Column::Name,
|
||||
page::Column::DownloadStatus,
|
||||
])
|
||||
.into_tuple::<(i32, i32, String, u32)>()
|
||||
.all(db.as_ref())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(PageInfo::from)
|
||||
.collect();
|
||||
Ok(ApiResponse::ok(VideoResponse {
|
||||
video: video_info,
|
||||
pages,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 将某个视频与其所有分页的失败状态清空为未下载状态,这样在下次下载任务中会触发重试
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/videos/{id}/reset",
|
||||
responses(
|
||||
(status = 200, body = ApiResponse<ResetVideoResponse> ),
|
||||
)
|
||||
)]
|
||||
pub async fn reset_video(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||
let txn = db.begin().await?;
|
||||
let video_status: Option<u32> = video::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(video::Column::DownloadStatus)
|
||||
.into_tuple()
|
||||
.one(&txn)
|
||||
.await?;
|
||||
let Some(video_status) = video_status else {
|
||||
return Err(anyhow!(InnerApiError::NotFound(id)).into());
|
||||
};
|
||||
let resetted_pages_model: Vec<_> = page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.all(&txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|mut model| {
|
||||
let mut page_status = PageStatus::from(model.download_status);
|
||||
if page_status.reset_failed() {
|
||||
model.download_status = page_status.into();
|
||||
Some(model)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut video_status = VideoStatus::from(video_status);
|
||||
let mut should_update_video = video_status.reset_failed();
|
||||
if !resetted_pages_model.is_empty() {
|
||||
// 视频状态标志的第 5 位表示是否有分 P 下载失败,如果有需要重置的分页,需要同时重置视频的该状态
|
||||
video_status.set(4, 0);
|
||||
should_update_video = true;
|
||||
}
|
||||
if should_update_video {
|
||||
video::Entity::update(video::ActiveModel {
|
||||
id: Unchanged(id),
|
||||
download_status: Set(video_status.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
}
|
||||
let resetted_pages_id: Vec<_> = resetted_pages_model.iter().map(|model| model.id).collect();
|
||||
let resetted_pages_model: Vec<page::ActiveModel> = resetted_pages_model
|
||||
.into_iter()
|
||||
.map(|model| model.into_active_model())
|
||||
.collect();
|
||||
for page_trunk in resetted_pages_model.chunks(50) {
|
||||
page::Entity::insert_many(page_trunk.to_vec())
|
||||
.on_conflict(
|
||||
OnConflict::column(page::Column::Id)
|
||||
.update_column(page::Column::DownloadStatus)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
Ok(ApiResponse::ok(ResetVideoResponse {
|
||||
resetted: should_update_video,
|
||||
video: id,
|
||||
pages: resetted_pages_id,
|
||||
}))
|
||||
}
|
||||
136
crates/bili_sync/src/api/helper.rs
Normal file
136
crates/bili_sync/src/api/helper.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use itertools::Itertools;
|
||||
use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction};
|
||||
|
||||
use crate::api::request::StatusFilter;
|
||||
use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo};
|
||||
use crate::utils::status::VideoStatus;
|
||||
|
||||
impl StatusFilter {
|
||||
pub fn to_video_query(&self) -> Condition {
|
||||
let query_builder = VideoStatus::query_builder();
|
||||
match self {
|
||||
Self::Failed => query_builder.failed(),
|
||||
Self::Succeeded => query_builder.succeeded(),
|
||||
Self::Waiting => query_builder.waiting(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait VideoRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
|
||||
pub trait PageRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
|
||||
impl VideoRecord for VideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoRecord for SimpleVideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for PageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for SimplePageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_video_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: VideoRecord,
|
||||
{
|
||||
if videos.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in videos.chunks(size) {
|
||||
execute_video_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_video_update_batch(txn, videos.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_page_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: PageRecord,
|
||||
{
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in pages.chunks(size) {
|
||||
execute_page_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_page_update_batch(txn, pages.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_video_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = videos.map(|v| format!("({}, {})", v.0, v.1)).join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
"WITH tempdata(id, download_status) AS (VALUES {}) \
|
||||
UPDATE video \
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE video.id = tempdata.id",
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_page_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = pages
|
||||
.map(|p| format!("({}, {})", p.0, p.1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
"WITH tempdata(id, download_status) AS (VALUES {}) \
|
||||
UPDATE page \
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE page.id = tempdata.id",
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod handler;
|
||||
|
||||
mod error;
|
||||
mod helper;
|
||||
mod request;
|
||||
mod response;
|
||||
mod routes;
|
||||
mod wrapper;
|
||||
|
||||
pub use routes::{LogHelper, MAX_HISTORY_LOGS, router};
|
||||
|
||||
@@ -1,13 +1,141 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
use crate::bilibili::CollectionType;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StatusFilter {
|
||||
Failed,
|
||||
Succeeded,
|
||||
Waiting,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VideosRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetVideoStatusRequest {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct StatusUpdate {
|
||||
#[validate(range(min = 0, max = 4))]
|
||||
pub status_index: usize,
|
||||
#[validate(custom(function = "crate::utils::validation::validate_status_value"))]
|
||||
pub status_value: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct PageStatusUpdate {
|
||||
pub page_id: i32,
|
||||
#[validate(nested)]
|
||||
pub updates: Vec<StatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateVideoStatusRequest {
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub video_updates: Vec<StatusUpdate>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub page_updates: Vec<PageStatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub video_updates: Vec<StatusUpdate>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub page_updates: Vec<StatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowedCollectionsRequest {
|
||||
pub page_num: Option<i32>,
|
||||
pub page_size: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowedUppersRequest {
|
||||
pub page_num: Option<i32>,
|
||||
pub page_size: Option<i32>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct InsertFavoriteRequest {
|
||||
pub fid: i64,
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct InsertCollectionRequest {
|
||||
pub sid: i64,
|
||||
pub mid: i64,
|
||||
#[serde(default)]
|
||||
pub collection_type: CollectionType,
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct InsertSubmissionRequest {
|
||||
pub upper_id: i64,
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVideoSourceRequest {
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub rule: Option<Rule>,
|
||||
pub use_dynamic_api: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct DefaultPathRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PollQrcodeRequest {
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use sea_orm::FromQueryResult;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::bilibili::{PollStatus, Qrcode};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoSourcesResponse {
|
||||
pub collection: Vec<VideoSource>,
|
||||
pub favorite: Vec<VideoSource>,
|
||||
@@ -12,65 +14,213 @@ pub struct VideoSourcesResponse {
|
||||
pub watch_later: Vec<VideoSource>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(Serialize)]
|
||||
pub struct VideosResponse {
|
||||
pub videos: Vec<VideoInfo>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoResponse {
|
||||
pub video: VideoInfo,
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(Serialize)]
|
||||
pub struct ResetVideoResponse {
|
||||
pub resetted: bool,
|
||||
pub video: i32,
|
||||
pub pages: Vec<i32>,
|
||||
pub video: VideoInfo,
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize, ToSchema)]
|
||||
#[derive(Serialize)]
|
||||
pub struct ClearAndResetVideoStatusResponse {
|
||||
pub warning: Option<String>,
|
||||
pub video: VideoInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResetFilteredVideosResponse {
|
||||
pub resetted: bool,
|
||||
pub resetted_videos_count: usize,
|
||||
pub resetted_pages_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateVideoStatusResponse {
|
||||
pub success: bool,
|
||||
pub video: VideoInfo,
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateFilteredVideoStatusResponse {
|
||||
pub success: bool,
|
||||
pub updated_videos_count: usize,
|
||||
pub updated_pages_count: usize,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize)]
|
||||
pub struct VideoSource {
|
||||
id: i32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PageInfo {
|
||||
pub id: i32,
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
pub download_status: [u32; 5],
|
||||
}
|
||||
|
||||
impl From<(i32, i32, String, u32)> for PageInfo {
|
||||
fn from((id, pid, name, download_status): (i32, i32, String, u32)) -> Self {
|
||||
Self {
|
||||
id,
|
||||
pid,
|
||||
name,
|
||||
download_status: PageStatus::from(download_status).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult)]
|
||||
#[sea_orm(entity = "video::Entity")]
|
||||
pub struct VideoInfo {
|
||||
pub id: i32,
|
||||
pub bvid: String,
|
||||
pub name: String,
|
||||
pub upper_name: String,
|
||||
pub download_status: [u32; 5],
|
||||
pub should_download: bool,
|
||||
#[serde(serialize_with = "serde_video_download_status")]
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
impl From<(i32, String, String, u32)> for VideoInfo {
|
||||
fn from((id, name, upper_name, download_status): (i32, String, String, u32)) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
upper_name,
|
||||
download_status: VideoStatus::from(download_status).into(),
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult)]
|
||||
#[sea_orm(entity = "page::Entity")]
|
||||
pub struct PageInfo {
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
#[serde(serialize_with = "serde_page_download_status")]
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "video::Entity")]
|
||||
pub struct SimpleVideoInfo {
|
||||
pub id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "page::Entity")]
|
||||
pub struct SimplePageInfo {
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
fn serde_video_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let status: [u32; 5] = VideoStatus::from(*status).into();
|
||||
status.serialize(serializer)
|
||||
}
|
||||
|
||||
fn serde_page_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let status: [u32; 5] = PageStatus::from(*status).into();
|
||||
status.serialize(serializer)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Followed {
|
||||
Favorite {
|
||||
title: String,
|
||||
media_count: i64,
|
||||
fid: i64,
|
||||
mid: i64,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
Collection {
|
||||
title: String,
|
||||
sid: i64,
|
||||
mid: i64,
|
||||
media_count: i64,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
Upper {
|
||||
mid: i64,
|
||||
uname: String,
|
||||
face: String,
|
||||
sign: String,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FavoritesResponse {
|
||||
pub favorites: Vec<Followed>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CollectionsResponse {
|
||||
pub collections: Vec<Followed>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UppersResponse {
|
||||
pub uppers: Vec<Followed>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoSourcesDetailsResponse {
|
||||
pub collections: Vec<VideoSourceDetail>,
|
||||
pub favorites: Vec<VideoSourceDetail>,
|
||||
pub submissions: Vec<VideoSourceDetail>,
|
||||
pub watch_later: Vec<VideoSourceDetail>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromQueryResult)]
|
||||
pub struct DayCountPair {
|
||||
pub day: String,
|
||||
pub cnt: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DashBoardResponse {
|
||||
pub enabled_favorites: u64,
|
||||
pub enabled_collections: u64,
|
||||
pub enabled_submissions: u64,
|
||||
pub enable_watch_later: bool,
|
||||
pub videos_by_day: Vec<DayCountPair>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
pub struct SysInfo {
|
||||
pub timestamp: i64,
|
||||
pub total_memory: u64,
|
||||
pub used_memory: u64,
|
||||
pub process_memory: u64,
|
||||
pub used_cpu: f32,
|
||||
pub process_cpu: f32,
|
||||
pub total_disk: u64,
|
||||
pub available_disk: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromQueryResult)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoSourceDetail {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub rule: Option<Rule>,
|
||||
#[serde(default)]
|
||||
pub rule_display: Option<String>,
|
||||
#[serde(default)]
|
||||
pub use_dynamic_api: Option<bool>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVideoSourceResponse {
|
||||
pub rule_display: Option<String>,
|
||||
}
|
||||
|
||||
pub type GenerateQrcodeResponse = Qrcode;
|
||||
|
||||
pub type PollQrcodeResponse = PollStatus;
|
||||
|
||||
47
crates/bili_sync/src/api/routes/config/mod.rs
Normal file
47
crates/bili_sync/src/api/routes/config/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::Extension;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::{Config, VersionedConfig};
|
||||
use crate::notifier::Notifier;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/config", get(get_config).put(update_config))
|
||||
.route("/config/notifiers/ping", post(ping_notifiers))
|
||||
}
|
||||
|
||||
/// 获取全局配置
|
||||
pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
Ok(ApiResponse::ok(VersionedConfig::get().snapshot()))
|
||||
}
|
||||
|
||||
/// 更新全局配置
|
||||
pub async fn update_config(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(config): ValidatedJson<Config>,
|
||||
) -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
config.check()?;
|
||||
let new_config = VersionedConfig::get().update(config, &db).await?;
|
||||
Ok(ApiResponse::ok(new_config))
|
||||
}
|
||||
|
||||
pub async fn ping_notifiers(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Json(mut notifier): Json<Notifier>,
|
||||
) -> Result<ApiResponse<()>, ApiError> {
|
||||
// 对于 webhook 类型的通知器测试,设置上 ignore_cache tag 以强制实时渲染
|
||||
if let Notifier::Webhook { ignore_cache, .. } = &mut notifier {
|
||||
*ignore_cache = Some(());
|
||||
}
|
||||
notifier
|
||||
.notify(bili_client.inner_client(), "This is a test notification from BiliSync.")
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(()))
|
||||
}
|
||||
65
crates/bili_sync/src/api/routes/dashboard/mod.rs
Normal file
65
crates/bili_sync/src/api/routes/dashboard/mod.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{FromQueryResult, Statement};
|
||||
|
||||
use crate::api::response::{DashBoardResponse, DayCountPair};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new().route("/dashboard", get(get_dashboard))
|
||||
}
|
||||
|
||||
async fn get_dashboard(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<DashBoardResponse>, ApiError> {
|
||||
let (enabled_favorites, enabled_collections, enabled_submissions, enabled_watch_later, videos_by_day) = tokio::try_join!(
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::Enabled.eq(true))
|
||||
.count(&db),
|
||||
collection::Entity::find()
|
||||
.filter(collection::Column::Enabled.eq(true))
|
||||
.count(&db),
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::Enabled.eq(true))
|
||||
.count(&db),
|
||||
watch_later::Entity::find()
|
||||
.filter(watch_later::Column::Enabled.eq(true))
|
||||
.count(&db),
|
||||
DayCountPair::find_by_statement(Statement::from_string(
|
||||
db.get_database_backend(),
|
||||
// 用 SeaORM 太复杂了,直接写个裸 SQL
|
||||
"
|
||||
SELECT
|
||||
dates.day AS day,
|
||||
COUNT(video.id) AS cnt
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
STRFTIME('%Y-%m-%d', DATE('now', '-' || n || ' days', 'localtime')) AS day,
|
||||
DATETIME(DATE('now', '-' || n || ' days', 'localtime'), 'utc') AS start_utc_datetime,
|
||||
DATETIME(DATE('now', '-' || n || ' days', '+1 day', 'localtime'), 'utc') AS end_utc_datetime
|
||||
FROM
|
||||
(
|
||||
SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
|
||||
)
|
||||
) AS dates
|
||||
LEFT JOIN
|
||||
video ON video.created_at >= dates.start_utc_datetime AND video.created_at < dates.end_utc_datetime
|
||||
GROUP BY
|
||||
dates.day
|
||||
ORDER BY
|
||||
dates.day;
|
||||
"
|
||||
))
|
||||
.all(&db),
|
||||
)?;
|
||||
Ok(ApiResponse::ok(DashBoardResponse {
|
||||
enabled_favorites,
|
||||
enabled_collections,
|
||||
enabled_submissions,
|
||||
enable_watch_later: enabled_watch_later > 0,
|
||||
videos_by_day,
|
||||
}))
|
||||
}
|
||||
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal file
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use crate::api::request::PollQrcodeRequest;
|
||||
use crate::api::response::{GenerateQrcodeResponse, PollQrcodeResponse};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::bilibili::{BiliClient, Credential};
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/login/qrcode/generate", post(generate_qrcode))
|
||||
.route("/login/qrcode/poll", get(poll_qrcode))
|
||||
}
|
||||
|
||||
/// 生成扫码登录二维码
|
||||
pub async fn generate_qrcode(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
) -> Result<ApiResponse<GenerateQrcodeResponse>, ApiError> {
|
||||
Ok(ApiResponse::ok(Credential::generate_qrcode(&bili_client.client).await?))
|
||||
}
|
||||
|
||||
/// 轮询扫码登录状态
|
||||
pub async fn poll_qrcode(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<PollQrcodeRequest>,
|
||||
) -> Result<ApiResponse<PollQrcodeResponse>, ApiError> {
|
||||
Ok(ApiResponse::ok(
|
||||
Credential::poll_qrcode(&bili_client.client, ¶ms.qrcode_key).await?,
|
||||
))
|
||||
}
|
||||
189
crates/bili_sync/src/api/routes/me/mod.rs
Normal file
189
crates/bili_sync/src/api/routes/me/mod.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::routing::get;
|
||||
use bili_sync_entity::*;
|
||||
use itertools::{Either, Itertools};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect};
|
||||
|
||||
use crate::api::request::{FollowedCollectionsRequest, FollowedUppersRequest};
|
||||
use crate::api::response::{CollectionsResponse, FavoritesResponse, Followed, UppersResponse};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::bilibili::{BiliClient, Me};
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/me/favorites", get(get_created_favorites))
|
||||
.route("/me/collections", get(get_followed_collections))
|
||||
.route("/me/uppers", get(get_followed_uppers))
|
||||
}
|
||||
|
||||
/// 获取当前用户创建的收藏夹
|
||||
pub async fn get_created_favorites(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
) -> Result<ApiResponse<FavoritesResponse>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let bili_favorites = me.get_created_favorites().await?;
|
||||
|
||||
let favorites = if let Some(bili_favorites) = bili_favorites {
|
||||
// b 站收藏夹相关接口使用的所谓“fid”其实是该处的 id,即 fid + mid 后两位
|
||||
let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect();
|
||||
let subscribed_fids: HashSet<i64> = favorite::Entity::find()
|
||||
.select_only()
|
||||
.column(favorite::Column::FId)
|
||||
.filter(favorite::Column::FId.is_in(bili_fids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
bili_favorites
|
||||
.into_iter()
|
||||
.map(|fav| Followed::Favorite {
|
||||
title: fav.title,
|
||||
media_count: fav.media_count,
|
||||
// api 返回的 id 才是真实的 fid
|
||||
fid: fav.id,
|
||||
mid: fav.mid,
|
||||
invalid: false,
|
||||
subscribed: subscribed_fids.contains(&fav.id),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok(FavoritesResponse { favorites }))
|
||||
}
|
||||
|
||||
/// 获取当前用户收藏的合集/收藏夹
|
||||
pub async fn get_followed_collections(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedCollectionsRequest>,
|
||||
) -> Result<ApiResponse<CollectionsResponse>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(50));
|
||||
let bili_collections = me.get_followed_collections(page_num, page_size).await?;
|
||||
|
||||
let collections = if let Some(collection_list) = bili_collections.list {
|
||||
// collection_list 中的条目可能是合集或者收藏夹,需要分类处理
|
||||
// 目前看下来,最显著的区别是合集的 fid 是 0
|
||||
let (bili_fids, bili_sids): (Vec<_>, Vec<_>) = collection_list.iter().partition_map(|col| {
|
||||
if col.fid != 0 {
|
||||
Either::Left(col.id)
|
||||
} else {
|
||||
Either::Right(col.id)
|
||||
}
|
||||
});
|
||||
let (subscribed_fids, subscribed_sids): (HashSet<i64>, HashSet<i64>) = tokio::try_join!(
|
||||
async {
|
||||
Result::<_, anyhow::Error>::Ok(
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.column(favorite::Column::FId)
|
||||
.filter(favorite::Column::FId.is_in(bili_fids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
},
|
||||
async {
|
||||
Ok(collection::Entity::find()
|
||||
.select_only()
|
||||
.column(collection::Column::SId)
|
||||
.filter(collection::Column::SId.is_in(bili_sids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
)?;
|
||||
collection_list
|
||||
.into_iter()
|
||||
.map(|col| {
|
||||
if col.fid != 0 {
|
||||
Followed::Favorite {
|
||||
title: col.title,
|
||||
media_count: col.media_count,
|
||||
fid: col.id,
|
||||
mid: col.mid,
|
||||
invalid: col.state == 1,
|
||||
subscribed: subscribed_fids.contains(&col.id),
|
||||
}
|
||||
} else {
|
||||
Followed::Collection {
|
||||
title: col.title,
|
||||
sid: col.id,
|
||||
mid: col.mid,
|
||||
media_count: col.media_count,
|
||||
invalid: col.state == 1,
|
||||
subscribed: subscribed_sids.contains(&col.id),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok(CollectionsResponse {
|
||||
collections,
|
||||
total: bili_collections.count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 获取当前用户关注的 UP 主
|
||||
pub async fn get_followed_uppers(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedUppersRequest>,
|
||||
) -> Result<ApiResponse<UppersResponse>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(20));
|
||||
let bili_uppers = me
|
||||
.get_followed_uppers(page_num, page_size, params.name.as_deref())
|
||||
.await?;
|
||||
|
||||
let bili_uid: Vec<_> = bili_uppers.list.iter().map(|upper| upper.mid).collect();
|
||||
|
||||
let subscribed_ids: Vec<i64> = submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::UpperId)
|
||||
.filter(submission::Column::UpperId.is_in(bili_uid))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
|
||||
|
||||
let uppers = bili_uppers
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|upper| Followed::Upper {
|
||||
mid: upper.mid,
|
||||
// 官方没有提供字段,但是可以使用这种方式简单判断下
|
||||
invalid: upper.uname == "账号已注销" && upper.face == "https://i0.hdslb.com/bfs/face/member/noface.jpg",
|
||||
uname: upper.uname,
|
||||
face: upper.face,
|
||||
sign: upper.sign,
|
||||
subscribed: subscribed_set.contains(&upper.mid),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ApiResponse::ok(UppersResponse {
|
||||
uppers,
|
||||
total: bili_uppers.total,
|
||||
}))
|
||||
}
|
||||
62
crates/bili_sync/src/api/routes/mod.rs
Normal file
62
crates/bili_sync/src/api/routes/mod.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Router, middleware};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::api::wrapper::ApiResponse;
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
mod config;
|
||||
mod dashboard;
|
||||
mod login;
|
||||
mod me;
|
||||
mod task;
|
||||
mod video_sources;
|
||||
mod videos;
|
||||
mod ws;
|
||||
|
||||
pub use ws::{LogHelper, MAX_HISTORY_LOGS};
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new().nest(
|
||||
"/api",
|
||||
config::router()
|
||||
.merge(me::router())
|
||||
.merge(login::router())
|
||||
.merge(video_sources::router())
|
||||
.merge(videos::router())
|
||||
.merge(dashboard::router())
|
||||
.merge(ws::router())
|
||||
.merge(task::router())
|
||||
.layer(middleware::from_fn(auth)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 中间件:使用 auth token 对请求进行身份验证
|
||||
pub async fn auth(mut headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||
let config = VersionedConfig::get().read();
|
||||
let token = config.auth_token.as_str();
|
||||
if headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|s| s == token)
|
||||
{
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
if let Some(protocol) = headers.remove("Sec-WebSocket-Protocol")
|
||||
&& protocol
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| BASE64_URL_SAFE_NO_PAD.decode(s).ok())
|
||||
.is_some_and(|s| s == token.as_bytes())
|
||||
{
|
||||
let mut resp = next.run(request).await;
|
||||
resp.headers_mut().insert("Sec-WebSocket-Protocol", protocol);
|
||||
return Ok(resp);
|
||||
}
|
||||
Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response())
|
||||
}
|
||||
15
crates/bili_sync/src/api/routes/task/mod.rs
Normal file
15
crates/bili_sync/src/api/routes/task/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::routing::post;
|
||||
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::task::DownloadTaskManager;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new().route("/task/download", post(new_download_task))
|
||||
}
|
||||
|
||||
pub async fn new_download_task() -> Result<ApiResponse<bool>, ApiError> {
|
||||
DownloadTaskManager::get().download_once().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
434
crates/bili_sync/src/api/routes/video_sources/mod.rs
Normal file
434
crates/bili_sync/src/api/routes/video_sources/mod.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::routing::{get, post, put};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::Expr;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum};
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::{
|
||||
DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest,
|
||||
UpdateVideoSourceRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
use crate::config::{PathSafeTemplate, TEMPLATE, VersionedConfig};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/video-sources", get(get_video_sources))
|
||||
.route("/video-sources/details", get(get_video_sources_details))
|
||||
.route(
|
||||
"/video-sources/{type}/default-path",
|
||||
get(get_video_sources_default_path),
|
||||
) // 仅用于前端获取默认路径
|
||||
.route(
|
||||
"/video-sources/{type}/{id}",
|
||||
put(update_video_source).delete(remove_video_source),
|
||||
)
|
||||
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
|
||||
.route("/video-sources/favorites", post(insert_favorite))
|
||||
.route("/video-sources/collections", post(insert_collection))
|
||||
.route("/video-sources/submissions", post(insert_submission))
|
||||
}
|
||||
|
||||
/// 列出所有视频来源
|
||||
pub async fn get_video_sources(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
|
||||
let (collection, favorite, submission, mut watch_later) = tokio::try_join!(
|
||||
collection::Entity::find()
|
||||
.select_only()
|
||||
.columns([collection::Column::Id, collection::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(&db),
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.columns([favorite::Column::Id, favorite::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(&db),
|
||||
submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::Id)
|
||||
.column_as(submission::Column::UpperName, "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(&db),
|
||||
watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column(watch_later::Column::Id)
|
||||
.column_as(Expr::value("稍后再看"), "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(&db)
|
||||
)?;
|
||||
// watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项
|
||||
if watch_later.is_empty() {
|
||||
watch_later.push(VideoSource {
|
||||
id: 1,
|
||||
name: "稍后再看".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(ApiResponse::ok(VideoSourcesResponse {
|
||||
collection,
|
||||
favorite,
|
||||
submission,
|
||||
watch_later,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 获取视频来源详情
|
||||
pub async fn get_video_sources_details(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
|
||||
let (mut collections, mut favorites, mut submissions, mut watch_later) = tokio::try_join!(
|
||||
collection::Entity::find()
|
||||
.select_only()
|
||||
.columns([
|
||||
collection::Column::Id,
|
||||
collection::Column::Name,
|
||||
collection::Column::Path,
|
||||
collection::Column::Rule,
|
||||
collection::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db),
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.columns([
|
||||
favorite::Column::Id,
|
||||
favorite::Column::Name,
|
||||
favorite::Column::Path,
|
||||
favorite::Column::Rule,
|
||||
favorite::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db),
|
||||
submission::Entity::find()
|
||||
.select_only()
|
||||
.column_as(submission::Column::UpperName, "name")
|
||||
.columns([
|
||||
submission::Column::Id,
|
||||
submission::Column::Path,
|
||||
submission::Column::Enabled,
|
||||
submission::Column::Rule,
|
||||
submission::Column::UseDynamicApi
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db),
|
||||
watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column_as(Expr::value("稍后再看"), "name")
|
||||
.columns([
|
||||
watch_later::Column::Id,
|
||||
watch_later::Column::Path,
|
||||
watch_later::Column::Enabled,
|
||||
watch_later::Column::Rule
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db)
|
||||
)?;
|
||||
if watch_later.is_empty() {
|
||||
watch_later.push(VideoSourceDetail {
|
||||
id: 1,
|
||||
name: "稍后再看".to_string(),
|
||||
path: String::new(),
|
||||
rule: None,
|
||||
rule_display: None,
|
||||
use_dynamic_api: None,
|
||||
enabled: false,
|
||||
})
|
||||
}
|
||||
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
|
||||
sources.iter_mut().for_each(|item| {
|
||||
if let Some(rule) = &item.rule {
|
||||
item.rule_display = Some(rule.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
||||
collections,
|
||||
favorites,
|
||||
submissions,
|
||||
watch_later,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_video_sources_default_path(
|
||||
Path(source_type): Path<String>,
|
||||
Query(params): Query<DefaultPathRequest>,
|
||||
) -> Result<ApiResponse<String>, ApiError> {
|
||||
let template_name = match source_type.as_str() {
|
||||
"favorites" => "favorite_default_path",
|
||||
"collections" => "collection_default_path",
|
||||
"submissions" => "submission_default_path",
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let template = TEMPLATE.read();
|
||||
Ok(ApiResponse::ok(
|
||||
template.path_safe_render(template_name, &serde_json::to_value(params)?)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 更新视频来源
|
||||
pub async fn update_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
|
||||
) -> Result<ApiResponse<UpdateVideoSourceResponse>, ApiError> {
|
||||
let rule_display = request.rule.as_ref().map(|rule| rule.to_string());
|
||||
let active_model = match source_type.as_str() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: collection::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Collection(active_model)
|
||||
}),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: favorite::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Favorite(active_model)
|
||||
}),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: submission::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
if let Some(use_dynamic_api) = request.use_dynamic_api {
|
||||
active_model.use_dynamic_api = Set(use_dynamic_api);
|
||||
}
|
||||
_ActiveModel::Submission(active_model)
|
||||
}),
|
||||
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
|
||||
// 稍后再看需要做特殊处理,get 时如果稍后再看不存在返回的是 id 为 1 的假记录
|
||||
// 因此此处可能是更新也可能是插入,做个额外的处理
|
||||
Some(model) => {
|
||||
// 如果有记录,使用 id 对应的记录更新
|
||||
let mut active_model: watch_later::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
Some(_ActiveModel::WatchLater(active_model))
|
||||
}
|
||||
None => {
|
||||
if id != 1 {
|
||||
None
|
||||
} else {
|
||||
// 如果没有记录且 id 为 1,插入一个新的稍后再看记录
|
||||
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
|
||||
path: Set(request.path),
|
||||
enabled: Set(request.enabled),
|
||||
rule: Set(request.rule),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let Some(active_model) = active_model else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
active_model.save(&db).await?;
|
||||
Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display }))
|
||||
}
|
||||
|
||||
pub async fn remove_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
// 不允许删除稍后再看
|
||||
let video_source: Option<VideoSourceEnum> = match source_type.as_str() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let Some(video_source) = video_source else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let txn = db.begin().await?;
|
||||
page::Entity::delete_many()
|
||||
.filter(
|
||||
page::Column::VideoId.in_subquery(
|
||||
video::Entity::find()
|
||||
.filter(video_source.filter_expr())
|
||||
.select_only()
|
||||
.column(video::Column::Id)
|
||||
.as_query()
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
video::Entity::delete_many()
|
||||
.filter(video_source.filter_expr())
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
video_source.delete_from_db(&txn).await?;
|
||||
txn.commit().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
pub async fn evaluate_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
// 找出对应 source 的规则与 video 筛选条件
|
||||
let (rule, filter_condition) = match source_type.as_str() {
|
||||
"collections" => (
|
||||
collection::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(collection::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::CollectionId.eq(id),
|
||||
),
|
||||
"favorites" => (
|
||||
favorite::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(favorite::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::FavoriteId.eq(id),
|
||||
),
|
||||
"submissions" => (
|
||||
submission::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(submission::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::SubmissionId.eq(id),
|
||||
),
|
||||
"watch_later" => (
|
||||
watch_later::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(watch_later::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::WatchLaterId.eq(id),
|
||||
),
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let videos: Vec<(video::Model, Vec<page::Model>)> = video::Entity::find()
|
||||
.filter(filter_condition)
|
||||
.find_with_related(page::Entity)
|
||||
.all(&db)
|
||||
.await?;
|
||||
let video_should_download_pairs = videos
|
||||
.into_iter()
|
||||
.map(|(video, pages)| (video.id, rule.evaluate_model(&video, &pages)))
|
||||
.collect::<Vec<(i32, bool)>>();
|
||||
let txn = db.begin().await?;
|
||||
for chunk in video_should_download_pairs.chunks(500) {
|
||||
let sql = format!(
|
||||
"WITH tempdata(id, should_download) AS (VALUES {}) \
|
||||
UPDATE video \
|
||||
SET should_download = tempdata.should_download \
|
||||
FROM tempdata \
|
||||
WHERE video.id = tempdata.id",
|
||||
chunk
|
||||
.iter()
|
||||
.map(|item| format!("({}, {})", item.0, item.1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
/// 新增收藏夹订阅
|
||||
pub async fn insert_favorite(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string(), credential);
|
||||
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(request.path),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
/// 新增合集/列表订阅
|
||||
pub async fn insert_collection(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let collection = Collection::new(
|
||||
bili_client.as_ref(),
|
||||
CollectionItem {
|
||||
sid: request.sid.to_string(),
|
||||
mid: request.mid.to_string(),
|
||||
collection_type: request.collection_type,
|
||||
},
|
||||
credential,
|
||||
);
|
||||
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(request.path),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
.await?;
|
||||
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
/// 新增投稿订阅
|
||||
pub async fn insert_submission(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string(), credential);
|
||||
let upper = submission.get_info().await?;
|
||||
submission::Entity::insert(submission::ActiveModel {
|
||||
upper_id: Set(upper.mid.parse()?),
|
||||
upper_name: Set(upper.name),
|
||||
path: Set(request.path),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
400
crates/bili_sync/src/api/routes/videos/mod.rs
Normal file
400
crates/bili_sync/src/api/routes/videos/mod.rs
Normal file
@@ -0,0 +1,400 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, TransactionTrait, TryIntoModel,
|
||||
};
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
||||
use crate::api::request::{
|
||||
ResetFilteredVideoStatusRequest, ResetVideoStatusRequest, UpdateFilteredVideoStatusRequest,
|
||||
UpdateVideoStatusRequest, VideosRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
ClearAndResetVideoStatusResponse, PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo,
|
||||
SimpleVideoInfo, UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
||||
VideosResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/videos", get(get_videos))
|
||||
.route("/videos/{id}", get(get_video))
|
||||
.route(
|
||||
"/videos/{id}/clear-and-reset-status",
|
||||
post(clear_and_reset_video_status),
|
||||
)
|
||||
.route("/videos/{id}/reset-status", post(reset_video_status))
|
||||
.route("/videos/{id}/update-status", post(update_video_status))
|
||||
.route("/videos/reset-status", post(reset_filtered_video_status))
|
||||
.route("/videos/update-status", post(update_filtered_video_status))
|
||||
}
|
||||
|
||||
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
|
||||
pub async fn get_videos(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Query(params): Query<VideosRequest>,
|
||||
) -> Result<ApiResponse<VideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(params.collection, video::Column::CollectionId),
|
||||
(params.favorite, video::Column::FavoriteId),
|
||||
(params.submission, video::Column::SubmissionId),
|
||||
(params.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = params.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.or(video::Column::Bvid.contains(query_word)),
|
||||
);
|
||||
}
|
||||
if let Some(status_filter) = params.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
let total_count = query.clone().count(&db).await?;
|
||||
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) {
|
||||
(page, page_size)
|
||||
} else {
|
||||
(0, 10)
|
||||
};
|
||||
Ok(ApiResponse::ok(VideosResponse {
|
||||
videos: query
|
||||
.order_by_desc(video::Column::Id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.paginate(&db, page_size)
|
||||
.fetch_page(page)
|
||||
.await?,
|
||||
total_count,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_video(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
Ok(ApiResponse::ok(VideoResponse {
|
||||
video: video_info,
|
||||
pages: pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(mut video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let resetted_pages_info = pages_info
|
||||
.into_iter()
|
||||
.filter_map(|mut page_info| {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
if (request.force && page_status.force_reset_failed()) || page_status.reset_failed() {
|
||||
page_info.download_status = page_status.into();
|
||||
Some(page_info)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
let mut video_resetted = (request.force && video_status.force_reset_failed()) || video_status.reset_failed();
|
||||
if !resetted_pages_info.is_empty() {
|
||||
video_status.set(4, 0); // 将“分页下载”重置为 0
|
||||
video_resetted = true;
|
||||
}
|
||||
let resetted_videos_info = if video_resetted {
|
||||
video_info.download_status = video_status.into();
|
||||
vec![&video_info]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let resetted = !resetted_videos_info.is_empty() || !resetted_pages_info.is_empty();
|
||||
if resetted {
|
||||
let txn = db.begin().await?;
|
||||
if !resetted_videos_info.is_empty() {
|
||||
// 只可能有 1 个元素,所以不用 batch
|
||||
update_video_download_status::<VideoInfo>(&txn, &resetted_videos_info, None).await?;
|
||||
}
|
||||
if !resetted_pages_info.is_empty() {
|
||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(ResetVideoResponse {
|
||||
resetted,
|
||||
video: video_info,
|
||||
pages: resetted_pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn clear_and_reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<ClearAndResetVideoStatusResponse>, ApiError> {
|
||||
let video_info = video::Entity::find_by_id(id).one(&db).await?;
|
||||
let Some(video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let txn = db.begin().await?;
|
||||
let mut video_info = video_info.into_active_model();
|
||||
video_info.single_page = Set(None);
|
||||
video_info.download_status = Set(0);
|
||||
let video_info = video_info.update(&txn).await?;
|
||||
page::Entity::delete_many()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
let video_info = video_info.try_into_model()?;
|
||||
let warning = tokio::fs::remove_dir_all(&video_info.path)
|
||||
.await
|
||||
.context(format!("删除本地路径「{}」失败", video_info.path))
|
||||
.err()
|
||||
.map(|e| format!("{:#}", e));
|
||||
Ok(ApiResponse::ok(ClearAndResetVideoStatusResponse {
|
||||
warning,
|
||||
video: VideoInfo {
|
||||
id: video_info.id,
|
||||
bvid: video_info.bvid,
|
||||
name: video_info.name,
|
||||
upper_name: video_info.upper_name,
|
||||
should_download: video_info.should_download,
|
||||
download_status: video_info.download_status,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetFilteredVideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.or(video::Column::Bvid.contains(query_word)),
|
||||
);
|
||||
}
|
||||
if let Some(status_filter) = request.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
let all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let resetted_pages_info = all_pages
|
||||
.into_iter()
|
||||
.filter_map(|mut page_info| {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
if (request.force && page_status.force_reset_failed()) || page_status.reset_failed() {
|
||||
page_info.download_status = page_status.into();
|
||||
Some(page_info)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let video_ids_with_resetted_pages: HashSet<i32> = resetted_pages_info.iter().map(|page| page.video_id).collect();
|
||||
let resetted_videos_info = all_videos
|
||||
.into_iter()
|
||||
.filter_map(|mut video_info| {
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
let mut video_resetted =
|
||||
(request.force && video_status.force_reset_failed()) || video_status.reset_failed();
|
||||
if video_ids_with_resetted_pages.contains(&video_info.id) {
|
||||
video_status.set(4, 0); // 将"分页下载"重置为 0
|
||||
video_resetted = true;
|
||||
}
|
||||
if video_resetted {
|
||||
video_info.download_status = video_status.into();
|
||||
Some(video_info)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let has_video_updates = !resetted_videos_info.is_empty();
|
||||
let has_page_updates = !resetted_pages_info.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &resetted_videos_info, Some(500)).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(ResetFilteredVideosResponse {
|
||||
resetted: has_video_updates || has_page_updates,
|
||||
resetted_videos_count: resetted_videos_info.len(),
|
||||
resetted_pages_count: resetted_pages_info.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<UpdateVideoStatusResponse>, ApiError> {
|
||||
let (video_info, mut pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(mut video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
for update in &request.video_updates {
|
||||
video_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
video_info.download_status = video_status.into();
|
||||
let mut updated_pages_info = Vec::new();
|
||||
let mut page_id_map = pages_info
|
||||
.iter_mut()
|
||||
.map(|page| (page.id, page))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
for page_update in &request.page_updates {
|
||||
if let Some(page_info) = page_id_map.remove(&page_update.page_id) {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
for update in &page_update.updates {
|
||||
page_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
page_info.download_status = page_status.into();
|
||||
updated_pages_info.push(page_info);
|
||||
}
|
||||
}
|
||||
let has_video_updates = !request.video_updates.is_empty();
|
||||
let has_page_updates = !updated_pages_info.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status::<VideoInfo>(&txn, &[&video_info], None).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status::<PageInfo>(&txn, &updated_pages_info, None).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(UpdateVideoStatusResponse {
|
||||
success: has_video_updates || has_page_updates,
|
||||
video: video_info,
|
||||
pages: pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<UpdateFilteredVideoStatusResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.or(video::Column::Bvid.contains(query_word)),
|
||||
);
|
||||
}
|
||||
if let Some(status_filter) = request.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
let mut all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let mut all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
for video_info in all_videos.iter_mut() {
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
for update in &request.video_updates {
|
||||
video_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
video_info.download_status = video_status.into();
|
||||
}
|
||||
for page_info in all_pages.iter_mut() {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
for update in &request.page_updates {
|
||||
page_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
page_info.download_status = page_status.into();
|
||||
}
|
||||
let has_video_updates = !all_videos.is_empty();
|
||||
let has_page_updates = !all_pages.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &all_videos, Some(500)).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &all_pages, Some(500)).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(UpdateFilteredVideoStatusResponse {
|
||||
success: has_video_updates || has_page_updates,
|
||||
updated_videos_count: all_videos.len(),
|
||||
updated_pages_count: all_pages.len(),
|
||||
}))
|
||||
}
|
||||
54
crates/bili_sync/src/api/routes/ws/log_helper.rs
Normal file
54
crates/bili_sync/src/api/routes/ws/log_helper.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub const MAX_HISTORY_LOGS: usize = 200;
|
||||
|
||||
/// LogHelper 维护了日志发送器和一个日志历史记录的缓冲区
|
||||
pub struct LogHelper {
|
||||
pub sender: broadcast::Sender<String>,
|
||||
pub log_history: Arc<RwLock<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LogHelper {
|
||||
pub fn new(sender: broadcast::Sender<String>, log_history: Arc<RwLock<VecDeque<String>>>) -> Self {
|
||||
LogHelper { sender, log_history }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for LogHelper {
|
||||
type Writer = Self;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for LogHelper {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let log_message = String::from_utf8_lossy(buf).to_string();
|
||||
let _ = self.sender.send(log_message.clone());
|
||||
let mut history = self.log_history.write();
|
||||
history.push_back(log_message);
|
||||
if history.len() > MAX_HISTORY_LOGS {
|
||||
history.pop_front();
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for LogHelper {
|
||||
fn clone(&self) -> Self {
|
||||
LogHelper {
|
||||
sender: self.sender.clone(),
|
||||
log_history: self.log_history.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
338
crates/bili_sync/src/api/routes/ws/mod.rs
Normal file
338
crates/bili_sync/src/api/routes/ws/mod.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
mod log_helper;
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::WebSocketUpgrade;
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::any;
|
||||
use axum::{Extension, Router};
|
||||
use dashmap::DashMap;
|
||||
use futures::stream::{SplitSink, SplitStream};
|
||||
use futures::{SinkExt, StreamExt, future};
|
||||
use itertools::Itertools;
|
||||
pub use log_helper::{LogHelper, MAX_HISTORY_LOGS};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sysinfo::{
|
||||
CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System,
|
||||
get_current_pid,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::{pin, select};
|
||||
use tokio_stream::wrappers::{BroadcastStream, WatchStream};
|
||||
use tokio_util::future::FutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::response::SysInfo;
|
||||
use crate::task::{DownloadTaskManager, TaskStatus};
|
||||
|
||||
static WEBSOCKET_HANDLER: LazyLock<WebSocketHandler> = LazyLock::new(WebSocketHandler::new);
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new().route("/ws", any(websocket_handler))
|
||||
}
|
||||
|
||||
async fn websocket_handler(ws: WebSocketUpgrade, Extension(log_writer): Extension<LogHelper>) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_socket(socket, log_writer))
|
||||
}
|
||||
|
||||
// 事件类型枚举
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum EventType {
|
||||
Logs,
|
||||
Tasks,
|
||||
SysInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum ClientEvent {
|
||||
Subscribe(EventType),
|
||||
Unsubscribe(EventType),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum ServerEvent {
|
||||
Logs(String),
|
||||
Tasks(TaskStatus),
|
||||
SysInfo(SysInfo),
|
||||
}
|
||||
|
||||
struct WebSocketHandler {
|
||||
sysinfo_subscribers: Arc<DashMap<Uuid, mpsc::Sender<ServerEvent>>>,
|
||||
sysinfo_cancel: RwLock<Option<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl WebSocketHandler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sysinfo_subscribers: Arc::new(DashMap::new()),
|
||||
sysinfo_cancel: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 向客户端推送信息
|
||||
async fn handle_sender(&self, mut sender: SplitSink<WebSocket, Message>, mut rx: mpsc::Receiver<ServerEvent>) {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let text = match serde_json::to_string(&event) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!("Failed to serialize event: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = sender.send(Message::Text(text.into())).await {
|
||||
error!("Failed to send message: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从客户端接收信息
|
||||
async fn handle_receiver(
|
||||
&self,
|
||||
mut receiver: SplitStream<WebSocket>,
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
uuid: Uuid,
|
||||
log_writer: LogHelper,
|
||||
) {
|
||||
// 日志和任务状态的处理本身就是由 stream 驱动的,可以直接为每个 ws 连接维护独立的任务处理器
|
||||
// 系统信息是服务端轮询然后推送的,如果单独维护会导致每个连接都独立轮询系统信息,造成不必要的浪费
|
||||
// 因此采用了全局的订阅者管理,所有连接共享同一个系统信息轮询任务
|
||||
let (mut log_cancel, mut task_cancel) = (None, None);
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
let Message::Text(text) = msg else {
|
||||
continue;
|
||||
};
|
||||
let client_event = match serde_json::from_str::<ClientEvent>(&text) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
error!("Failed to parse client message: {:?}, error: {:?}", text, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match client_event {
|
||||
ClientEvent::Subscribe(EventType::Logs) => {
|
||||
if log_cancel.is_none() {
|
||||
log_cancel = Some(self.new_log_handler(tx.clone(), &log_writer));
|
||||
}
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::Logs) => {
|
||||
if let Some(cancel) = log_cancel.take() {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
ClientEvent::Subscribe(EventType::Tasks) => {
|
||||
if task_cancel.is_none() {
|
||||
task_cancel = Some(self.new_task_handler(tx.clone()));
|
||||
}
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::Tasks) => {
|
||||
if let Some(cancel) = task_cancel.take() {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
ClientEvent::Subscribe(EventType::SysInfo) => {
|
||||
self.add_sysinfo_subscriber(uuid, tx.clone());
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::SysInfo) => {
|
||||
self.remove_sysinfo_subscriber(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 连接关闭,清除仍然残留的任务
|
||||
if let Some(cancel) = log_cancel {
|
||||
cancel.cancel();
|
||||
}
|
||||
if let Some(cancel) = task_cancel {
|
||||
cancel.cancel();
|
||||
}
|
||||
self.remove_sysinfo_subscriber(uuid);
|
||||
}
|
||||
|
||||
/// 添加全局系统信息订阅者
|
||||
fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: mpsc::Sender<ServerEvent>) {
|
||||
self.sysinfo_subscribers.insert(uuid, sender);
|
||||
if self.sysinfo_cancel.read().is_none() {
|
||||
let mut sys_info_cancel = self.sysinfo_cancel.write();
|
||||
if sys_info_cancel.is_some() {
|
||||
return;
|
||||
}
|
||||
*sys_info_cancel = Some(self.new_sysinfo_handler(self.sysinfo_subscribers.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除全局系统信息订阅者
|
||||
fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
|
||||
self.sysinfo_subscribers.remove(&uuid);
|
||||
if self.sysinfo_subscribers.is_empty()
|
||||
&& let Some(token) = self.sysinfo_cancel.write().take()
|
||||
{
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建异步日志推送任务,返回任务的取消令牌
|
||||
fn new_log_handler(&self, tx: mpsc::Sender<ServerEvent>, log_writer: &LogHelper) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
// 读取历史日志
|
||||
let history = log_writer.log_history.read();
|
||||
let history_logs = history.iter().cloned().collect::<Vec<String>>();
|
||||
drop(history);
|
||||
// 获取日志广播接收器
|
||||
let log_rx = log_writer.sender.subscribe();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
// 合并历史日志和实时日志流
|
||||
let log_stream = futures::stream::iter(history_logs)
|
||||
.chain(BroadcastStream::new(log_rx).filter_map(async |msg| msg.ok()))
|
||||
.map(ServerEvent::Logs);
|
||||
pin!(log_stream);
|
||||
while let Some(event) = log_stream.next().await {
|
||||
if let Err(e) = tx.send(event).await {
|
||||
error!("Failed to send log event: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
cancel_token
|
||||
}
|
||||
|
||||
/// 创建异步任务状态推送任务,返回任务的取消令牌
|
||||
fn new_task_handler(&self, tx: mpsc::Sender<ServerEvent>) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
let mut stream = WatchStream::new(DownloadTaskManager::get().subscribe()).map(ServerEvent::Tasks);
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Err(e) = tx.send(event).await {
|
||||
error!("Failed to send task status: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
cancel_token
|
||||
}
|
||||
|
||||
/// 创建异步系统信息推送任务,返回任务的取消令牌
|
||||
fn new_sysinfo_handler(
|
||||
&self,
|
||||
sysinfo_subscribers: Arc<DashMap<Uuid, mpsc::Sender<ServerEvent>>>,
|
||||
) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
tokio::spawn(async move {
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
let (tick_tx, mut tick_rx) = mpsc::channel(3);
|
||||
// 在阻塞线程中轮询系统信息,防止阻塞异步运行时
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// 对于 linux/mac/windows 平台,该方法永远返回 Some(pid),expect 基本是安全的
|
||||
let self_pid = get_current_pid().expect("Unsupported platform");
|
||||
let mut system = System::new();
|
||||
let mut disks = Disks::new();
|
||||
while tick_rx.blocking_recv().is_some() {
|
||||
system.refresh_needed(self_pid);
|
||||
disks.refresh_needed(self_pid);
|
||||
let process = match system.process(self_pid) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let (available, total) = disks
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.available_space() > 0
|
||||
&& d.total_space() > 0
|
||||
// 简单过滤一些虚拟文件系统
|
||||
&& !["overlay", "tmpfs", "sysfs", "proc"]
|
||||
.contains(&d.file_system().to_string_lossy().as_ref())
|
||||
})
|
||||
.unique_by(|d| d.name())
|
||||
.fold((0, 0), |(mut available, mut total), d| {
|
||||
available += d.available_space();
|
||||
total += d.total_space();
|
||||
(available, total)
|
||||
});
|
||||
let sys_info = SysInfo {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
total_memory: system.total_memory(),
|
||||
used_memory: system.used_memory(),
|
||||
process_memory: process.memory(),
|
||||
used_cpu: system.global_cpu_usage(),
|
||||
process_cpu: process.cpu_usage() / system.cpus().len() as f32,
|
||||
total_disk: total,
|
||||
available_disk: available,
|
||||
};
|
||||
if tx.blocking_send(sys_info).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 异步部分负责获取由阻塞线程发送过来的系统信息,并推送给所有订阅者
|
||||
// 收到取消信号时,设置标志位,确保阻塞线程正常退出
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
||||
loop {
|
||||
select! {
|
||||
_ = cancel_token_clone.cancelled() => {
|
||||
drop(tick_tx);
|
||||
break;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let _ = tick_tx.send(()).await;
|
||||
}
|
||||
Some(sys_info) = rx.recv() => {
|
||||
future::join_all(sysinfo_subscribers.iter().map(async |subscriber| {
|
||||
if let Err(e) = subscriber.send(ServerEvent::SysInfo(sys_info)).await {
|
||||
error!(
|
||||
"Failed to send sysinfo event to subscriber {}: {:?}",
|
||||
subscriber.key(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cancel_token
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, log_writer: LogHelper) {
|
||||
let (ws_sender, ws_receiver) = socket.split();
|
||||
let uuid = Uuid::new_v4();
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
tokio::spawn(WEBSOCKET_HANDLER.handle_sender(ws_sender, rx));
|
||||
tokio::spawn(WEBSOCKET_HANDLER.handle_receiver(ws_receiver, tx, uuid, log_writer));
|
||||
}
|
||||
|
||||
trait SysInfoExt {
|
||||
fn refresh_needed(&mut self, self_pid: Pid);
|
||||
}
|
||||
|
||||
impl SysInfoExt for System {
|
||||
fn refresh_needed(&mut self, self_pid: Pid) {
|
||||
self.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
|
||||
self.refresh_cpu_specifics(CpuRefreshKind::nothing().with_cpu_usage());
|
||||
self.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[self_pid]),
|
||||
true,
|
||||
ProcessRefreshKind::nothing().with_cpu().with_memory(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SysInfoExt for Disks {
|
||||
fn refresh_needed(&mut self, _self_pid: Pid) {
|
||||
self.refresh_specifics(true, DiskRefreshKind::nothing().with_storage());
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,65 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::Error;
|
||||
use axum::Json;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{FromRequest, Request};
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
|
||||
#[derive(ToSchema, Serialize)]
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiResponse<T: Serialize> {
|
||||
status_code: u16,
|
||||
data: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<Cow<'static, str>>,
|
||||
}
|
||||
|
||||
impl<T: Serialize> ApiResponse<T> {
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self { status_code: 200, data }
|
||||
Self {
|
||||
status_code: 200,
|
||||
data: Some(data),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(data: T) -> Self {
|
||||
Self { status_code: 401, data }
|
||||
pub fn bad_request(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
status_code: 400,
|
||||
data: None,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(data: T) -> Self {
|
||||
Self { status_code: 404, data }
|
||||
pub fn unauthorized(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
status_code: 401,
|
||||
data: None,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_server_error(data: T) -> Self {
|
||||
Self { status_code: 500, data }
|
||||
pub fn not_found(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
status_code: 404,
|
||||
data: None,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_server_error(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
status_code: 500,
|
||||
data: None,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +88,32 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
if let Some(inner_error) = self.0.downcast_ref::<InnerApiError>() {
|
||||
match inner_error {
|
||||
InnerApiError::NotFound(_) => return ApiResponse::not_found(self.0.to_string()).into_response(),
|
||||
InnerApiError::NotFound(_) => return ApiResponse::<()>::not_found(self.0.to_string()).into_response(),
|
||||
InnerApiError::BadRequest(_) => {
|
||||
return ApiResponse::<()>::bad_request(self.0.to_string()).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
ApiResponse::internal_server_error(self.0.to_string()).into_response()
|
||||
ApiResponse::<()>::internal_server_error(self.0.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ValidatedJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidatedJson<T>
|
||||
where
|
||||
T: DeserializeOwned + Validate,
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Json(value) = Json::<T>::from_request(req, state).await?;
|
||||
value
|
||||
.validate()
|
||||
.map_err(|e| ApiError::from(InnerApiError::BadRequest(e.to_string())))?;
|
||||
Ok(ValidatedJson(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::error::BiliError;
|
||||
use crate::config::CONFIG;
|
||||
|
||||
pub struct PageAnalyzer {
|
||||
info: serde_json::Value,
|
||||
pub(crate) info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
|
||||
pub enum VideoQuality {
|
||||
Quality360p = 16,
|
||||
Quality480p = 32,
|
||||
@@ -54,7 +53,9 @@ impl AudioQuality {
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize, Clone,
|
||||
)]
|
||||
pub enum VideoCodecs {
|
||||
#[strum(serialize = "hev")]
|
||||
HEV,
|
||||
@@ -64,8 +65,22 @@ pub enum VideoCodecs {
|
||||
AV1,
|
||||
}
|
||||
|
||||
impl TryFrom<u64> for VideoCodecs {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: u64) -> std::result::Result<Self, Self::Error> {
|
||||
// https://socialsisteryi.github.io/bilibili-API-collect/docs/video/videostream_url.html#%E8%A7%86%E9%A2%91%E7%BC%96%E7%A0%81%E4%BB%A3%E7%A0%81
|
||||
match value {
|
||||
7 => Ok(Self::AVC),
|
||||
12 => Ok(Self::HEV),
|
||||
13 => Ok(Self::AV1),
|
||||
_ => bail!("invalid video codecs id: {}", value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频流的筛选偏好
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct FilterOption {
|
||||
pub video_max_quality: VideoQuality,
|
||||
pub video_min_quality: VideoQuality,
|
||||
@@ -115,16 +130,14 @@ pub enum Stream {
|
||||
|
||||
// 通用的获取流链接的方法,交由 Downloader 使用
|
||||
impl Stream {
|
||||
pub fn urls(&self) -> Vec<&str> {
|
||||
pub fn urls(&self, enable_cdn_sorting: bool) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Flv(url) | Self::Html5Mp4(url) | Self::EpisodeTryMp4(url) => vec![url],
|
||||
Self::DashVideo { url, backup_url, .. } | Self::DashAudio { url, backup_url, .. } => {
|
||||
let mut urls = std::iter::once(url.as_str())
|
||||
.chain(backup_url.iter().map(|s| s.as_str()))
|
||||
.collect();
|
||||
if !CONFIG.cdn_sorting {
|
||||
urls
|
||||
} else {
|
||||
.collect::<Vec<_>>();
|
||||
if enable_cdn_sorting {
|
||||
urls.sort_by_key(|u| {
|
||||
if u.contains("upos-") {
|
||||
0 // 服务商 cdn
|
||||
@@ -136,8 +149,8 @@ impl Stream {
|
||||
3 // pcdn 或者其它
|
||||
}
|
||||
});
|
||||
urls
|
||||
}
|
||||
urls
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,23 +217,18 @@ impl PageAnalyzer {
|
||||
.info
|
||||
.pointer_mut("/dash/video")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
.ok_or(BiliError::RiskControlOccurred)?
|
||||
.ok_or(BiliError::VideoStreamsEmpty)?
|
||||
.iter_mut()
|
||||
{
|
||||
let (Some(url), Some(quality), Some(codecs)) = (
|
||||
let (Some(url), Some(quality), Some(codecs_id)) = (
|
||||
video["baseUrl"].as_str(),
|
||||
video["id"].as_u64(),
|
||||
video["codecs"].as_str(),
|
||||
video["codecid"].as_u64(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let quality = VideoQuality::from_repr(quality as usize).context("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 等,直接跳过,不影响流程
|
||||
let Ok(codecs) = codecs_id.try_into() else {
|
||||
continue;
|
||||
};
|
||||
if !filter_option.codecs.contains(&codecs)
|
||||
@@ -254,39 +262,37 @@ impl PageAnalyzer {
|
||||
});
|
||||
}
|
||||
}
|
||||
if !filter_option.no_hires {
|
||||
if let Some(flac) = self.info.pointer_mut("/dash/flac/audio") {
|
||||
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).context("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(),
|
||||
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
if !filter_option.no_hires
|
||||
&& let Some(flac) = self.info.pointer_mut("/dash/flac/audio")
|
||||
{
|
||||
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
|
||||
bail!("invalid flac stream, flac content: {}", flac);
|
||||
};
|
||||
let quality = AudioQuality::from_repr(quality as usize).context("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(),
|
||||
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !filter_option.no_dolby_audio {
|
||||
if let Some(dolby_audio) = self
|
||||
if !filter_option.no_dolby_audio
|
||||
&& let Some(dolby_audio) = self
|
||||
.info
|
||||
.pointer_mut("/dash/dolby/audio/0")
|
||||
.and_then(|a| a.as_object_mut())
|
||||
{
|
||||
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).context("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(),
|
||||
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
{
|
||||
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).context("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(),
|
||||
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(streams)
|
||||
@@ -344,7 +350,7 @@ impl PageAnalyzer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bilibili::{BiliClient, Video};
|
||||
use crate::config::CONFIG;
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
#[test]
|
||||
fn test_quality_order() {
|
||||
@@ -383,35 +389,60 @@ mod tests {
|
||||
(
|
||||
"BV1xRChYUE2R",
|
||||
VideoQuality::Quality8k,
|
||||
VideoCodecs::HEV,
|
||||
Some(AudioQuality::QualityHiRES),
|
||||
),
|
||||
// 一个没有声音的纯视频
|
||||
("BV1J7411H7KQ", VideoQuality::Quality720p, None),
|
||||
("BV1J7411H7KQ", VideoQuality::Quality720p, VideoCodecs::HEV, None),
|
||||
// 一个杜比全景声的演示片
|
||||
(
|
||||
"BV1Mm4y1P7JV",
|
||||
VideoQuality::Quality4k,
|
||||
VideoQuality::QualityDolby,
|
||||
VideoCodecs::HEV,
|
||||
Some(AudioQuality::QualityDolby),
|
||||
),
|
||||
// 影视飓风的杜比视界视频
|
||||
(
|
||||
"BV1HEf2YWEvs",
|
||||
VideoQuality::QualityDolby,
|
||||
VideoCodecs::HEV,
|
||||
Some(AudioQuality::QualityDolby),
|
||||
),
|
||||
// 孤独摇滚的杜比视界 + hires + 杜比全景声视频
|
||||
(
|
||||
"BV1YDVYzeE39",
|
||||
VideoQuality::QualityDolby,
|
||||
VideoCodecs::HEV,
|
||||
Some(AudioQuality::QualityHiRES),
|
||||
),
|
||||
// 一个京紫的 HDR 视频
|
||||
(
|
||||
"BV1cZ4y1b7iB",
|
||||
VideoQuality::QualityHdr,
|
||||
VideoCodecs::HEV,
|
||||
Some(AudioQuality::Quality192k),
|
||||
),
|
||||
];
|
||||
for (bvid, video_quality, audio_quality) in testcases.into_iter() {
|
||||
let config = VersionedConfig::get().read();
|
||||
for (bvid, video_quality, video_codec, audio_quality) in testcases.into_iter() {
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, bvid.to_owned());
|
||||
let video = Video::new(&client, bvid.to_owned(), &config.credential);
|
||||
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)
|
||||
.best_stream(&config.filter_option)
|
||||
.expect("failed to get best stream");
|
||||
dbg!(bvid, &best_stream);
|
||||
match best_stream {
|
||||
BestStream::VideoAudio {
|
||||
video: Stream::DashVideo { quality, .. },
|
||||
video: Stream::DashVideo { quality, codecs, .. },
|
||||
audio,
|
||||
} => {
|
||||
assert_eq!(quality, video_quality);
|
||||
assert_eq!(codecs, video_codec);
|
||||
assert_eq!(
|
||||
audio.map(|audio_stream| match audio_stream {
|
||||
Stream::DashAudio { quality, .. } => quality,
|
||||
@@ -438,7 +469,7 @@ mod tests {
|
||||
codecs: VideoCodecs::AVC,
|
||||
};
|
||||
assert_eq!(
|
||||
stream.urls(),
|
||||
stream.urls(true),
|
||||
vec![
|
||||
"https://upos-sz-mirrorcos.bilivideo.com",
|
||||
"https://cn-tj-cu-01-11.bilivideo.com",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Result, bail};
|
||||
use leaky_bucket::RateLimiter;
|
||||
use parking_lot::Once;
|
||||
use reqwest::{Method, header};
|
||||
use ua_generator::ua;
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
use crate::config::{CONFIG, RateLimit};
|
||||
use crate::config::{RateLimit, VersionedCache};
|
||||
|
||||
// 一个对 reqwest::Client 的简单封装,用于 Bilibili 请求
|
||||
#[derive(Clone)]
|
||||
@@ -15,13 +17,17 @@ pub struct Client(reqwest::Client);
|
||||
|
||||
impl Client {
|
||||
pub fn new() -> Self {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
});
|
||||
// 正常访问 api 所必须的 header,作为默认 header 添加到每个请求中
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
header::HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
),
|
||||
header::HeaderValue::from_static(ua::spoof_chrome_ua()),
|
||||
);
|
||||
headers.insert(
|
||||
header::REFERER,
|
||||
@@ -61,55 +67,82 @@ impl Default for Client {
|
||||
}
|
||||
}
|
||||
|
||||
enum Limiter {
|
||||
Latest(VersionedCache<Option<RateLimiter>>),
|
||||
Snapshot(Arc<Option<RateLimiter>>),
|
||||
}
|
||||
|
||||
pub struct BiliClient {
|
||||
pub client: Client,
|
||||
limiter: Option<RateLimiter>,
|
||||
limiter: Limiter,
|
||||
}
|
||||
|
||||
impl BiliClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::new();
|
||||
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()
|
||||
});
|
||||
let limiter = Limiter::Latest(
|
||||
VersionedCache::new(|config| {
|
||||
Ok(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()
|
||||
}))
|
||||
})
|
||||
.expect("failed to create rate limiter"),
|
||||
);
|
||||
Self { client, limiter }
|
||||
}
|
||||
|
||||
/// 获取一个预构建的请求,通过该方法获取请求时会检查并等待速率限制
|
||||
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())
|
||||
/// 获取当前 BiliClient 的快照,快照中的限流器固定不变
|
||||
pub fn snapshot(&self) -> Result<Self> {
|
||||
let Limiter::Latest(inner) = &self.limiter else {
|
||||
// 语法上没问题,但语义上不允许对快照进行快照
|
||||
bail!("cannot snapshot a snapshot BiliClient");
|
||||
};
|
||||
Ok(Self {
|
||||
client: self.client.clone(),
|
||||
limiter: Limiter::Snapshot(inner.snapshot()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_refresh(&self) -> Result<()> {
|
||||
let credential = CONFIG.credential.load();
|
||||
let Some(credential) = credential.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !credential.need_refresh(&self.client).await? {
|
||||
return Ok(());
|
||||
/// 获取一个预构建的请求,通过该方法获取请求时会检查并等待速率限制
|
||||
pub async fn request(&self, method: Method, url: &str, credential: &Credential) -> reqwest::RequestBuilder {
|
||||
match &self.limiter {
|
||||
Limiter::Latest(inner) => {
|
||||
if let Some(limiter) = inner.read().as_ref() {
|
||||
limiter.acquire_one().await;
|
||||
}
|
||||
}
|
||||
Limiter::Snapshot(inner) => {
|
||||
if let Some(limiter) = inner.as_ref() {
|
||||
limiter.acquire_one().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_credential = credential.refresh(&self.client).await?;
|
||||
CONFIG.credential.store(Some(Arc::new(new_credential)));
|
||||
CONFIG.save()
|
||||
self.client.request(method, url, Some(credential))
|
||||
}
|
||||
|
||||
/// 检查并刷新 Credential,不需要刷新返回 Ok(None),需要刷新返回 Ok(Some(new_credential))
|
||||
pub async fn check_refresh(&self, credential: &Credential) -> Result<Option<Credential>> {
|
||||
if !credential.need_refresh(&self.client).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(credential.refresh(&self.client).await?))
|
||||
}
|
||||
|
||||
/// 获取 wbi img,用于生成请求签名
|
||||
pub async fn wbi_img(&self) -> Result<WbiImg> {
|
||||
let credential = CONFIG.credential.load();
|
||||
let credential = credential.as_deref().context("no credential found")?;
|
||||
pub async fn wbi_img(&self, credential: &Credential) -> Result<WbiImg> {
|
||||
credential.wbi_img(&self.client).await
|
||||
}
|
||||
|
||||
pub fn inner_client(&self) -> &reqwest::Client {
|
||||
&self.client.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,25 @@ use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
|
||||
pub enum CollectionType {
|
||||
Series,
|
||||
#[default]
|
||||
Season,
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for CollectionType {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = i32::deserialize(deserializer)?;
|
||||
CollectionType::try_from(v).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CollectionType> for i32 {
|
||||
fn from(v: CollectionType) -> Self {
|
||||
match v {
|
||||
@@ -25,16 +35,24 @@ impl From<CollectionType> for i32 {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for CollectionType {
|
||||
fn from(v: i32) -> Self {
|
||||
impl TryFrom<i32> for CollectionType {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(v: i32) -> Result<Self, Self::Error> {
|
||||
match v {
|
||||
1 => CollectionType::Series,
|
||||
2 => CollectionType::Season,
|
||||
_ => panic!("invalid collection type"),
|
||||
1 => Ok(CollectionType::Series),
|
||||
2 => Ok(CollectionType::Season),
|
||||
v => Err(anyhow!("got invalid collection type {}", v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CollectionType {
|
||||
pub fn from_expected(v: i32) -> Self {
|
||||
Self::try_from(v).expect("invalid collection type")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CollectionType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
@@ -54,7 +72,8 @@ pub struct CollectionItem {
|
||||
|
||||
pub struct Collection<'a> {
|
||||
client: &'a BiliClient,
|
||||
collection: &'a CollectionItem,
|
||||
pub collection: CollectionItem,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -93,8 +112,12 @@ impl<'de> Deserialize<'de> for CollectionInfo {
|
||||
}
|
||||
|
||||
impl<'a> Collection<'a> {
|
||||
pub fn new(client: &'a BiliClient, collection: &'a CollectionItem) -> Self {
|
||||
Self { client, collection }
|
||||
pub fn new(client: &'a BiliClient, collection: CollectionItem, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
collection,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<CollectionInfo> {
|
||||
@@ -108,7 +131,7 @@ impl<'a> Collection<'a> {
|
||||
|
||||
async fn get_series_info(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series")
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series", self.credential)
|
||||
.await
|
||||
.query(&[("series_id", self.collection.sid.as_str())])
|
||||
.send()
|
||||
@@ -120,46 +143,40 @@ impl<'a> Collection<'a> {
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
let req = match self.collection.collection_type {
|
||||
CollectionType::Series => self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/series/archives",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("pn", page)])
|
||||
.query(&[
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("series_id", self.collection.sid.as_str()),
|
||||
("only_normal", "true"),
|
||||
("sort", "desc"),
|
||||
("ps", "30"),
|
||||
]),
|
||||
CollectionType::Season => self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("page_num", page)])
|
||||
.query(&[
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("season_id", self.collection.sid.as_str()),
|
||||
("sort_reverse", "true"),
|
||||
("page_size", "30"),
|
||||
]),
|
||||
};
|
||||
self.client
|
||||
.request(Method::GET, url)
|
||||
.await
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
req.send().await?.error_for_status()?.json::<Value>().await?.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use cookie::Cookie;
|
||||
use cow_utils::CowUtils;
|
||||
use regex::Regex;
|
||||
use reqwest::{Method, header};
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
@@ -11,7 +9,7 @@ use rsa::sha2::Sha256;
|
||||
use rsa::{Oaep, RsaPublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{Client, Validate};
|
||||
use crate::bilibili::{BiliError, 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,
|
||||
@@ -19,6 +17,13 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
||||
20, 34, 44, 52,
|
||||
];
|
||||
|
||||
mod qrcode_status_code {
|
||||
pub const SUCCESS: i64 = 0;
|
||||
pub const NOT_SCANNED: i64 = 86101;
|
||||
pub const SCANNED_UNCONFIRMED: i64 = 86090;
|
||||
pub const EXPIRED: i64 = 86038;
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
pub sessdata: String,
|
||||
@@ -30,17 +35,35 @@ pub struct Credential {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WbiImg {
|
||||
img_url: String,
|
||||
sub_url: String,
|
||||
pub(crate) img_url: String,
|
||||
pub(crate) 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()),
|
||||
) {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Qrcode {
|
||||
pub url: String,
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum PollStatus {
|
||||
Success {
|
||||
credential: Credential,
|
||||
},
|
||||
Pending {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
scanned: bool,
|
||||
},
|
||||
Expired {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl WbiImg {
|
||||
pub fn into_mixin_key(self) -> Option<String> {
|
||||
let key = match (get_filename(self.img_url.as_str()), get_filename(self.sub_url.as_str())) {
|
||||
(Some(img_key), Some(sub_key)) => img_key.to_string() + sub_key,
|
||||
_ => return None,
|
||||
};
|
||||
@@ -62,6 +85,78 @@ impl Credential {
|
||||
Ok(serde_json::from_value(res["data"]["wbi_img"].take())?)
|
||||
}
|
||||
|
||||
pub async fn generate_qrcode(client: &Client) -> Result<Qrcode> {
|
||||
let mut res = client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://passport.bilibili.com/x/passport-login/web/qrcode/generate",
|
||||
None,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn poll_qrcode(client: &Client, qrcode_key: &str) -> Result<PollStatus> {
|
||||
let mut resp = client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll",
|
||||
None,
|
||||
)
|
||||
.query(&[("qrcode_key", qrcode_key)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let headers = std::mem::take(resp.headers_mut());
|
||||
let json = resp.json::<serde_json::Value>().await?.validate()?;
|
||||
let code = json["data"]["code"].as_i64().context("missing 'code' field in data")?;
|
||||
|
||||
match code {
|
||||
qrcode_status_code::SUCCESS => {
|
||||
let mut credential = Self::extract(headers, json)?;
|
||||
credential.buvid3 = Self::get_buvid3(client).await?;
|
||||
Ok(PollStatus::Success { credential })
|
||||
}
|
||||
qrcode_status_code::NOT_SCANNED => Ok(PollStatus::Pending {
|
||||
message: "未扫描".to_owned(),
|
||||
scanned: false,
|
||||
}),
|
||||
qrcode_status_code::SCANNED_UNCONFIRMED => Ok(PollStatus::Pending {
|
||||
message: "已扫描,请在手机上确认登录".to_owned(),
|
||||
scanned: true,
|
||||
}),
|
||||
qrcode_status_code::EXPIRED => Ok(PollStatus::Expired {
|
||||
message: "二维码已过期".to_owned(),
|
||||
}),
|
||||
_ => {
|
||||
bail!(BiliError::InvalidResponse(json.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 buvid3 浏览器指纹
|
||||
///
|
||||
/// 参考 https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/buvid3_4.md
|
||||
async fn get_buvid3(client: &Client) -> Result<String> {
|
||||
let resp = client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-frontend/getbuvid", None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
resp["data"]["buvid"]
|
||||
.as_str()
|
||||
.context("missing 'buvid' field in data")
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 检查凭据是否有效
|
||||
pub async fn need_refresh(&self, client: &Client) -> Result<bool> {
|
||||
let res = client
|
||||
@@ -81,9 +176,17 @@ impl Credential {
|
||||
|
||||
pub async fn refresh(&self, client: &Client) -> Result<Self> {
|
||||
let correspond_path = Self::get_correspond_path();
|
||||
let csrf = self.get_refresh_csrf(client, correspond_path).await?;
|
||||
let new_credential = self.get_new_credential(client, &csrf).await?;
|
||||
self.confirm_refresh(client, &new_credential).await?;
|
||||
let csrf = self
|
||||
.get_refresh_csrf(client, correspond_path)
|
||||
.await
|
||||
.context("获取 refresh_csrf 失败")?;
|
||||
let new_credential = self
|
||||
.get_new_credential(client, &csrf)
|
||||
.await
|
||||
.context("刷新 Credential 失败")?;
|
||||
self.confirm_refresh(client, &new_credential)
|
||||
.await
|
||||
.context("确认更新 Credential 失败")?;
|
||||
Ok(new_credential)
|
||||
}
|
||||
|
||||
@@ -98,11 +201,11 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
-----END PUBLIC KEY-----",
|
||||
)
|
||||
.expect("fail to decode public key");
|
||||
let ts = chrono::Local::now().timestamp_millis();
|
||||
// 精确到毫秒的时间戳可能出现时间比服务器快的情况,提前 20s 以防万一
|
||||
let ts = chrono::Local::now().timestamp_millis() - 20000;
|
||||
let data = format!("refresh_{}", ts).into_bytes();
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let encrypted = key
|
||||
.encrypt(&mut rng, Oaep::new::<Sha256>(), &data)
|
||||
.encrypt(&mut rand::rng(), Oaep::new::<Sha256>(), &data)
|
||||
.expect("fail to encrypt");
|
||||
hex::encode(encrypted)
|
||||
}
|
||||
@@ -122,7 +225,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
}
|
||||
|
||||
async fn get_new_credential(&self, client: &Client, csrf: &str) -> Result<Credential> {
|
||||
let mut res = client
|
||||
let mut resp = client
|
||||
.request(
|
||||
Method::POST,
|
||||
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
|
||||
@@ -139,37 +242,10 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
// 必须在 .json 前取出 headers,否则 res 会被消耗
|
||||
let headers = std::mem::take(res.headers_mut());
|
||||
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(),
|
||||
..Self::default()
|
||||
};
|
||||
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
|
||||
let cookies: Vec<Cookie> = set_cookies
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().ok())
|
||||
.filter_map(|x| Cookie::parse(x).ok())
|
||||
.filter(|x| required_cookies.contains(x.name()))
|
||||
.collect();
|
||||
ensure!(
|
||||
cookies.len() == required_cookies.len(),
|
||||
"not all required cookies found"
|
||||
);
|
||||
for cookie in cookies {
|
||||
match cookie.name() {
|
||||
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
|
||||
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
|
||||
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
match res["data"]["refresh_token"].as_str() {
|
||||
Some(token) => credential.ac_time_value = token.to_string(),
|
||||
None => bail!("refresh_token not found"),
|
||||
}
|
||||
let headers = std::mem::take(resp.headers_mut());
|
||||
let json = resp.json::<serde_json::Value>().await?.validate()?;
|
||||
let mut credential = Self::extract(headers, json)?;
|
||||
credential.buvid3 = self.buvid3.clone();
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
@@ -193,6 +269,36 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 header 和 json,获取除 buvid3 字段外全部填充的 Credential
|
||||
fn extract(headers: header::HeaderMap, json: serde_json::Value) -> Result<Credential> {
|
||||
let mut credential = Credential::default();
|
||||
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
|
||||
let cookies: Vec<Cookie> = headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().ok())
|
||||
.filter_map(|x| Cookie::parse(x).ok())
|
||||
.filter(|x| required_cookies.contains(x.name()))
|
||||
.collect();
|
||||
ensure!(
|
||||
cookies.len() == required_cookies.len(),
|
||||
"not all required cookies found"
|
||||
);
|
||||
for cookie in cookies {
|
||||
match cookie.name() {
|
||||
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
|
||||
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
|
||||
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
match json["data"]["refresh_token"].as_str() {
|
||||
Some(token) => credential.ac_time_value = token.to_string(),
|
||||
None => bail!("refresh_token not found"),
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
}
|
||||
|
||||
// 用指定的 pattern 正则表达式在 doc 中查找,返回第一个匹配的捕获组
|
||||
@@ -213,47 +319,8 @@ fn get_filename(url: &str) -> Option<&str> {
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
|
||||
pub fn encoded_query<'a>(
|
||||
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
|
||||
mixin_key: Option<impl AsRef<str>>,
|
||||
) -> Vec<(&'a str, Cow<'a, str>)> {
|
||||
match mixin_key {
|
||||
Some(key) => _encoded_query(params, key.as_ref(), chrono::Local::now().timestamp().to_string()),
|
||||
None => params.into_iter().map(|(k, v)| (k, v.into())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
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 disallowed = ['!', '\'', '(', ')', '*'];
|
||||
let mut params: Vec<(&'a str, Cow<'a, str>)> = params
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
match Into::<Cow<'a, str>>::into(v) {
|
||||
Cow::Borrowed(v) => v.cow_replace(&disallowed[..], ""),
|
||||
Cow::Owned(v) => v.replace(&disallowed[..], "").into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
params.push(("wts", timestamp.into()));
|
||||
params.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let query = serde_urlencoded::to_string(¶ms)
|
||||
.expect("fail to encode query")
|
||||
.replace('+', "%20");
|
||||
params.push(("w_rid", format!("{:x}", md5::compute(query.clone() + mixin_key)).into()));
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assert_matches::assert_matches;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -285,54 +352,92 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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_matches!(
|
||||
&_encoded_query(
|
||||
vec![("foo", "114"), ("bar", "514"), ("zab", "1919810")],
|
||||
key.as_str(),
|
||||
"1702204169".to_string(),
|
||||
)[..],
|
||||
[
|
||||
("bar", Cow::Borrowed(a)),
|
||||
("foo", Cow::Borrowed(b)),
|
||||
("wts", Cow::Owned(c)),
|
||||
("zab", Cow::Borrowed(d)),
|
||||
("w_rid", Cow::Owned(e)),
|
||||
] => {
|
||||
assert_eq!(*a, "514");
|
||||
assert_eq!(*b, "114");
|
||||
assert_eq!(c, "1702204169");
|
||||
assert_eq!(*d, "1919810");
|
||||
assert_eq!(e, "8f6f2b5b3d485fe1886cec6a0be8c5d4");
|
||||
}
|
||||
fn test_extract_credential_success() {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"SESSDATA=test_sessdata; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
// 有特殊字符
|
||||
assert_matches!(
|
||||
&_encoded_query(
|
||||
vec![("foo", "'1(1)4'"), ("bar", "!5*1!14"), ("zab", "1919810")],
|
||||
key.as_str(),
|
||||
"1702204169".to_string(),
|
||||
)[..],
|
||||
[
|
||||
("bar", Cow::Owned(a)),
|
||||
("foo", Cow::Owned(b)),
|
||||
("wts", Cow::Owned(c)),
|
||||
("zab", Cow::Borrowed(d)),
|
||||
("w_rid", Cow::Owned(e)),
|
||||
] => {
|
||||
assert_eq!(a, "5114");
|
||||
assert_eq!(b, "114");
|
||||
assert_eq!(c, "1702204169");
|
||||
assert_eq!(*d, "1919810");
|
||||
assert_eq!(e, "6a2c86c4b0648ce062ba0dac2de91a85");
|
||||
}
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"bili_jct=test_jct; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"DedeUserID=123456; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
|
||||
let json = serde_json::json!({
|
||||
"data": {
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
});
|
||||
|
||||
let credential = Credential::extract(headers, json).unwrap();
|
||||
|
||||
assert_eq!(credential.sessdata, "test_sessdata");
|
||||
assert_eq!(credential.bili_jct, "test_jct");
|
||||
assert_eq!(credential.dedeuserid, "123456");
|
||||
assert_eq!(credential.ac_time_value, "test_refresh_token");
|
||||
assert!(credential.buvid3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_credential_missing_sessdata() {
|
||||
let headers = header::HeaderMap::new();
|
||||
let json = serde_json::json!({
|
||||
"data": {
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
});
|
||||
assert!(Credential::extract(headers, json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_credential_missing_refresh_token() {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.append(header::SET_COOKIE, "SESSDATA=test_sessdata".parse().unwrap());
|
||||
headers.append(header::SET_COOKIE, "bili_jct=test_jct".parse().unwrap());
|
||||
headers.append(header::SET_COOKIE, "DedeUserID=123456".parse().unwrap());
|
||||
let json = serde_json::json!({
|
||||
"data": {}
|
||||
});
|
||||
assert!(Credential::extract(headers, json).is_err());
|
||||
}
|
||||
|
||||
#[ignore = "requires manual testing with real QR code scan"]
|
||||
#[tokio::test]
|
||||
async fn test_qrcode_login_flow() -> Result<()> {
|
||||
let client = Client::new();
|
||||
// 1. 生成二维码
|
||||
let qr_response = Credential::generate_qrcode(&client).await?;
|
||||
println!("二维码 URL: {}", qr_response.url);
|
||||
println!("qrcode_key: {}", qr_response.qrcode_key);
|
||||
println!("\n请使用 B 站 APP 扫描二维码...\n");
|
||||
// 2. 轮询登录状态(最多轮询 90 次,每 2 秒一次,共 180 秒)
|
||||
for i in 1..=90 {
|
||||
println!("第 {} 次轮询...", i);
|
||||
let status = Credential::poll_qrcode(&client, &qr_response.qrcode_key).await?;
|
||||
match status {
|
||||
PollStatus::Success { credential } => {
|
||||
println!("\n登录成功!");
|
||||
println!("SESSDATA: {}", credential.sessdata);
|
||||
println!("bili_jct: {}", credential.bili_jct);
|
||||
println!("buvid3: {}", credential.buvid3);
|
||||
println!("DedeUserID: {}", credential.dedeuserid);
|
||||
println!("ac_time_value: {}", credential.ac_time_value);
|
||||
return Ok(());
|
||||
}
|
||||
PollStatus::Pending { message, scanned } => {
|
||||
println!("状态: {}, 已扫描: {}", message, scanned);
|
||||
}
|
||||
PollStatus::Expired { message } => {
|
||||
println!("\n二维码已过期: {}", message);
|
||||
anyhow::bail!("二维码过期");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
bail!("轮询超时")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ impl fmt::Display for CanvasStyles {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AssWriter<W: AsyncWrite> {
|
||||
pub struct AssWriter<'a, W: AsyncWrite> {
|
||||
f: Pin<Box<BufWriter<W>>>,
|
||||
title: String,
|
||||
canvas_config: CanvasConfig,
|
||||
canvas_config: CanvasConfig<'a>,
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite> AssWriter<W> {
|
||||
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Self {
|
||||
impl<'a, W: AsyncWrite> AssWriter<'a, W> {
|
||||
pub fn new(f: W, title: String, canvas_config: CanvasConfig<'a>) -> Self {
|
||||
AssWriter {
|
||||
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
||||
f: Box::pin(BufWriter::with_capacity(10 << 20, f)),
|
||||
@@ -104,7 +104,7 @@ impl<W: AsyncWrite> AssWriter<W> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn construct(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
|
||||
pub async fn construct(f: W, title: String, canvas_config: CanvasConfig<'a>) -> Result<Self> {
|
||||
let mut res = Self::new(f, title, canvas_config);
|
||||
res.init().await?;
|
||||
Ok(res)
|
||||
@@ -184,7 +184,7 @@ impl<W: AsyncWrite> AssWriter<W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_text(text: &str) -> Cow<str> {
|
||||
fn escape_text(text: &'_ str) -> Cow<'_, str> {
|
||||
let text = text.trim();
|
||||
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
||||
Cow::from(text.replace('\n', "\\N"))
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct Lane {
|
||||
}
|
||||
|
||||
impl Lane {
|
||||
pub fn draw(danmu: &Danmu, config: &CanvasConfig) -> Self {
|
||||
pub fn draw(danmu: &Danmu, config: &CanvasConfig<'_>) -> Self {
|
||||
Lane {
|
||||
last_shoot_time: danmu.timeline_s,
|
||||
last_length: danmu.length(config),
|
||||
@@ -26,7 +26,7 @@ impl Lane {
|
||||
}
|
||||
|
||||
/// 这个槽位是否可以发射另外一条弹幕,返回可能的情形
|
||||
pub fn available_for(&self, other: &Danmu, config: &CanvasConfig) -> Collision {
|
||||
pub fn available_for(&self, other: &Danmu, config: &CanvasConfig<'_>) -> Collision {
|
||||
#[allow(non_snake_case)]
|
||||
let T = config.danmaku_option.duration;
|
||||
#[allow(non_snake_case)]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::bilibili::danmaku::canvas::lane::Collision;
|
||||
use crate::bilibili::danmaku::danmu::DanmuType;
|
||||
use crate::bilibili::danmaku::{Danmu, DrawEffect, Drawable};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct DanmakuOption {
|
||||
pub duration: f64,
|
||||
pub font: String,
|
||||
@@ -26,7 +26,7 @@ pub struct DanmakuOption {
|
||||
pub bottom_percentage: f64,
|
||||
/// 透明度(0-255)
|
||||
pub opacity: u8,
|
||||
/// 是否加粗,1代表是,0代表否
|
||||
/// 是否加粗,1 代表是,0 代表否
|
||||
pub bold: bool,
|
||||
/// 描边
|
||||
pub outline: f64,
|
||||
@@ -54,13 +54,13 @@ impl Default for DanmakuOption {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CanvasConfig {
|
||||
pub struct CanvasConfig<'a> {
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
pub danmaku_option: &'static DanmakuOption,
|
||||
pub danmaku_option: &'a DanmakuOption,
|
||||
}
|
||||
impl CanvasConfig {
|
||||
pub fn new(danmaku_option: &'static DanmakuOption, page: &PageInfo) -> Self {
|
||||
impl<'a> CanvasConfig<'a> {
|
||||
pub fn new(danmaku_option: &'a DanmakuOption, page: &PageInfo) -> Self {
|
||||
let (width, height) = Self::dimension(page);
|
||||
Self {
|
||||
width,
|
||||
@@ -86,7 +86,7 @@ impl CanvasConfig {
|
||||
((720.0 / height as f64 * width as f64) as u64, 720)
|
||||
}
|
||||
|
||||
pub fn canvas(self) -> Canvas {
|
||||
pub fn canvas(self) -> Canvas<'a> {
|
||||
let float_lanes_cnt =
|
||||
(self.danmaku_option.float_percentage * self.height as f64 / self.danmaku_option.lane_size as f64) as usize;
|
||||
|
||||
@@ -97,12 +97,12 @@ impl CanvasConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Canvas {
|
||||
pub config: CanvasConfig,
|
||||
pub struct Canvas<'a> {
|
||||
pub config: CanvasConfig<'a>,
|
||||
pub float_lanes: Vec<Option<Lane>>,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
impl<'a> Canvas<'a> {
|
||||
pub fn draw(&mut self, mut danmu: Danmu) -> Result<Option<Drawable>> {
|
||||
danmu.timeline_s += self.config.danmaku_option.time_offset;
|
||||
if danmu.timeline_s < 0.0 {
|
||||
|
||||
@@ -39,8 +39,8 @@ pub struct Danmu {
|
||||
impl Danmu {
|
||||
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
|
||||
///
|
||||
/// 汉字算一个全宽,英文算2/3宽
|
||||
pub fn length(&self, config: &CanvasConfig) -> f64 {
|
||||
/// 汉字算一个全宽,英文算 2/3 宽
|
||||
pub fn length(&self, config: &CanvasConfig<'_>) -> f64 {
|
||||
let pts = config.danmaku_option.font_size
|
||||
* self
|
||||
.content
|
||||
|
||||
@@ -3,10 +3,9 @@ use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
use tokio::fs::{self, File};
|
||||
|
||||
use crate::bilibili::PageInfo;
|
||||
use crate::bilibili::danmaku::canvas::CanvasConfig;
|
||||
use crate::bilibili::danmaku::{AssWriter, Danmu};
|
||||
use crate::config::CONFIG;
|
||||
use crate::bilibili::{DanmakuOption, PageInfo};
|
||||
|
||||
pub struct DanmakuWriter<'a> {
|
||||
page: &'a PageInfo,
|
||||
@@ -18,11 +17,11 @@ impl<'a> DanmakuWriter<'a> {
|
||||
DanmakuWriter { page, danmaku }
|
||||
}
|
||||
|
||||
pub async fn write(self, path: PathBuf) -> Result<()> {
|
||||
pub async fn write(self, path: PathBuf, danmaku_option: &DanmakuOption) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let canvas_config = CanvasConfig::new(&CONFIG.danmaku_option, self.page);
|
||||
let canvas_config = CanvasConfig::new(danmaku_option, self.page);
|
||||
let mut writer =
|
||||
AssWriter::construct(File::create(path).await?, self.page.name.clone(), canvas_config.clone()).await?;
|
||||
let mut canvas = canvas_config.canvas();
|
||||
|
||||
89
crates/bili_sync/src/bilibili/dynamic.rs
Normal file
89
crates/bili_sync/src/bilibili/dynamic.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_stream::try_stream;
|
||||
use chrono::DateTime;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Credential, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
|
||||
pub struct Dynamic<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub upper_id: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> Dynamic<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
upper_id,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_dynamics(&self, offset: Option<String>) -> Result<Value> {
|
||||
self.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("host_mid", self.upper_id.as_str()),
|
||||
("offset", offset.as_deref().unwrap_or("")),
|
||||
("type", "video"),
|
||||
])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
|
||||
try_stream! {
|
||||
let mut offset = None;
|
||||
loop {
|
||||
let mut res = self
|
||||
.get_dynamics(offset.take())
|
||||
.await
|
||||
.with_context(|| "failed to get dynamics")?;
|
||||
let items = res["data"]["items"].as_array_mut().context("items not exist")?;
|
||||
for item in items.iter_mut() {
|
||||
if item["type"].as_str().is_none_or(|t| t != "DYNAMIC_TYPE_AV") {
|
||||
continue;
|
||||
}
|
||||
let pub_ts = item["modules"]["module_author"]["pub_ts"].take();
|
||||
let pub_dt = pub_ts
|
||||
.as_i64()
|
||||
.or_else(|| pub_ts.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||||
.and_then(DateTime::from_timestamp_secs)
|
||||
.with_context(|| format!("invalid pub_ts: {:?}", pub_ts))?;
|
||||
let mut video_info: VideoInfo =
|
||||
serde_json::from_value(item["modules"]["module_dynamic"]["major"]["archive"].take())?;
|
||||
// 这些地方不使用 let else 是因为 try_stream! 宏不支持
|
||||
if let VideoInfo::Dynamic { ref mut pubtime, .. } = video_info {
|
||||
*pubtime = pub_dt;
|
||||
yield video_info;
|
||||
} else {
|
||||
Err(anyhow!("video info is not dynamic"))?;
|
||||
}
|
||||
}
|
||||
if let (Some(has_more), Some(new_offset)) =
|
||||
(res["data"]["has_more"].as_bool(), res["data"]["offset"].as_str())
|
||||
{
|
||||
if !has_more {
|
||||
break;
|
||||
}
|
||||
offset = Some(new_offset.to_string());
|
||||
} else {
|
||||
Err(anyhow!("no has_more or offset found"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum BiliError {
|
||||
#[error("risk control occurred")]
|
||||
RiskControlOccurred,
|
||||
#[error("request failed, status code: {0}, message: {1}")]
|
||||
RequestFailed(i64, String),
|
||||
#[error("response missing 'code' or 'message' field, full response: {0}")]
|
||||
InvalidResponse(String),
|
||||
#[error("API returned error code {0}, full response: {1}")]
|
||||
ErrorResponse(i64, String),
|
||||
#[error("risk control triggered by server, full response: {0}")]
|
||||
RiskControlOccurred(String),
|
||||
#[error("no video streams available (may indicate risk control)")]
|
||||
VideoStreamsEmpty,
|
||||
}
|
||||
|
||||
impl BiliError {
|
||||
pub fn is_risk_control_related(&self) -> bool {
|
||||
matches!(self, BiliError::RiskControlOccurred(_) | BiliError::VideoStreamsEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ use async_stream::try_stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
|
||||
pub struct FavoriteList<'a> {
|
||||
client: &'a BiliClient,
|
||||
fid: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -22,14 +23,22 @@ pub struct Upper<T> {
|
||||
pub face: String,
|
||||
}
|
||||
impl<'a> FavoriteList<'a> {
|
||||
pub fn new(client: &'a BiliClient, fid: String) -> Self {
|
||||
Self { client, fid }
|
||||
pub fn new(client: &'a BiliClient, fid: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
fid,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<FavoriteListInfo> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/folder/info")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/info",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("media_id", &self.fid)])
|
||||
.send()
|
||||
@@ -43,7 +52,11 @@ impl<'a> FavoriteList<'a> {
|
||||
|
||||
async fn get_videos(&self, page: u32) -> Result<Value> {
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/resource/list")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/resource/list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("media_id", self.fid.as_str()),
|
||||
|
||||
138
crates/bili_sync/src/bilibili/me.rs
Normal file
138
crates/bili_sync/src/bilibili/me.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::bilibili::{BiliClient, Credential, Validate};
|
||||
|
||||
pub struct Me<'a> {
|
||||
client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> Me<'a> {
|
||||
pub fn new(client: &'a BiliClient, credential: &'a Credential) -> Self {
|
||||
Self { client, credential }
|
||||
}
|
||||
|
||||
pub async fn get_created_favorites(&self) -> Result<Option<Vec<FavoriteItem>>> {
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let mut resp = self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/created/list-all",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("up_mid", &self.mid())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(resp["data"]["list"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_followed_collections(&self, page_num: i32, page_size: i32) -> Result<Collections> {
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let mut resp = self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/collected/list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("up_mid", self.mid()), ("platform", "web")])
|
||||
.query(&[("pn", page_num), ("ps", page_size)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(resp["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_followed_uppers(
|
||||
&self,
|
||||
page_num: i32,
|
||||
page_size: i32,
|
||||
name: Option<&str>,
|
||||
) -> Result<FollowedUppers> {
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let url = if name.is_some() {
|
||||
"https://api.bilibili.com/x/relation/followings/search"
|
||||
} else {
|
||||
"https://api.bilibili.com/x/relation/followings"
|
||||
};
|
||||
let mut request = self
|
||||
.client
|
||||
.request(Method::GET, url, self.credential)
|
||||
.await
|
||||
.query(&[("vmid", self.mid())])
|
||||
.query(&[("pn", page_num), ("ps", page_size)]);
|
||||
if let Some(name) = name {
|
||||
request = request.query(&[("name", name)]);
|
||||
}
|
||||
let mut resp = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(resp["data"].take())?)
|
||||
}
|
||||
|
||||
fn mid(&self) -> &str {
|
||||
&self.credential.dedeuserid
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct FavoriteItem {
|
||||
pub title: String,
|
||||
pub media_count: i64,
|
||||
pub id: i64,
|
||||
pub mid: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct CollectionItem {
|
||||
pub id: i64,
|
||||
pub fid: i64,
|
||||
pub mid: i64,
|
||||
pub state: i32,
|
||||
pub title: String,
|
||||
pub media_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Collections {
|
||||
pub count: i64,
|
||||
pub list: Option<Vec<CollectionItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct FollowedUppers {
|
||||
pub total: i64,
|
||||
pub list: Vec<FollowedUpper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct FollowedUpper {
|
||||
pub mid: i64,
|
||||
pub uname: String,
|
||||
pub face: String,
|
||||
pub sign: String,
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
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 credential::{Credential, PollStatus, Qrcode};
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use dynamic::Dynamic;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
pub use me::Me;
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::RequestBuilder;
|
||||
pub use submission::Submission;
|
||||
pub use video::{Dimension, PageInfo, Video};
|
||||
pub use watch_later::WatchLater;
|
||||
@@ -22,8 +26,10 @@ mod client;
|
||||
mod collection;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod dynamic;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod me;
|
||||
mod submission;
|
||||
mod subtitle;
|
||||
mod video;
|
||||
@@ -45,15 +51,50 @@ 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"),
|
||||
};
|
||||
ensure!(code == 0, BiliError::RequestFailed(code, msg.to_owned()));
|
||||
let code = self["code"]
|
||||
.as_i64()
|
||||
.with_context(|| BiliError::InvalidResponse(self.to_string()))?;
|
||||
if code == -352 || !self["data"]["v_voucher"].is_null() {
|
||||
bail!(BiliError::RiskControlOccurred(self.to_string()));
|
||||
}
|
||||
ensure!(code == 0, BiliError::ErrorResponse(code, self.to_string()));
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait WbiSign {
|
||||
type Output;
|
||||
|
||||
fn wbi_sign(self, mixin_key: Option<impl AsRef<str>>) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl WbiSign for RequestBuilder {
|
||||
type Output = RequestBuilder;
|
||||
|
||||
fn wbi_sign(self, mixin_key: Option<impl AsRef<str>>) -> Result<Self::Output> {
|
||||
let Some(mixin_key) = mixin_key else {
|
||||
return Ok(self);
|
||||
};
|
||||
let (client, req) = self.build_split();
|
||||
let mut req = req?;
|
||||
sign_request(&mut req, mixin_key.as_ref(), chrono::Utc::now().timestamp())?;
|
||||
Ok(RequestBuilder::from_parts(client, req))
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_request(req: &mut reqwest::Request, mixin_key: &str, timestamp: i64) -> Result<()> {
|
||||
let mut query_pairs = req.url().query_pairs().collect::<Vec<_>>();
|
||||
let timestamp = timestamp.to_string();
|
||||
query_pairs.push(("wts".into(), Cow::Borrowed(timestamp.as_str())));
|
||||
query_pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let query_str = serde_urlencoded::to_string(query_pairs)?.replace('+', "%20");
|
||||
let w_rid = format!("{:x}", md5::compute(query_str + mixin_key));
|
||||
req.url_mut()
|
||||
.query_pairs_mut()
|
||||
.extend_pairs([("w_rid", w_rid), ("wts", timestamp)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
/// 注意此处的顺序是有要求的,因为对于 untagged 的 enum 来说,serde 会按照顺序匹配
|
||||
@@ -74,6 +115,9 @@ pub enum VideoInfo {
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
is_upower_exclusive: bool,
|
||||
is_upower_play: bool,
|
||||
redirect_url: Option<String>,
|
||||
pages: Vec<PageInfo>,
|
||||
state: i32,
|
||||
},
|
||||
@@ -133,32 +177,54 @@ pub enum VideoInfo {
|
||||
#[serde(rename = "created", with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
},
|
||||
// 从动态获取的视频信息(此处 pubtime 未在结构中,因此使用 default + 手动赋值)
|
||||
Dynamic {
|
||||
title: String,
|
||||
bvid: String,
|
||||
desc: String,
|
||||
cover: String,
|
||||
#[serde(default)]
|
||||
pubtime: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Method;
|
||||
|
||||
use super::*;
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::database::setup_database;
|
||||
use crate::utils::init_logger;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_video_info_type() {
|
||||
init_logger("None,bili_sync=debug");
|
||||
async fn test_video_info_type() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
init_logger("None,bili_sync=debug", None);
|
||||
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 失败");
|
||||
};
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
// 测试视频合集
|
||||
let collection_item = CollectionItem {
|
||||
mid: "521722088".to_string(),
|
||||
sid: "4523".to_string(),
|
||||
collection_type: CollectionType::Season,
|
||||
};
|
||||
let collection = Collection::new(&bili_client, &collection_item);
|
||||
let collection = Collection::new(
|
||||
&bili_client,
|
||||
CollectionItem {
|
||||
mid: "521722088".to_string(),
|
||||
sid: "4523".to_string(),
|
||||
collection_type: CollectionType::Season,
|
||||
},
|
||||
&credential,
|
||||
);
|
||||
let videos = collection
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -168,7 +234,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Collection { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试收藏夹
|
||||
let favorite = FavoriteList::new(&bili_client, "3144336058".to_string());
|
||||
let favorite = FavoriteList::new(&bili_client, "3144336058".to_string(), &credential);
|
||||
let videos = favorite
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -178,7 +244,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Favorite { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试稍后再看
|
||||
let watch_later = WatchLater::new(&bili_client);
|
||||
let watch_later = WatchLater::new(&bili_client, &credential);
|
||||
let videos = watch_later
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -188,7 +254,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::WatchLater { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试投稿
|
||||
let submission = Submission::new(&bili_client, "956761".to_string());
|
||||
let submission = Submission::new(&bili_client, "956761".to_string(), &credential);
|
||||
let videos = submission
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -197,17 +263,32 @@ mod tests {
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Submission { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试动态
|
||||
let dynamic = Dynamic::new(&bili_client, "659898".to_string(), &credential);
|
||||
let videos = dynamic
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
.filter_map(|v| futures::future::ready(v.ok()))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Dynamic { .. })));
|
||||
assert!(videos.iter().skip(1).rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_subtitle_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
|
||||
panic!("获取 mixin key 失败");
|
||||
};
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string());
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string(), &credential);
|
||||
let pages = video.get_pages().await?;
|
||||
println!("pages: {:?}", pages);
|
||||
let subtitles = video.get_subtitles(&pages[0]).await?;
|
||||
@@ -220,4 +301,116 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_upower_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
for (bvid, (upower_exclusive, upower_play)) in [
|
||||
("BV1HxXwYEEqt", (true, false)), // 充电专享且无权观看
|
||||
("BV16w41187fx", (true, true)), // 充电专享但有权观看
|
||||
("BV1n34jzPEYq", (false, false)), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail {
|
||||
is_upower_exclusive,
|
||||
is_upower_play,
|
||||
..
|
||||
} = info
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(is_upower_exclusive, upower_exclusive, "bvid: {}", bvid);
|
||||
assert_eq!(is_upower_play, upower_play, "bvid: {}", bvid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_ep_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
for (bvid, redirect_is_none) in [
|
||||
("BV1SF411g796", false), // EP
|
||||
("BV13xtnzPEye", false), // 番剧
|
||||
("BV1kT4NzTEZj", true), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail { redirect_url, .. } = info else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(redirect_url.is_none(), redirect_is_none, "bvid: {}", bvid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wbi_key() -> Result<()> {
|
||||
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 = key.into_mixin_key().context("no mixin key")?;
|
||||
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
|
||||
let client = Client::new();
|
||||
let mut req = client
|
||||
.request(Method::GET, "https://www.baidu.com/", None)
|
||||
.query(&[("foo", "114"), ("bar", "514")])
|
||||
.query(&[("zab", "1919810")])
|
||||
.build()?;
|
||||
sign_request(&mut req, key.as_str(), 1702204169).unwrap();
|
||||
let query: Vec<_> = req.url().query_pairs().collect();
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![
|
||||
("foo".into(), "114".into()),
|
||||
("bar".into(), "514".into()),
|
||||
("zab".into(), "1919810".into()),
|
||||
("w_rid".into(), "8f6f2b5b3d485fe1886cec6a0be8c5d4".into()),
|
||||
("wts".into(), "1702204169".into()),
|
||||
]
|
||||
);
|
||||
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 = key.into_mixin_key().context("no mixin key")?;
|
||||
let mut req = client
|
||||
.request(Method::GET, "https://www.baidu.com/", None)
|
||||
.query(&[("mid", "11997177"), ("token", "")])
|
||||
.query(&[("platform", "web"), ("web_location", "1550101")])
|
||||
.build()?;
|
||||
sign_request(&mut req, key.as_str(), 1703513649).unwrap();
|
||||
let query: Vec<_> = req.url().query_pairs().collect();
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![
|
||||
("mid".into(), "11997177".into()),
|
||||
("token".into(), "".into()),
|
||||
("platform".into(), "web".into()),
|
||||
("web_location".into(), "1550101".into()),
|
||||
("w_rid".into(), "7d4428b3f2f9ee2811e116ec6fd41a4f".into()),
|
||||
("wts".into(), "1703513649".into()),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use arc_swap::access::Access;
|
||||
use async_stream::try_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, MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Dynamic, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
pub struct Submission<'a> {
|
||||
client: &'a BiliClient,
|
||||
upper_id: String,
|
||||
pub upper_id: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> From<Submission<'a>> for Dynamic<'a> {
|
||||
fn from(submission: Submission<'a>) -> Self {
|
||||
Dynamic::new(submission.client, submission.upper_id, submission.credential)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Submission<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String) -> Self {
|
||||
Self { client, upper_id }
|
||||
pub fn new(client: &'a BiliClient, upper_id: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
upper_id,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/card",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("mid", self.upper_id.as_str())])
|
||||
.send()
|
||||
@@ -35,20 +48,22 @@ impl<'a> Submission<'a> {
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/space/wbi/arc/search")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/space/wbi/arc/search",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("mid", self.upper_id.as_str()),
|
||||
("order", "pubdate"),
|
||||
("order_avoided", "true"),
|
||||
("platform", "web"),
|
||||
("web_location", "1550101"),
|
||||
("pn", page.to_string().as_str()),
|
||||
("ps", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.query(&[
|
||||
("mid", self.upper_id.as_str()),
|
||||
("order", "pubdate"),
|
||||
("order_avoided", "true"),
|
||||
("platform", "web"),
|
||||
("web_location", "1550101"),
|
||||
("ps", "30"),
|
||||
])
|
||||
.query(&[("pn", page)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct SubTitleItem {
|
||||
impl SubTitleInfo {
|
||||
pub fn is_ai_sub(&self) -> bool {
|
||||
// ai: aisubtitle.hdslb.com/bfs/ai_subtitle/xxxx
|
||||
// 非 ai: aisubtitle.hdslb.com/bfs/subtitle/xxxx
|
||||
// 非 ai:aisubtitle.hdslb.com/bfs/subtitle/xxxx
|
||||
self.subtitle_url.contains("ai_subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use prost::Message;
|
||||
@@ -6,39 +6,16 @@ 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::subtitle::{SubTitle, SubTitleBody, SubTitleInfo, SubTitlesInfo};
|
||||
use crate::bilibili::{MIXIN_KEY, Validate, VideoInfo};
|
||||
|
||||
static MASK_CODE: u64 = 2251799813685247;
|
||||
static XOR_CODE: u64 = 23442827791579;
|
||||
static BASE: u64 = 58;
|
||||
static DATA: &[char] = &[
|
||||
'F', 'c', 'w', 'A', 'P', 'N', 'K', 'T', 'M', 'u', 'g', '3', 'G', 'V', '5', 'L', 'j', '7', 'E', 'J', 'n', 'H', 'p',
|
||||
'W', 's', 'x', '4', 't', 'b', '8', 'h', 'a', 'Y', 'e', 'v', 'i', 'q', 'B', 'z', '6', 'r', 'k', 'C', 'y', '1', '2',
|
||||
'm', 'U', 'S', 'D', 'Q', 'X', '9', 'R', 'd', 'o', 'Z', 'f',
|
||||
];
|
||||
use crate::bilibili::{Credential, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
|
||||
pub struct Video<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub aid: String,
|
||||
pub bvid: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Tag {
|
||||
pub tag_name: String,
|
||||
}
|
||||
|
||||
impl serde::Serialize for Tag {
|
||||
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.tag_name)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct PageInfo {
|
||||
pub cid: i64,
|
||||
@@ -58,18 +35,26 @@ pub struct Dimension {
|
||||
}
|
||||
|
||||
impl<'a> Video<'a> {
|
||||
pub fn new(client: &'a BiliClient, bvid: String) -> Self {
|
||||
let aid = bvid_to_aid(&bvid).to_string();
|
||||
Self { client, aid, bvid }
|
||||
pub fn new(client: &'a BiliClient, bvid: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bvid,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
/// 直接调用视频信息接口获取详细的视频信息,视频信息中包含了视频的分页信息
|
||||
pub async fn get_view_info(&self) -> Result<VideoInfo> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/wbi/view",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
@@ -79,13 +64,17 @@ impl<'a> Video<'a> {
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[cfg(test)]
|
||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/pagelist")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/player/pagelist",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
@@ -95,24 +84,33 @@ impl<'a> Video<'a> {
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
|
||||
let mut res = self
|
||||
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||
let res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/view/detail/tag",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
Ok(res["data"]
|
||||
.as_array()
|
||||
.context("tags is not an array")?
|
||||
.iter()
|
||||
.filter_map(|v| v["tag_name"].as_str().map(String::from))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter<'a>> {
|
||||
let tasks = FuturesUnordered::new();
|
||||
for i in 1..=(page.duration + 359) / 360 {
|
||||
for i in 1..=page.duration.div_ceil(360) {
|
||||
tasks.push(self.get_danmaku_segment(page, i as i64));
|
||||
}
|
||||
let result: Vec<Vec<DanmakuElem>> = tasks.try_collect().await?;
|
||||
@@ -124,9 +122,14 @@ impl<'a> Video<'a> {
|
||||
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")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v2/dm/wbi/web/seg.so",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("type", 1), ("oid", page.cid), ("segment_index", segment_idx)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
@@ -144,19 +147,21 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_page_analyzer(&self, page: &PageInfo) -> Result<PageAnalyzer> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/playurl")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/player/wbi/playurl",
|
||||
self.credential,
|
||||
)
|
||||
.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(),
|
||||
))
|
||||
.query(&[
|
||||
("bvid", self.bvid.as_str()),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
("fourk", "1"),
|
||||
])
|
||||
.query(&[("cid", page.cid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
@@ -169,12 +174,11 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_subtitles(&self, page: &PageInfo) -> Result<Vec<SubTitle>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2")
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2", self.credential)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![("cid", &page.cid.to_string()), ("bvid", &self.bvid), ("aid", &self.aid)],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.query(&[("bvid", self.bvid.as_str())])
|
||||
.query(&[("cid", page.cid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
@@ -182,14 +186,18 @@ impl<'a> Video<'a> {
|
||||
.await?
|
||||
.validate()?;
|
||||
// 接口返回的信息,包含了一系列的字幕,每个字幕包含了字幕的语言和 json 下载地址
|
||||
let subtitles_info: SubTitlesInfo = serde_json::from_value(res["data"]["subtitle"].take())?;
|
||||
let tasks = subtitles_info
|
||||
.subtitles
|
||||
.into_iter()
|
||||
.filter(|v| !v.is_ai_sub())
|
||||
.map(|v| self.get_subtitle(v))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect().await
|
||||
match serde_json::from_value::<Option<SubTitlesInfo>>(res["data"]["subtitle"].take())? {
|
||||
Some(subtitles_info) => {
|
||||
let tasks = subtitles_info
|
||||
.subtitles
|
||||
.into_iter()
|
||||
.filter(|v| !v.is_ai_sub())
|
||||
.map(|v| self.get_subtitle(v))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect().await
|
||||
}
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_subtitle(&self, info: SubTitleInfo) -> Result<SubTitle> {
|
||||
@@ -206,26 +214,3 @@ impl<'a> Video<'a> {
|
||||
Ok(SubTitle { lan: info.lan, body })
|
||||
}
|
||||
}
|
||||
|
||||
fn bvid_to_aid(bvid: &str) -> u64 {
|
||||
let mut bvid = bvid.chars().collect::<Vec<_>>();
|
||||
(bvid[3], bvid[9]) = (bvid[9], bvid[3]);
|
||||
(bvid[4], bvid[7]) = (bvid[7], bvid[4]);
|
||||
let mut tmp = 0u64;
|
||||
for char in bvid.into_iter().skip(3) {
|
||||
let idx = DATA.iter().position(|&x| x == char).expect("invalid bvid");
|
||||
tmp = tmp * BASE + idx as u64;
|
||||
}
|
||||
(tmp & MASK_CODE) ^ XOR_CODE
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bvid_to_aid() {
|
||||
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
||||
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,24 @@ use async_stream::try_stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
|
||||
pub struct WatchLater<'a> {
|
||||
client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> WatchLater<'a> {
|
||||
pub fn new(client: &'a BiliClient) -> Self {
|
||||
Self { client }
|
||||
pub fn new(client: &'a BiliClient, credential: &'a Credential) -> Self {
|
||||
Self { client, credential }
|
||||
}
|
||||
|
||||
async fn get_videos(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v2/history/toview")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v2/history/toview",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.send()
|
||||
.await?
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
pub static ARGS: LazyLock<Args> = LazyLock::new(Args::parse);
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "Bili-Sync", version = detail_version(), about, long_about = None)]
|
||||
pub struct Args {
|
||||
@@ -10,6 +13,9 @@ pub struct Args {
|
||||
|
||||
#[arg(short, long, default_value = "None,bili_sync=info", env = "RUST_LOG")]
|
||||
pub log_level: String,
|
||||
|
||||
#[arg(short, long, env = "DISABLE_CREDENTIAL_REFRESH")]
|
||||
pub disable_credential_refresh: bool,
|
||||
}
|
||||
|
||||
mod built_info {
|
||||
137
crates/bili_sync/src/config/current.rs
Normal file
137
crates/bili_sync/src/config/current.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use croner::parser::CronParser;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||
use crate::config::default::{
|
||||
default_auth_token, default_bind_address, default_collection_path, default_favorite_path, default_submission_path,
|
||||
default_time_format,
|
||||
};
|
||||
use crate::config::item::{ConcurrentLimit, NFOTimeType, SkipOption, Trigger};
|
||||
use crate::notifier::Notifier;
|
||||
use crate::utils::model::{load_db_config, save_db_config};
|
||||
|
||||
pub static CONFIG_DIR: LazyLock<PathBuf> =
|
||||
LazyLock::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
pub struct Config {
|
||||
pub auth_token: String,
|
||||
pub bind_address: String,
|
||||
pub credential: Credential,
|
||||
pub filter_option: FilterOption,
|
||||
pub danmaku_option: DanmakuOption,
|
||||
#[serde(default)]
|
||||
pub skip_option: SkipOption,
|
||||
pub video_name: String,
|
||||
pub page_name: String,
|
||||
#[serde(default)]
|
||||
pub notifiers: Option<Arc<Vec<Notifier>>>,
|
||||
#[serde(default = "default_favorite_path")]
|
||||
pub favorite_default_path: String,
|
||||
#[serde(default = "default_collection_path")]
|
||||
pub collection_default_path: String,
|
||||
#[serde(default = "default_submission_path")]
|
||||
pub submission_default_path: String,
|
||||
pub interval: Trigger,
|
||||
pub upper_path: PathBuf,
|
||||
pub nfo_time_type: NFOTimeType,
|
||||
pub concurrent_limit: ConcurrentLimit,
|
||||
pub time_format: String,
|
||||
pub cdn_sorting: bool,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn load_from_database(connection: &DatabaseConnection) -> Result<Option<Result<Self>>> {
|
||||
load_db_config(connection).await
|
||||
}
|
||||
|
||||
pub async fn save_to_database(&self, connection: &DatabaseConnection) -> Result<()> {
|
||||
save_db_config(self, connection).await
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
if !self.upper_path.is_absolute() {
|
||||
errors.push("up 主头像保存的路径应为绝对路径");
|
||||
}
|
||||
if self.video_name.is_empty() {
|
||||
errors.push("未设置 video_name 模板");
|
||||
}
|
||||
if self.page_name.is_empty() {
|
||||
errors.push("未设置 page_name 模板");
|
||||
}
|
||||
let credential = &self.credential;
|
||||
if credential.sessdata.is_empty()
|
||||
|| credential.bili_jct.is_empty()
|
||||
|| credential.buvid3.is_empty()
|
||||
|| credential.dedeuserid.is_empty()
|
||||
|| credential.ac_time_value.is_empty()
|
||||
{
|
||||
errors.push("Credential 信息不完整,请确保填写完整");
|
||||
}
|
||||
if !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
|
||||
errors.push("video 和 page 允许的并发数必须大于 0");
|
||||
}
|
||||
match &self.interval {
|
||||
Trigger::Interval(secs) => {
|
||||
if *secs <= 60 {
|
||||
errors.push("下载任务执行间隔时间必须大于 60 秒");
|
||||
}
|
||||
}
|
||||
Trigger::Cron(cron) => {
|
||||
if CronParser::builder()
|
||||
.seconds(croner::parser::Seconds::Required)
|
||||
.dom_and_dow(true)
|
||||
.build()
|
||||
.parse(cron)
|
||||
.is_err()
|
||||
{
|
||||
errors.push("Cron 表达式无效,正确格式为“秒 分 时 日 月 周”");
|
||||
}
|
||||
}
|
||||
};
|
||||
if !errors.is_empty() {
|
||||
bail!(
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|e| format!("- {}", e))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_token: default_auth_token(),
|
||||
bind_address: default_bind_address(),
|
||||
credential: Credential::default(),
|
||||
filter_option: FilterOption::default(),
|
||||
danmaku_option: DanmakuOption::default(),
|
||||
skip_option: SkipOption::default(),
|
||||
video_name: "{{title}}".to_owned(),
|
||||
page_name: "{{bvid}}".to_owned(),
|
||||
notifiers: None,
|
||||
favorite_default_path: default_favorite_path(),
|
||||
collection_default_path: default_collection_path(),
|
||||
submission_default_path: default_submission_path(),
|
||||
interval: Trigger::default(),
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
nfo_time_type: NFOTimeType::FavTime,
|
||||
concurrent_limit: ConcurrentLimit::default(),
|
||||
time_format: default_time_format(),
|
||||
cdn_sorting: false,
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/bili_sync/src/config/default.rs
Normal file
30
crates/bili_sync/src/config/default.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use rand::seq::IndexedRandom;
|
||||
|
||||
/// 默认的 auth_token 实现,生成随机 16 位字符串
|
||||
pub(super) fn default_auth_token() -> String {
|
||||
let byte_choices = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=";
|
||||
let mut rng = rand::rng();
|
||||
(0..16)
|
||||
.map(|_| *(byte_choices.choose(&mut rng).expect("choose byte failed")) as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn default_bind_address() -> String {
|
||||
"0.0.0.0:12345".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn default_time_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
pub fn default_favorite_path() -> String {
|
||||
"收藏夹/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_collection_path() -> String {
|
||||
"合集/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_submission_path() -> String {
|
||||
"投稿/{{name}}".to_owned()
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use handlebars::handlebars_helper;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::clap::Args;
|
||||
use crate::config::item::PathSafeTemplate;
|
||||
|
||||
/// 全局的 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)
|
||||
.expect("failed to register video template");
|
||||
handlebars
|
||||
.path_safe_register("page", &CONFIG.page_name)
|
||||
.expect("failed to register page template");
|
||||
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))]
|
||||
fn load_config() -> Config {
|
||||
info!("开始加载配置文件..");
|
||||
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().expect("保存默认配置时遇到错误");
|
||||
info!("检查配置文件..");
|
||||
config.check();
|
||||
info!("配置文件检查通过");
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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),
|
||||
cdn_sorting: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
102
crates/bili_sync/src/config/handlebar.rs
Normal file
102
crates/bili_sync/src/config/handlebar.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use handlebars::handlebars_helper;
|
||||
|
||||
use crate::config::versioned_cache::VersionedCache;
|
||||
use crate::config::{Config, PathSafeTemplate};
|
||||
use crate::notifier::{Notifier, webhook_template_content, webhook_template_key};
|
||||
|
||||
pub static TEMPLATE: LazyLock<VersionedCache<handlebars::Handlebars<'static>>> =
|
||||
LazyLock::new(|| VersionedCache::new(create_template).expect("Failed to create handlebars template"));
|
||||
|
||||
fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars.path_safe_register("video", config.video_name.clone())?;
|
||||
handlebars.path_safe_register("page", config.page_name.clone())?;
|
||||
handlebars.path_safe_register("favorite_default_path", config.favorite_default_path.clone())?;
|
||||
handlebars.path_safe_register("collection_default_path", config.collection_default_path.clone())?;
|
||||
handlebars.path_safe_register("submission_default_path", config.submission_default_path.clone())?;
|
||||
if let Some(notifiers) = &config.notifiers {
|
||||
for notifier in notifiers.iter() {
|
||||
if let Notifier::Webhook { url, template, .. } = notifier {
|
||||
handlebars.register_template_string(&webhook_template_key(url), webhook_template_content(template))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(handlebars)
|
||||
}
|
||||
|
||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||
if s.chars().count() > len {
|
||||
s.chars().take(len).collect::<String>()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_template_usage() {
|
||||
let mut template = handlebars::Handlebars::new();
|
||||
template.register_helper("truncate", Box::new(truncate));
|
||||
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
|
||||
.path_safe_render("video", &json!({"bvid": "BV1b5411h7g7"}))
|
||||
.unwrap(),
|
||||
"testBV1b5411h7g7test"
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render(
|
||||
"test_truncate",
|
||||
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
|
||||
编译将发生在一个被称作「Cargo」的构建系统中。在这里,被引用的指针将被授予「生命周期」之力,导引对象安全。\
|
||||
你将扮演一位名为「Rustacean」的神秘角色,在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
|
||||
征服她们、通过编译同时,逐步发掘「C++」程序崩溃的真相。"})
|
||||
)
|
||||
.unwrap(),
|
||||
"哈哈,你说得对,但是 Rust 是由 Mozilla 自主研发的一"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,10 @@
|
||||
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)]
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NFOTimeType {
|
||||
#[default]
|
||||
@@ -26,14 +13,33 @@ pub enum NFOTimeType {
|
||||
}
|
||||
|
||||
/// 并发下载相关的配置
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ConcurrentLimit {
|
||||
pub video: usize,
|
||||
pub page: usize,
|
||||
pub rate_limit: Option<RateLimit>,
|
||||
#[serde(default)]
|
||||
pub download: ConcurrentDownloadLimit,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ConcurrentDownloadLimit {
|
||||
pub enable: bool,
|
||||
pub concurrency: usize,
|
||||
pub threshold: u64,
|
||||
}
|
||||
|
||||
impl Default for ConcurrentDownloadLimit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable: true,
|
||||
concurrency: 4,
|
||||
threshold: 20 * (1 << 20), // 20 MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct RateLimit {
|
||||
pub limit: usize,
|
||||
pub duration: u64,
|
||||
@@ -49,18 +55,42 @@ impl Default for ConcurrentLimit {
|
||||
limit: 4,
|
||||
duration: 250,
|
||||
}),
|
||||
download: ConcurrentDownloadLimit::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct SkipOption {
|
||||
pub no_poster: bool,
|
||||
pub no_video_nfo: bool,
|
||||
pub no_upper: bool,
|
||||
pub no_danmaku: bool,
|
||||
pub no_subtitle: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum Trigger {
|
||||
Interval(u64),
|
||||
Cron(String),
|
||||
}
|
||||
|
||||
impl Default for Trigger {
|
||||
fn default() -> Self {
|
||||
Trigger::Interval(1200)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PathSafeTemplate {
|
||||
fn path_safe_register(&mut self, name: &'static str, template: &'static str) -> Result<()>;
|
||||
fn path_safe_register(&mut self, name: &'static str, template: impl Into<String>) -> 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<()> {
|
||||
fn path_safe_register(&mut self, name: &'static str, template: impl Into<String>) -> Result<()> {
|
||||
let template = template.into();
|
||||
Ok(self.register_template_string(name, template.replace(std::path::MAIN_SEPARATOR_STR, "__SEP__"))?)
|
||||
}
|
||||
|
||||
@@ -68,72 +98,3 @@ impl PathSafeTemplate for handlebars::Handlebars<'_> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,187 +1,15 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod clap;
|
||||
mod global;
|
||||
mod args;
|
||||
mod current;
|
||||
mod default;
|
||||
mod handlebar;
|
||||
mod item;
|
||||
mod versioned_cache;
|
||||
mod versioned_config;
|
||||
|
||||
use crate::adapter::Args;
|
||||
use crate::bilibili::{CollectionItem, Credential, DanmakuOption, FilterOption};
|
||||
pub use crate::config::clap::version;
|
||||
pub use crate::config::global::{ARGS, CONFIG, CONFIG_DIR, TEMPLATE};
|
||||
use crate::config::item::{ConcurrentLimit, deserialize_collection_list, serialize_collection_list};
|
||||
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit, WatchLaterConfig};
|
||||
|
||||
fn default_time_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
/// 默认的 auth_token 实现,生成随机 16 位字符串
|
||||
fn default_auth_token() -> Option<String> {
|
||||
let byte_choices = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=";
|
||||
let mut rng = rand::thread_rng();
|
||||
Some(
|
||||
(0..16)
|
||||
.map(|_| *(byte_choices.choose(&mut rng).expect("choose byte failed")) as char)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_bind_address() -> String {
|
||||
"0.0.0.0:12345".to_string()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_auth_token")]
|
||||
pub auth_token: Option<String>,
|
||||
#[serde(default = "default_bind_address")]
|
||||
pub bind_address: String,
|
||||
pub credential: ArcSwapOption<Credential>,
|
||||
pub filter_option: FilterOption,
|
||||
#[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,
|
||||
#[serde(default)]
|
||||
pub cdn_sorting: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_token: default_auth_token(),
|
||||
bind_address: default_bind_address(),
|
||||
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(),
|
||||
cdn_sorting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)?)
|
||||
}
|
||||
|
||||
pub fn as_video_sources(&self) -> Vec<(Args<'_>, &PathBuf)> {
|
||||
let mut params = Vec::new();
|
||||
self.favorite_list
|
||||
.iter()
|
||||
.for_each(|(fid, path)| params.push((Args::Favorite { fid }, path)));
|
||||
self.collection_list
|
||||
.iter()
|
||||
.for_each(|(collection_item, path)| params.push((Args::Collection { collection_item }, path)));
|
||||
if self.watch_later.enabled {
|
||||
params.push((Args::WatchLater, &self.watch_later.path));
|
||||
}
|
||||
self.submission_list
|
||||
.iter()
|
||||
.for_each(|(upper_id, path)| params.push((Args::Submission { upper_id }, path)));
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn check(&self) {
|
||||
let mut ok = true;
|
||||
let video_sources = self.as_video_sources();
|
||||
if video_sources.is_empty() {
|
||||
ok = false;
|
||||
error!("没有配置任何需要扫描的内容,程序空转没有意义");
|
||||
}
|
||||
for (args, path) in video_sources {
|
||||
if !path.is_absolute() {
|
||||
ok = false;
|
||||
error!("{:?} 保存的路径应为绝对路径,检测到: {}", args, path.display());
|
||||
}
|
||||
}
|
||||
if !self.upper_path.is_absolute() {
|
||||
ok = false;
|
||||
error!("up 主头像保存的路径应为绝对路径");
|
||||
}
|
||||
if self.video_name.is_empty() {
|
||||
ok = false;
|
||||
error!("未设置 video_name 模板");
|
||||
}
|
||||
if self.page_name.is_empty() {
|
||||
ok = false;
|
||||
error!("未设置 page_name 模板");
|
||||
}
|
||||
let credential = self.credential.load();
|
||||
match credential.as_deref() {
|
||||
Some(credential) => {
|
||||
if credential.sessdata.is_empty()
|
||||
|| credential.bili_jct.is_empty()
|
||||
|| credential.buvid3.is_empty()
|
||||
|| credential.dedeuserid.is_empty()
|
||||
|| credential.ac_time_value.is_empty()
|
||||
{
|
||||
ok = false;
|
||||
error!("Credential 信息不完整,请确保填写完整");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ok = false;
|
||||
error!("未设置 Credential 信息");
|
||||
}
|
||||
}
|
||||
if !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
|
||||
ok = false;
|
||||
error!("video 和 page 允许的并发数必须大于 0");
|
||||
}
|
||||
if !ok {
|
||||
panic!(
|
||||
"位于 {} 的配置文件不合法,请参考提示信息修复后继续运行",
|
||||
CONFIG_DIR.join("config.toml").display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::config::args::{ARGS, version};
|
||||
pub use crate::config::current::{CONFIG_DIR, Config};
|
||||
pub(crate) use crate::config::default::default_bind_address;
|
||||
pub use crate::config::handlebar::TEMPLATE;
|
||||
pub use crate::config::item::{ConcurrentDownloadLimit, NFOTimeType, PathSafeTemplate, RateLimit, Trigger};
|
||||
pub use crate::config::versioned_cache::VersionedCache;
|
||||
pub use crate::config::versioned_config::VersionedConfig;
|
||||
|
||||
56
crates/bili_sync/src/config/versioned_cache.rs
Normal file
56
crates/bili_sync/src/config/versioned_cache.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::{ArcSwap, Guard};
|
||||
use tokio_util::future::FutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::{Config, VersionedConfig};
|
||||
|
||||
pub struct VersionedCache<T> {
|
||||
inner: Arc<ArcSwap<T>>,
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
/// 一个跟随全局配置变化自动更新的缓存
|
||||
impl<T: Send + Sync + 'static> VersionedCache<T> {
|
||||
pub fn new(builder: fn(&Config) -> Result<T>) -> Result<Self> {
|
||||
let mut rx = VersionedConfig::get().subscribe();
|
||||
let initial_value = builder(&rx.borrow_and_update())?;
|
||||
let cancel_token = CancellationToken::new();
|
||||
let inner = Arc::new(ArcSwap::from_pointee(initial_value));
|
||||
let inner_clone = inner.clone();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
while rx.changed().await.is_ok() {
|
||||
match builder(&rx.borrow()) {
|
||||
Ok(new_value) => {
|
||||
inner_clone.store(Arc::new(new_value));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update versioned cache: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
Ok(Self { inner, cancel_token })
|
||||
}
|
||||
|
||||
/// 获取一个临时的只读引用
|
||||
pub fn read(&self) -> Guard<Arc<T>> {
|
||||
self.inner.load()
|
||||
}
|
||||
|
||||
/// 获取当前缓存的完整快照
|
||||
pub fn snapshot(&self) -> Arc<T> {
|
||||
self.inner.load_full()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for VersionedCache<T> {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
126
crates/bili_sync/src/config/versioned_config.rs
Normal file
126
crates/bili_sync/src/config/versioned_config.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use arc_swap::{ArcSwap, Guard};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::sync::{OnceCell, watch};
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
use crate::config::Config;
|
||||
|
||||
static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
|
||||
|
||||
pub struct VersionedConfig {
|
||||
inner: ArcSwap<Config>,
|
||||
update_lock: tokio::sync::Mutex<()>,
|
||||
tx: watch::Sender<Arc<Config>>,
|
||||
rx: watch::Receiver<Arc<Config>>,
|
||||
}
|
||||
|
||||
impl VersionedConfig {
|
||||
/// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误
|
||||
pub async fn init(connection: &DatabaseConnection) -> Result<&'static VersionedConfig> {
|
||||
VERSIONED_CONFIG
|
||||
.get_or_try_init(|| async move {
|
||||
let mut config = match Config::load_from_database(connection).await? {
|
||||
Some(Ok(config)) => config,
|
||||
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
|
||||
None => {
|
||||
let config = Config::default();
|
||||
warn!(
|
||||
"生成 auth_token:{},可使用该 token 登录 web UI,该信息仅在首次运行时打印",
|
||||
config.auth_token
|
||||
);
|
||||
config.save_to_database(connection).await?;
|
||||
config
|
||||
}
|
||||
};
|
||||
// version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空
|
||||
config.version = 0;
|
||||
Ok(VersionedConfig::new(config))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 仅在测试环境使用,该方法会尝试从测试数据库中加载配置并写入到全局的 VERSIONED_CONFIG
|
||||
pub async fn init_for_test(connection: &DatabaseConnection) -> Result<&'static VersionedConfig> {
|
||||
VERSIONED_CONFIG
|
||||
.get_or_try_init(|| async move {
|
||||
let Some(Ok(config)) = Config::load_from_database(&connection).await? else {
|
||||
bail!("no config found in test database");
|
||||
};
|
||||
Ok(VersionedConfig::new(config))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
/// 获取全局的 `VersionedConfig`,如果未初始化则会 panic
|
||||
pub fn get() -> &'static VersionedConfig {
|
||||
VERSIONED_CONFIG.get().expect("VERSIONED_CONFIG is not initialized")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 尝试获取全局的 `VersionedConfig`,如果未初始化则退回默认配置
|
||||
pub fn get() -> &'static VersionedConfig {
|
||||
use std::sync::LazyLock;
|
||||
static FALLBACK_CONFIG: LazyLock<VersionedConfig> = LazyLock::new(|| VersionedConfig::new(Config::default()));
|
||||
// 优先从全局变量获取,未初始化则退回默认配置
|
||||
return VERSIONED_CONFIG.get().unwrap_or_else(|| &FALLBACK_CONFIG);
|
||||
}
|
||||
|
||||
fn new(config: Config) -> Self {
|
||||
let inner = ArcSwap::from_pointee(config);
|
||||
let (tx, rx) = watch::channel(inner.load_full());
|
||||
Self {
|
||||
inner,
|
||||
update_lock: tokio::sync::Mutex::new(()),
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Guard<Arc<Config>> {
|
||||
self.inner.load()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Arc<Config> {
|
||||
self.inner.load_full()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> watch::Receiver<Arc<Config>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
|
||||
pub async fn update_credential(
|
||||
&self,
|
||||
new_credential: Credential,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Arc<Config>> {
|
||||
let _lock = self.update_lock.lock().await;
|
||||
let mut new_config = self.inner.load().as_ref().clone();
|
||||
new_config.credential = new_credential;
|
||||
new_config.version += 1;
|
||||
new_config.save_to_database(connection).await?;
|
||||
let new_config = Arc::new(new_config);
|
||||
self.inner.store(new_config.clone());
|
||||
self.tx.send(new_config.clone())?;
|
||||
Ok(new_config)
|
||||
}
|
||||
|
||||
/// 外部 API 会调用这个方法,如果更新失败直接返回错误
|
||||
pub async fn update(&self, mut new_config: Config, connection: &DatabaseConnection) -> Result<Arc<Config>> {
|
||||
let _lock = self.update_lock.lock().await;
|
||||
let old_config = self.inner.load();
|
||||
if old_config.version != new_config.version {
|
||||
bail!("配置版本不匹配,请刷新页面修改后重新提交");
|
||||
}
|
||||
new_config.version += 1;
|
||||
new_config.save_to_database(connection).await?;
|
||||
let new_config = Arc::new(new_config);
|
||||
self.inner.store(new_config.clone());
|
||||
self.tx.send(new_config.clone())?;
|
||||
Ok(new_config)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,71 @@
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||
use sea_orm::sqlx::{ConnectOptions as SqlxConnectOptions, Sqlite};
|
||||
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, SqlxSqliteConnector, Statement};
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
|
||||
fn database_url() -> String {
|
||||
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
|
||||
fn database_url(path: &Path) -> String {
|
||||
format!("sqlite://{}?mode=rwc", path.to_string_lossy())
|
||||
}
|
||||
|
||||
async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url());
|
||||
async fn database_connection(database_url: &str) -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url);
|
||||
option
|
||||
.max_connections(100)
|
||||
.max_connections(50)
|
||||
.min_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(90));
|
||||
Ok(Database::connect(option).await?)
|
||||
.acquire_timeout(Duration::from_secs(90));
|
||||
let connect_option = option
|
||||
.get_url()
|
||||
.parse::<SqliteConnectOptions>()
|
||||
.context("Failed to parse database URL")?
|
||||
.disable_statement_logging()
|
||||
.busy_timeout(Duration::from_secs(90))
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.optimize_on_close(true, None);
|
||||
Ok(SqlxSqliteConnector::from_sqlx_sqlite_pool(
|
||||
option
|
||||
.sqlx_pool_options::<Sqlite>()
|
||||
.connect_with(connect_option)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn migrate_database() -> Result<()> {
|
||||
async fn migrate_database(database_url: &str) -> Result<()> {
|
||||
// 注意此处使用内部构造的 DatabaseConnection,而不是通过 database_connection() 获取
|
||||
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
|
||||
let connection = Database::connect(database_url()).await?;
|
||||
let connection = Database::connect(database_url).await?;
|
||||
// 避免 https://github.com/amtoaer/bili-sync/issues/571 问题,迁移前根据 migration 确认当前版本
|
||||
// 如果用户从 2.6.0 以下版本直接升级,migration 不满足需求,直接报错而不执行迁移
|
||||
if connection
|
||||
.query_one(Statement::from_string(
|
||||
connection.get_database_backend(),
|
||||
"SELECT 1 FROM seaql_migrations WHERE version = 'm20250613_043257_add_config';",
|
||||
))
|
||||
.await
|
||||
.is_ok_and(|res| res.is_none())
|
||||
{
|
||||
// 查询成功且结果为空,即没有 m20250613_043257_add_config,说明版本低于 2.6.0
|
||||
bail!("该版本仅支持从 2.6.x 以上的版本升级,请先升级至 2.6.x 或 2.7.x 完成配置迁移,再升级至最新版本。");
|
||||
}
|
||||
Ok(Migrator::up(&connection, None).await?)
|
||||
}
|
||||
|
||||
/// 进行数据库迁移并获取数据库连接,供外部使用
|
||||
pub async fn setup_database() -> DatabaseConnection {
|
||||
migrate_database().await.expect("数据库迁移失败");
|
||||
database_connection().await.expect("获取数据库连接失败")
|
||||
pub async fn setup_database(path: &Path) -> Result<DatabaseConnection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.context(
|
||||
"Failed to create config directory. Please check if you have granted necessary permissions to your folder.",
|
||||
)?;
|
||||
}
|
||||
let database_url = database_url(path);
|
||||
migrate_database(&database_url)
|
||||
.await
|
||||
.context("Failed to migrate database")?;
|
||||
database_connection(&database_url)
|
||||
.await
|
||||
.context("Failed to connect to database")
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
use core::str;
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use async_tempfile::TempFile;
|
||||
use futures::TryStreamExt;
|
||||
use reqwest::Method;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use reqwest::{Method, StatusCode, header};
|
||||
use tokio::fs::{self};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::process::Command;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::bilibili::Client;
|
||||
use crate::config::ConcurrentDownloadLimit;
|
||||
|
||||
pub struct Downloader {
|
||||
client: Client,
|
||||
}
|
||||
@@ -21,63 +28,317 @@ impl Downloader {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn fetch(&self, url: &str, path: &Path) -> Result<()> {
|
||||
pub async fn fetch(&self, url: &str, path: &Path, concurrent_download: &ConcurrentDownloadLimit) -> Result<()> {
|
||||
let mut temp_file = TempFile::new().await?;
|
||||
self.fetch_internal(url, &mut temp_file, false, concurrent_download)
|
||||
.await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut file = File::create(path).await?;
|
||||
fs::copy(temp_file.file_path(), path).await?;
|
||||
// temp_file 的 drop 需要 std::fs::remove_file
|
||||
// 如果交由 rust 自动执行虽然逻辑正确但会略微阻塞异步上下文
|
||||
// 尽量主动调用,保证正常执行的情况下文件清除操作由 spawn_blocking 在专门线程中完成
|
||||
temp_file.drop_async().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn multi_fetch(
|
||||
&self,
|
||||
urls: &[&str],
|
||||
path: &Path,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let temp_file = self.multi_fetch_internal(urls, true, concurrent_download).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::copy(temp_file.file_path(), path).await?;
|
||||
temp_file.drop_async().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn multi_fetch_and_merge(
|
||||
&self,
|
||||
video_urls: &[&str],
|
||||
audio_urls: &[&str],
|
||||
path: &Path,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let (video_temp_file, audio_temp_file) = tokio::try_join!(
|
||||
self.multi_fetch_internal(video_urls, true, concurrent_download),
|
||||
self.multi_fetch_internal(audio_urls, true, concurrent_download)
|
||||
)?;
|
||||
let final_temp_file = TempFile::new().await?;
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
video_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
"-i",
|
||||
audio_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-strict",
|
||||
"unofficial",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-y",
|
||||
final_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run ffmpeg")?;
|
||||
if !output.status.success() {
|
||||
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::copy(final_temp_file.file_path(), path).await?;
|
||||
tokio::join!(
|
||||
video_temp_file.drop_async(),
|
||||
audio_temp_file.drop_async(),
|
||||
final_temp_file.drop_async()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn multi_fetch_internal(
|
||||
&self,
|
||||
urls: &[&str],
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<TempFile> {
|
||||
if urls.is_empty() {
|
||||
bail!("no urls provided");
|
||||
}
|
||||
let mut temp_file = TempFile::new().await?;
|
||||
for (idx, url) in urls.iter().enumerate() {
|
||||
match self
|
||||
.fetch_internal(url, &mut temp_file, is_stream, concurrent_download)
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(temp_file),
|
||||
Err(e) => {
|
||||
if idx == urls.len() - 1 {
|
||||
temp_file.drop_async().await;
|
||||
return Err(e).with_context(|| format!("failed to download file from all {} urls", urls.len()));
|
||||
}
|
||||
temp_file.set_len(0).await?;
|
||||
temp_file.rewind().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn fetch_internal(
|
||||
&self,
|
||||
url: &str,
|
||||
file: &mut TempFile,
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
if concurrent_download.enable {
|
||||
self.fetch_parallel(url, file, is_stream, concurrent_download).await
|
||||
} else {
|
||||
self.fetch_serial(url, file).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_serial(&self, url: &str, file: &mut TempFile) -> Result<()> {
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::GET, url, None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let expected = resp.content_length().unwrap_or_default();
|
||||
let expected = resp.header_content_length();
|
||||
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
|
||||
let received = tokio::io::copy(&mut stream_reader, &mut file).await?;
|
||||
let received = tokio::io::copy(&mut stream_reader, file).await?;
|
||||
file.flush().await?;
|
||||
ensure!(
|
||||
received >= expected,
|
||||
"received {} bytes, expected {} bytes",
|
||||
received,
|
||||
expected
|
||||
);
|
||||
if let Some(expected) = expected {
|
||||
ensure!(
|
||||
received == expected,
|
||||
"downloaded bytes mismatch: expected {}, got {}",
|
||||
expected,
|
||||
received
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_with_fallback(&self, urls: &[&str], path: &Path) -> Result<()> {
|
||||
if urls.is_empty() {
|
||||
bail!("no urls provided");
|
||||
}
|
||||
let mut res = Ok(());
|
||||
for url in urls {
|
||||
match self.fetch(url, path).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) => {
|
||||
res = Err(err);
|
||||
}
|
||||
async fn fetch_parallel(
|
||||
&self,
|
||||
url: &str,
|
||||
file: &mut TempFile,
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let (concurrency, threshold) = (concurrent_download.concurrency, concurrent_download.threshold);
|
||||
let file_size = if is_stream {
|
||||
// B 站视频、音频流存在 HEAD 为 404 但 GET 正常的情况,此处假设支持分块,直接使用携带 Range 头的 GET 请求探测
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::GET, url, None)
|
||||
.header(header::RANGE, "bytes=0-0")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
if resp.status() != StatusCode::PARTIAL_CONTENT {
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
resp.header_file_size()
|
||||
} else {
|
||||
// 对于普通文件,直接使用常规的 HEAD 请求探测
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::HEAD, url, None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
if resp
|
||||
.headers()
|
||||
.get(header::ACCEPT_RANGES)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges#none
|
||||
.is_none_or(|v| v.to_str().unwrap_or_default() == "none")
|
||||
{
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
resp.header_content_length()
|
||||
};
|
||||
let Some(file_size) = file_size else {
|
||||
return self.fetch_serial(url, file).await;
|
||||
};
|
||||
let chunk_size = file_size / concurrency as u64;
|
||||
if chunk_size < threshold {
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
res.with_context(|| format!("failed to download from {:?}", urls))
|
||||
}
|
||||
|
||||
pub async fn merge(&self, video_path: &Path, audio_path: &Path, output_path: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
video_path.to_string_lossy().as_ref(),
|
||||
"-i",
|
||||
audio_path.to_string_lossy().as_ref(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
|
||||
file.set_len(file_size).await?;
|
||||
let mut tasks = JoinSet::new();
|
||||
let url = Arc::new(url.to_string());
|
||||
for i in 0..concurrency {
|
||||
let start = i as u64 * chunk_size;
|
||||
let end = if i == concurrency - 1 {
|
||||
file_size
|
||||
} else {
|
||||
start + chunk_size
|
||||
} - 1;
|
||||
let (url_clone, client_clone) = (url.clone(), self.client.clone());
|
||||
let mut file_clone = file.open_rw().await?;
|
||||
tasks.spawn(async move {
|
||||
file_clone.seek(SeekFrom::Start(start)).await?;
|
||||
let range_header = format!("bytes={}-{}", start, end);
|
||||
let resp = client_clone
|
||||
.request(Method::GET, &url_clone, None)
|
||||
.header(header::RANGE, &range_header)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
if let Some(content_length) = resp.header_content_length() {
|
||||
ensure!(
|
||||
content_length == end - start + 1,
|
||||
"content length mismatch: expected {}, got {}",
|
||||
end - start + 1,
|
||||
content_length
|
||||
);
|
||||
}
|
||||
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
|
||||
let received = tokio::io::copy(&mut stream_reader, &mut file_clone).await?;
|
||||
file_clone.flush().await?;
|
||||
ensure!(
|
||||
received == end - start + 1,
|
||||
"downloaded bytes mismatch: expected {}, got {}",
|
||||
end - start + 1,
|
||||
received,
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
while let Some(res) = tasks.join_next().await {
|
||||
res??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// reqwest.content_length() 居然指的是 body_size 而非 content-length header,没办法自己实现一下
|
||||
/// https://github.com/seanmonstar/reqwest/issues/1814
|
||||
trait ResponseExt {
|
||||
/// 获取 Content-Length 头的值
|
||||
fn header_content_length(&self) -> Option<u64>;
|
||||
/// 获取 Content-Range 头中的文件总大小部分
|
||||
fn header_file_size(&self) -> Option<u64>;
|
||||
}
|
||||
|
||||
impl ResponseExt for reqwest::Response {
|
||||
fn header_content_length(&self) -> Option<u64> {
|
||||
self.headers()
|
||||
.get(header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn header_file_size(&self) -> Option<u64> {
|
||||
self.headers()
|
||||
.get(header::CONTENT_RANGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| {
|
||||
// Content-Range: bytes 0-0/800946
|
||||
s.rsplit_once('/')
|
||||
})
|
||||
.and_then(|(_, size_str)| size_str.parse::<u64>().ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::bilibili::{BestStream, BiliClient, Video};
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::database::setup_database;
|
||||
use crate::downloader::Downloader;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_parse_and_download_video() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let config = VersionedConfig::get().read();
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, "BV1QJmaYKEv4".to_owned(), &config.credential);
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let mut page_analyzer = video
|
||||
.get_page_analyzer(&first_page)
|
||||
.await
|
||||
.expect("failed to get page analyzer");
|
||||
let json_info = serde_json::to_string_pretty(&page_analyzer.info)?;
|
||||
tokio::fs::write("./debug_playurl.json", json_info).await?;
|
||||
let best_stream = page_analyzer
|
||||
.best_stream(&config.filter_option)
|
||||
.expect("failed to get best stream");
|
||||
let BestStream::VideoAudio {
|
||||
video,
|
||||
audio: Some(audio),
|
||||
} = best_stream
|
||||
else {
|
||||
panic!("best stream is not video & audio");
|
||||
};
|
||||
dbg!(&video);
|
||||
dbg!(&audio);
|
||||
let downloader = Downloader::new(client.client);
|
||||
downloader
|
||||
.multi_fetch_and_merge(
|
||||
&video.urls(true),
|
||||
&audio.urls(true),
|
||||
Path::new("./output.mp4"),
|
||||
&config.concurrent_limit.download,
|
||||
)
|
||||
.await
|
||||
.expect("failed to download video");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
use std::io;
|
||||
|
||||
use anyhow::Result;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("Request too frequently")]
|
||||
pub struct DownloadAbortError();
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("Process page error")]
|
||||
pub struct ProcessPageError();
|
||||
|
||||
pub enum ExecutionStatus {
|
||||
Skipped,
|
||||
@@ -17,7 +8,7 @@ pub enum ExecutionStatus {
|
||||
Ignored(anyhow::Error),
|
||||
Failed(anyhow::Error),
|
||||
// 任务可以返回该状态固定自己的 status
|
||||
FixedFailed(u32, anyhow::Error),
|
||||
Fixed(u32),
|
||||
}
|
||||
|
||||
// 目前 stable rust 似乎不支持自定义类型使用 ? 运算符,只能先在返回值使用 Result,再这样套层娃
|
||||
@@ -35,18 +26,17 @@ impl From<Result<ExecutionStatus>> for ExecutionStatus {
|
||||
// 使用 io::Error 包裹的 reqwest::Error
|
||||
if io_err.kind() == io::ErrorKind::Other
|
||||
&& io_err.get_ref().is_some_and(|e| {
|
||||
e.downcast_ref::<reqwest::Error>()
|
||||
.is_some_and(|e| is_ignored_reqwest_error(e))
|
||||
e.downcast_ref::<reqwest::Error>().is_some_and(is_ignored_reqwest_error)
|
||||
})
|
||||
{
|
||||
return ExecutionStatus::Ignored(err);
|
||||
}
|
||||
}
|
||||
// 未包裹的 reqwest::Error
|
||||
if let Some(error) = cause.downcast_ref::<reqwest::Error>() {
|
||||
if is_ignored_reqwest_error(error) {
|
||||
return ExecutionStatus::Ignored(err);
|
||||
}
|
||||
if let Some(error) = cause.downcast_ref::<reqwest::Error>()
|
||||
&& is_ignored_reqwest_error(error)
|
||||
{
|
||||
return ExecutionStatus::Ignored(err);
|
||||
}
|
||||
}
|
||||
ExecutionStatus::Failed(err)
|
||||
|
||||
@@ -8,36 +8,53 @@ mod config;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
mod notifier;
|
||||
mod task;
|
||||
mod utils;
|
||||
mod workflow;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Debug;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use bilibili::BiliClient;
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use task::{http_server, video_downloader};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config::{ARGS, CONFIG};
|
||||
use crate::api::{LogHelper, MAX_HISTORY_LOGS};
|
||||
use crate::config::{ARGS, CONFIG_DIR, VersionedConfig};
|
||||
use crate::database::setup_database;
|
||||
use crate::utils::init_logger;
|
||||
use crate::utils::signal::terminate;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init();
|
||||
let connection = Arc::new(setup_database().await);
|
||||
let (connection, log_writer) = init().await;
|
||||
let bili_client = Arc::new(BiliClient::new());
|
||||
|
||||
let token = CancellationToken::new();
|
||||
let tracker = TaskTracker::new();
|
||||
|
||||
spawn_task("HTTP 服务", http_server(connection.clone()), &tracker, token.clone());
|
||||
spawn_task("定时下载", video_downloader(connection), &tracker, token.clone());
|
||||
spawn_task(
|
||||
"HTTP 服务",
|
||||
http_server(connection.clone(), bili_client.clone(), log_writer),
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
|
||||
spawn_task(
|
||||
"定时下载",
|
||||
video_downloader(connection.clone(), bili_client),
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
|
||||
tracker.close();
|
||||
handle_shutdown(tracker, token).await
|
||||
handle_shutdown(connection, tracker, token).await
|
||||
}
|
||||
|
||||
fn spawn_task(
|
||||
@@ -59,24 +76,40 @@ fn spawn_task(
|
||||
});
|
||||
}
|
||||
|
||||
/// 初始化日志系统,打印欢迎信息,加载配置文件
|
||||
fn init() {
|
||||
init_logger(&ARGS.log_level);
|
||||
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
|
||||
async fn init() -> (DatabaseConnection, LogHelper) {
|
||||
let (tx, _rx) = tokio::sync::broadcast::channel(30);
|
||||
let log_history = Arc::new(RwLock::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
|
||||
let log_writer = LogHelper::new(tx, log_history.clone());
|
||||
|
||||
init_logger(&ARGS.log_level, Some(log_writer.clone()));
|
||||
info!("欢迎使用 Bili-Sync,当前程序版本:{}", config::version());
|
||||
info!("项目地址:https://github.com/amtoaer/bili-sync");
|
||||
Lazy::force(&CONFIG);
|
||||
let connection = setup_database(&CONFIG_DIR.join("data.sqlite"))
|
||||
.await
|
||||
.expect("数据库初始化失败");
|
||||
info!("数据库初始化完成");
|
||||
VersionedConfig::init(&connection).await.expect("配置初始化失败");
|
||||
info!("配置初始化完成");
|
||||
|
||||
(connection, log_writer)
|
||||
}
|
||||
|
||||
async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) {
|
||||
async fn handle_shutdown(connection: DatabaseConnection, tracker: TaskTracker, token: CancellationToken) {
|
||||
tokio::select! {
|
||||
_ = tracker.wait() => {
|
||||
error!("所有任务均已终止,程序退出")
|
||||
error!("所有任务均已终止..")
|
||||
}
|
||||
_ = terminate() => {
|
||||
info!("接收到终止信号,正在终止任务..");
|
||||
info!("接收到终止信号,开始终止任务..");
|
||||
token.cancel();
|
||||
tracker.wait().await;
|
||||
info!("所有任务均已终止,程序退出");
|
||||
info!("所有任务均已终止..");
|
||||
}
|
||||
}
|
||||
info!("正在关闭数据库连接..");
|
||||
match connection.close().await {
|
||||
Ok(()) => info!("数据库连接已关闭,程序结束"),
|
||||
Err(e) => error!("关闭数据库连接时遇到错误:{:#},程序异常结束", e),
|
||||
}
|
||||
}
|
||||
|
||||
80
crates/bili_sync/src/notifier/mod.rs
Normal file
80
crates/bili_sync/src/notifier/mod.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use futures::future;
|
||||
use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::TEMPLATE;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum Notifier {
|
||||
Telegram {
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
},
|
||||
Webhook {
|
||||
url: String,
|
||||
template: Option<String>,
|
||||
#[serde(skip)]
|
||||
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
|
||||
ignore_cache: Option<()>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn webhook_template_key(url: &str) -> String {
|
||||
format!("payload_{}", url)
|
||||
}
|
||||
|
||||
pub fn webhook_template_content(template: &Option<String>) -> &str {
|
||||
template
|
||||
.as_deref()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or(r#"{"text": "{{{message}}}"}"#)
|
||||
}
|
||||
|
||||
pub trait NotifierAllExt {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
impl NotifierAllExt for Vec<Notifier> {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
future::join_all(self.iter().map(|notifier| notifier.notify(client, message))).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
pub async fn notify(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
match self {
|
||||
Notifier::Telegram { bot_token, chat_id } => {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
|
||||
let params = [("chat_id", chat_id.as_str()), ("text", message)];
|
||||
client.post(&url).form(¶ms).send().await?;
|
||||
}
|
||||
Notifier::Webhook {
|
||||
url,
|
||||
template,
|
||||
ignore_cache,
|
||||
} => {
|
||||
let key = webhook_template_key(url);
|
||||
let data = serde_json::json!(
|
||||
{
|
||||
"message": message,
|
||||
}
|
||||
);
|
||||
let handlebar = TEMPLATE.read();
|
||||
let payload = match ignore_cache {
|
||||
Some(_) => handlebar.render_template(webhook_template_content(template), &data)?,
|
||||
None => handlebar.render(&key, &data)?,
|
||||
};
|
||||
client
|
||||
.post(url)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,117 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::extract::Request;
|
||||
use axum::http::{Uri, header};
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router, ServiceExt, middleware};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, ServiceExt};
|
||||
use reqwest::StatusCode;
|
||||
use rust_embed::Embed;
|
||||
use rust_embed_for_web::{EmbedableFile, RustEmbed};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||
|
||||
use crate::api::auth;
|
||||
use crate::api::handler::{ApiDoc, get_video, get_video_sources, get_videos, reset_video};
|
||||
use crate::config::CONFIG;
|
||||
use crate::api::{LogHelper, router};
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::{VersionedConfig, default_bind_address};
|
||||
|
||||
#[derive(Embed)]
|
||||
#[derive(RustEmbed)]
|
||||
#[preserve_source = false]
|
||||
#[folder = "../../web/build"]
|
||||
struct Asset;
|
||||
|
||||
pub async fn http_server(database_connection: Arc<DatabaseConnection>) -> Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/api/video-sources", get(get_video_sources))
|
||||
.route("/api/videos", get(get_videos))
|
||||
.route("/api/videos/{id}", get(get_video))
|
||||
.route("/api/videos/{id}/reset", post(reset_video))
|
||||
.merge(
|
||||
SwaggerUi::new("/swagger-ui/")
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi())
|
||||
.config(
|
||||
Config::default()
|
||||
.try_it_out_enabled(true)
|
||||
.persist_authorization(true)
|
||||
.validator_url("none"),
|
||||
),
|
||||
)
|
||||
.fallback_service(get(frontend_files))
|
||||
pub async fn http_server(
|
||||
database_connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
log_writer: LogHelper,
|
||||
) -> Result<()> {
|
||||
let app = router()
|
||||
.fallback_service(get(frontend_files).head(frontend_files))
|
||||
.layer(Extension(database_connection))
|
||||
.layer(middleware::from_fn(auth::auth));
|
||||
let listener = tokio::net::TcpListener::bind(&CONFIG.bind_address)
|
||||
.await
|
||||
.context("bind address failed")?;
|
||||
info!("开始运行管理页: http://{}", CONFIG.bind_address);
|
||||
.layer(Extension(bili_client))
|
||||
.layer(Extension(log_writer));
|
||||
let (bind_address, listener) = {
|
||||
let bind_address = VersionedConfig::get().read().bind_address.to_owned();
|
||||
let listen_res = tokio::net::TcpListener::bind(&bind_address)
|
||||
.await
|
||||
.context("bind address failed");
|
||||
match listen_res {
|
||||
Ok(listener) => (bind_address, listener),
|
||||
Err(e) => {
|
||||
let default_bind_address = default_bind_address();
|
||||
if default_bind_address == bind_address {
|
||||
return Err(e);
|
||||
}
|
||||
warn!(
|
||||
"绑定到地址 {} 失败:{:#},尝试绑定到默认地址 {}",
|
||||
bind_address, e, default_bind_address
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(&default_bind_address)
|
||||
.await
|
||||
.context("bind default address failed")?;
|
||||
(default_bind_address, listener)
|
||||
}
|
||||
}
|
||||
};
|
||||
info!("开始运行管理页:http://{}", bind_address);
|
||||
Ok(axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await?)
|
||||
}
|
||||
|
||||
async fn frontend_files(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches('/');
|
||||
if path.is_empty() {
|
||||
async fn frontend_files(request: Request) -> impl IntoResponse {
|
||||
let mut path = request.uri().path().trim_start_matches('/');
|
||||
if path.is_empty() || Asset::get(path).is_none() {
|
||||
path = "index.html";
|
||||
}
|
||||
match Asset::get(path) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
|
||||
let Some(content) = Asset::get(path) else {
|
||||
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
|
||||
};
|
||||
let mime_type = content.mime_type();
|
||||
let content_type = mime_type.as_deref().unwrap_or("application/octet-stream");
|
||||
let default_headers = [
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
(header::ETAG, &content.hash()),
|
||||
];
|
||||
if let Some(if_none_match) = request.headers().get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = if_none_match.to_str()
|
||||
&& client_etag == content.hash()
|
||||
{
|
||||
return (StatusCode::NOT_MODIFIED, default_headers).into_response();
|
||||
}
|
||||
|
||||
if request.method() == axum::http::Method::HEAD {
|
||||
return (StatusCode::OK, default_headers).into_response();
|
||||
}
|
||||
if cfg!(debug_assertions) {
|
||||
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
|
||||
return (StatusCode::OK, default_headers, content.data().unwrap()).into_response();
|
||||
}
|
||||
let accepted_encodings = request
|
||||
.headers()
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
|
||||
if accepted_encodings.contains(encoding)
|
||||
&& let Some(data) = data
|
||||
{
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
(header::ETAG, &content.hash()),
|
||||
(header::CONTENT_ENCODING, encoding),
|
||||
],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"Client must support gzip or brotli compression",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ mod http_server;
|
||||
mod video_downloader;
|
||||
|
||||
pub use http_server::http_server;
|
||||
pub use video_downloader::video_downloader;
|
||||
pub use video_downloader::{DownloadTaskManager, TaskStatus, video_downloader};
|
||||
|
||||
@@ -1,45 +1,373 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::time;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{OnceCell, watch};
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
|
||||
use crate::bilibili::{self, BiliClient};
|
||||
use crate::config::CONFIG;
|
||||
use crate::adapter::VideoSource;
|
||||
use crate::bilibili::{self, BiliClient, BiliError};
|
||||
use crate::config::{ARGS, Config, TEMPLATE, Trigger, VersionedConfig};
|
||||
use crate::utils::model::get_enabled_video_sources;
|
||||
use crate::utils::notify::error_and_notify;
|
||||
use crate::workflow::process_video_source;
|
||||
|
||||
static INSTANCE: OnceCell<DownloadTaskManager> = OnceCell::const_new();
|
||||
|
||||
/// 启动周期下载视频的任务
|
||||
pub async fn video_downloader(connection: Arc<DatabaseConnection>) {
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
let bili_client = BiliClient::new();
|
||||
let video_sources = CONFIG.as_video_sources();
|
||||
loop {
|
||||
info!("开始执行本轮视频下载任务..");
|
||||
'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 失败,等待下一轮执行");
|
||||
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 (args, path) in &video_sources {
|
||||
if let Err(e) = process_video_source(*args, &bili_client, path, &connection).await {
|
||||
error!("处理过程遇到错误:{:#}", e);
|
||||
}
|
||||
}
|
||||
info!("本轮任务执行完毕,等待下一轮执行");
|
||||
pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<BiliClient>) -> Result<()> {
|
||||
let task_manager = DownloadTaskManager::init(connection, bili_client).await?;
|
||||
task_manager.start().await
|
||||
}
|
||||
|
||||
pub struct DownloadTaskManager {
|
||||
sched: Arc<tokio::sync::Mutex<JobScheduler>>,
|
||||
cx: Arc<TaskContext>,
|
||||
shutdown_rx: watch::Receiver<Result<()>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default, Clone, Copy, Debug)]
|
||||
pub struct TaskStatus {
|
||||
is_running: bool,
|
||||
last_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
last_finish: Option<chrono::DateTime<chrono::Local>>,
|
||||
next_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
struct TaskContext {
|
||||
connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
running: tokio::sync::Mutex<()>,
|
||||
status_tx: watch::Sender<TaskStatus>,
|
||||
status_rx: watch::Receiver<TaskStatus>,
|
||||
video_task_id: tokio::sync::Mutex<Option<uuid::Uuid>>, // 存储当前视频下载任务的 UUID
|
||||
}
|
||||
|
||||
impl DownloadTaskManager {
|
||||
/// 初始化 DownloadTaskManager 单例
|
||||
pub async fn init(
|
||||
connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
) -> Result<&'static DownloadTaskManager> {
|
||||
INSTANCE
|
||||
.get_or_try_init(|| DownloadTaskManager::new(connection, bili_client))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 获取 DownloadTaskManager 单例,未初始化时直接 panic
|
||||
pub fn get() -> &'static DownloadTaskManager {
|
||||
INSTANCE.get().expect("DownloadTaskManager is not initialized")
|
||||
}
|
||||
|
||||
/// 订阅下载任务的状态更新
|
||||
pub fn subscribe(&self) -> watch::Receiver<TaskStatus> {
|
||||
self.cx.status_rx.clone()
|
||||
}
|
||||
|
||||
/// 手动执行一次下载任务
|
||||
pub async fn download_once(&self) -> Result<()> {
|
||||
let _ = self
|
||||
.sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::download_video_task(self.cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动任务调度器
|
||||
async fn start(&self) -> Result<()> {
|
||||
self.sched.lock().await.start().await?;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
shutdown_rx.changed().await?;
|
||||
self.sched.lock().await.shutdown().await.context("任务调度器关闭失败")?;
|
||||
if let Err(e) = &*shutdown_rx.borrow() {
|
||||
bail!("{:#}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 私有的调度器构造函数
|
||||
async fn new(connection: DatabaseConnection, bili_client: Arc<BiliClient>) -> Result<Self> {
|
||||
let sched = Arc::new(tokio::sync::Mutex::new(JobScheduler::new().await?));
|
||||
let (status_tx, status_rx) = watch::channel(TaskStatus::default());
|
||||
let (running, video_task_id) = (tokio::sync::Mutex::new(()), tokio::sync::Mutex::new(None));
|
||||
let cx = Arc::new(TaskContext {
|
||||
connection,
|
||||
bili_client,
|
||||
running,
|
||||
status_tx,
|
||||
status_rx,
|
||||
video_task_id,
|
||||
});
|
||||
// 读取初始配置
|
||||
let mut rx = VersionedConfig::get().subscribe();
|
||||
let initial_config = rx.borrow_and_update().clone();
|
||||
if ARGS.disable_credential_refresh {
|
||||
warn!("已禁用凭据检查与刷新任务,bili-sync 将不会自动检查刷新 Credential,需要用户自行维护");
|
||||
} else {
|
||||
// 初始化凭据检查与刷新任务,该任务必须成功,否则直接退出
|
||||
sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_async_tz(
|
||||
"0 0 1 * * *",
|
||||
chrono::Local,
|
||||
DownloadTaskManager::check_and_refresh_credential_task(cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
// 初始化并添加视频下载任务,将任务 ID 保存到 TaskManager 中
|
||||
let video_task_id = async {
|
||||
let job_run = DownloadTaskManager::download_video_task(cx.clone());
|
||||
let job = match &initial_config.interval {
|
||||
Trigger::Interval(interval) => Job::new_repeated_async(Duration::from_secs(*interval), job_run)?,
|
||||
Trigger::Cron(cron) => Job::new_async_tz(cron, chrono::Local, job_run)?,
|
||||
};
|
||||
Result::<_, anyhow::Error>::Ok(sched.lock().await.add(job).await?)
|
||||
}
|
||||
.await;
|
||||
let video_task_id = match video_task_id {
|
||||
Ok(id) => Some(id),
|
||||
Err(err) => {
|
||||
error_and_notify(
|
||||
&initial_config,
|
||||
&cx.bili_client,
|
||||
format!("初始化视频下载任务失败:{:#}", err),
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
*cx.video_task_id.lock().await = video_task_id;
|
||||
// 发起一个一次性的任务,更新一下下次运行的时间
|
||||
if let Some(video_task_id) = video_task_id {
|
||||
sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::refresh_next_run(video_task_id, cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
// 发起一个新任务,用来监听配置变更,动态更新视频下载任务
|
||||
let cx_clone = cx.clone();
|
||||
let sched_clone = sched.clone();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(Ok(()));
|
||||
tokio::spawn(async move {
|
||||
let update_task_result = async {
|
||||
while rx.changed().await.is_ok() {
|
||||
let new_config = rx.borrow().clone();
|
||||
let cx = cx_clone.clone();
|
||||
let mut video_task_id = cx.video_task_id.lock().await;
|
||||
if let Some(old_video_task_id) = *video_task_id {
|
||||
// 这里必须成功,不然后面会重复添加任务
|
||||
sched_clone
|
||||
.lock()
|
||||
.await
|
||||
.remove(&old_video_task_id)
|
||||
.await
|
||||
.context("移除旧的视频下载任务失败")?;
|
||||
}
|
||||
let new_video_task_id = async {
|
||||
let job_run = DownloadTaskManager::download_video_task(cx.clone());
|
||||
let job = match &new_config.interval {
|
||||
Trigger::Interval(interval) => {
|
||||
Job::new_repeated_async(Duration::from_secs(*interval), job_run)?
|
||||
}
|
||||
Trigger::Cron(cron) => Job::new_async_tz(cron, chrono::Local, job_run)?,
|
||||
};
|
||||
Result::<_, anyhow::Error>::Ok(sched_clone.lock().await.add(job).await?)
|
||||
}
|
||||
.await;
|
||||
let new_video_task_id = match new_video_task_id {
|
||||
Ok(id) => Some(id),
|
||||
Err(err) => {
|
||||
error_and_notify(
|
||||
&initial_config,
|
||||
&cx.bili_client,
|
||||
format!("重载视频下载任务失败:{:#}", err),
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
*video_task_id = new_video_task_id;
|
||||
if let Some(video_task_id) = new_video_task_id {
|
||||
sched_clone
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::refresh_next_run(video_task_id, cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Result::<(), anyhow::Error>::Ok(())
|
||||
}
|
||||
.await;
|
||||
// 如果执行正常,上面应该是永远不会退出的
|
||||
let _ = shutdown_tx.send(update_task_result);
|
||||
});
|
||||
Ok(Self { sched, cx, shutdown_rx })
|
||||
}
|
||||
|
||||
fn check_and_refresh_credential_task(
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |_uuid, _l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let _lock = cx.running.lock().await;
|
||||
let config = VersionedConfig::get().read();
|
||||
info!("开始执行本轮凭据检查与刷新任务..");
|
||||
match check_and_refresh_credential(&cx.connection, &cx.bili_client, &config).await {
|
||||
Ok(_) => info!("本轮凭据检查与刷新任务执行完毕"),
|
||||
Err(e) => {
|
||||
error_and_notify(
|
||||
&config,
|
||||
&cx.bili_client,
|
||||
format!("本轮凭据检查与刷新任务执行遇到错误:{:#}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_next_run(
|
||||
video_task_id: uuid::Uuid,
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |_uuid, mut l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let old_status = *cx.status_rx.borrow();
|
||||
let next_run = l
|
||||
.next_tick_for_job(video_task_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|dt| dt.with_timezone(&chrono::Local));
|
||||
let _ = cx.status_tx.send(TaskStatus { next_run, ..old_status });
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn download_video_task(
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |uuid, mut l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let Ok(_lock) = cx.running.try_lock() else {
|
||||
warn!("上一次视频下载任务尚未结束,跳过本次执行..");
|
||||
return;
|
||||
};
|
||||
let _ = cx.status_tx.send(TaskStatus {
|
||||
is_running: true,
|
||||
last_run: Some(chrono::Local::now()),
|
||||
last_finish: None,
|
||||
next_run: None,
|
||||
});
|
||||
info!("开始执行本轮视频下载任务..");
|
||||
let mut config = VersionedConfig::get().snapshot();
|
||||
match download_video(&cx.connection, &cx.bili_client, &mut config).await {
|
||||
Ok(_) => info!("本轮视频下载任务执行完毕"),
|
||||
Err(e) => {
|
||||
error_and_notify(
|
||||
&config,
|
||||
&cx.bili_client,
|
||||
format!("本轮视频下载任务执行遇到错误:{:#}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 注意此处尽量从 updating 中读取 uuid,因为当前任务可能是不存在 next_tick 的 oneshot 任务
|
||||
let task_uuid = (*cx.video_task_id.lock().await).unwrap_or(uuid);
|
||||
let next_run = l
|
||||
.next_tick_for_job(task_uuid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|dt| dt.with_timezone(&chrono::Local));
|
||||
let last_status = *cx.status_rx.borrow();
|
||||
let _ = cx.status_tx.send(TaskStatus {
|
||||
is_running: false,
|
||||
last_run: last_status.last_run,
|
||||
last_finish: Some(chrono::Local::now()),
|
||||
next_run,
|
||||
});
|
||||
})
|
||||
}
|
||||
time::sleep(time::Duration::from_secs(CONFIG.interval)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_and_refresh_credential(
|
||||
connection: &DatabaseConnection,
|
||||
bili_client: &BiliClient,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
match bili_client
|
||||
.check_refresh(&config.credential)
|
||||
.await
|
||||
.context("检查刷新 Credential 失败")?
|
||||
{
|
||||
None => {
|
||||
info!("Credential 无需刷新");
|
||||
}
|
||||
Some(new_credential) => {
|
||||
VersionedConfig::get()
|
||||
.update_credential(new_credential, connection)
|
||||
.await
|
||||
.context("新 Credential 持久化失败")?;
|
||||
info!("Credential 已刷新并保存");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_video(
|
||||
connection: &DatabaseConnection,
|
||||
bili_client: &BiliClient,
|
||||
config: &mut Arc<Config>,
|
||||
) -> Result<()> {
|
||||
config.check().context("配置检查失败")?;
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(&config.credential)
|
||||
.await
|
||||
.context("获取 wbi_img 失败")?
|
||||
.into_mixin_key()
|
||||
.context("解析 mixin key 失败")?;
|
||||
bilibili::set_global_mixin_key(mixin_key);
|
||||
let template = TEMPLATE.snapshot();
|
||||
let bili_client = bili_client.snapshot()?;
|
||||
let video_sources = get_enabled_video_sources(connection)
|
||||
.await
|
||||
.context("获取视频源列表失败")?;
|
||||
if video_sources.is_empty() {
|
||||
bail!("没有可用的视频源");
|
||||
}
|
||||
for video_source in video_sources {
|
||||
let display_name = video_source.display_name();
|
||||
if let Err(e) = process_video_source(video_source, &bili_client, connection, &template, config).await {
|
||||
error_and_notify(
|
||||
config,
|
||||
&bili_client,
|
||||
format!("处理 {} 时遇到错误:{:#},跳过该视频源", display_name, e),
|
||||
);
|
||||
if let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
warn!("检测到风控,终止此轮视频下载任务..");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -97,7 +97,23 @@ impl VideoInfo {
|
||||
valid: Set(true),
|
||||
..default
|
||||
},
|
||||
_ => unreachable!(),
|
||||
VideoInfo::Dynamic {
|
||||
title,
|
||||
bvid,
|
||||
desc,
|
||||
cover,
|
||||
pubtime,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid),
|
||||
name: Set(title),
|
||||
intro: Set(desc),
|
||||
cover: Set(cover),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
category: Set(2), // 动态里的视频内容类型肯定是视频
|
||||
valid: Set(true),
|
||||
..default
|
||||
},
|
||||
VideoInfo::Detail { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,22 +130,30 @@ impl VideoInfo {
|
||||
ctime,
|
||||
pubtime,
|
||||
state,
|
||||
is_upower_exclusive,
|
||||
is_upower_play,
|
||||
redirect_url,
|
||||
..
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid),
|
||||
name: Set(title),
|
||||
category: Set(2),
|
||||
intro: Set(intro),
|
||||
cover: Set(cover),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: if base_model.favtime != NaiveDateTime::default() {
|
||||
NotSet // 之前设置了 favtime,不覆盖
|
||||
Set(base_model.favtime) // 之前设置了 favtime,使用之前的值(等价于 unset,但设置上以支持后续的规则匹配)
|
||||
} else {
|
||||
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
||||
},
|
||||
download_status: Set(0),
|
||||
valid: Set(state == 0),
|
||||
// state == 0 表示开放浏览
|
||||
// is_upower_exclusive 和 is_upower_play 相等有两种情况:
|
||||
// 1. 都为 true,表示视频是充电专享但是已经充过电,有权观看
|
||||
// 2. 都为 false,表示视频是非充电视频
|
||||
// redirect_url 仅在视频为番剧、影视、纪录片等特殊视频时才会有值,如果为空说明是普通视频
|
||||
// 仅在三种条件都满足时,才认为视频是可下载的
|
||||
valid: Set(state == 0 && (is_upower_exclusive == is_upower_play) && redirect_url.is_none()),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name),
|
||||
upper_face: Set(upper.face),
|
||||
@@ -145,17 +169,15 @@ impl VideoInfo {
|
||||
VideoInfo::Collection { pubtime: time, .. }
|
||||
| VideoInfo::Favorite { fav_time: time, .. }
|
||||
| VideoInfo::WatchLater { fav_time: time, .. }
|
||||
| VideoInfo::Submission { ctime: time, .. } => time,
|
||||
_ => unreachable!(),
|
||||
| VideoInfo::Submission { ctime: time, .. }
|
||||
| VideoInfo::Dynamic { pubtime: time, .. } => time,
|
||||
VideoInfo::Detail { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageInfo {
|
||||
pub fn into_active_model(
|
||||
self,
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
) -> bili_sync_entity::page::ActiveModel {
|
||||
pub fn into_active_model(self, video_model_id: i32) -> bili_sync_entity::page::ActiveModel {
|
||||
let (width, height) = match &self.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
@@ -167,7 +189,7 @@ impl PageInfo {
|
||||
None => (None, None),
|
||||
};
|
||||
bili_sync_entity::page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
video_id: Set(video_model_id),
|
||||
cid: Set(self.cid),
|
||||
pid: Set(self.page),
|
||||
name: Set(self.name),
|
||||
|
||||
36
crates/bili_sync/src/utils/download_context.rs
Normal file
36
crates/bili_sync/src/utils/download_context.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::adapter::VideoSourceEnum;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::Config;
|
||||
use crate::downloader::Downloader;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DownloadContext<'a> {
|
||||
pub bili_client: &'a BiliClient,
|
||||
pub video_source: &'a VideoSourceEnum,
|
||||
pub template: &'a handlebars::Handlebars<'a>,
|
||||
pub connection: &'a DatabaseConnection,
|
||||
pub downloader: &'a Downloader,
|
||||
pub config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> DownloadContext<'a> {
|
||||
pub fn new(
|
||||
bili_client: &'a BiliClient,
|
||||
video_source: &'a VideoSourceEnum,
|
||||
template: &'a handlebars::Handlebars<'a>,
|
||||
connection: &'a DatabaseConnection,
|
||||
downloader: &'a Downloader,
|
||||
config: &'a Config,
|
||||
) -> Self {
|
||||
Self {
|
||||
bili_client,
|
||||
video_source,
|
||||
template,
|
||||
connection,
|
||||
downloader,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::CONFIG;
|
||||
|
||||
pub fn video_format_args(video_model: &bili_sync_entity::video::Model) -> serde_json::Value {
|
||||
pub fn video_format_args(video_model: &bili_sync_entity::video::Model, time_format: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"bvid": &video_model.bvid,
|
||||
"title": &video_model.name,
|
||||
"upper_name": &video_model.upper_name,
|
||||
"upper_mid": &video_model.upper_id,
|
||||
"pubtime": &video_model.pubtime.and_utc().format(&CONFIG.time_format).to_string(),
|
||||
"fav_time": &video_model.favtime.and_utc().format(&CONFIG.time_format).to_string(),
|
||||
"pubtime": &video_model.pubtime.and_utc().format(time_format).to_string(),
|
||||
"fav_time": &video_model.favtime.and_utc().format(time_format).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn page_format_args(
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
page_model: &bili_sync_entity::page::Model,
|
||||
time_format: &str,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"bvid": &video_model.bvid,
|
||||
@@ -24,7 +23,7 @@ pub fn page_format_args(
|
||||
"upper_mid": &video_model.upper_id,
|
||||
"ptitle": &page_model.name,
|
||||
"pid": page_model.pid,
|
||||
"pubtime": video_model.pubtime.and_utc().format(&CONFIG.time_format).to_string(),
|
||||
"fav_time": video_model.favtime.and_utc().format(&CONFIG.time_format).to_string(),
|
||||
"pubtime": video_model.pubtime.and_utc().format(time_format).to_string(),
|
||||
"fav_time": video_model.favtime.and_utc().format(time_format).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
pub mod convert;
|
||||
pub mod download_context;
|
||||
pub mod filenamify;
|
||||
pub mod format_arg;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod notify;
|
||||
pub mod rule;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
|
||||
pub mod validation;
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
pub fn init_logger(log_level: &str) {
|
||||
tracing_subscriber::fmt::Subscriber::builder()
|
||||
use crate::api::LogHelper;
|
||||
|
||||
pub fn init_logger(log_level: &str, log_writer: Option<LogHelper>) {
|
||||
let log = tracing_subscriber::fmt::Subscriber::builder()
|
||||
.compact()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
|
||||
.with_target(false)
|
||||
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
||||
"%b %d %H:%M:%S".to_owned(),
|
||||
))
|
||||
.finish()
|
||||
.finish();
|
||||
if let Some(writer) = log_writer {
|
||||
log.with(
|
||||
fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
||||
"%b %d %H:%M:%S".to_owned(),
|
||||
))
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.try_init()
|
||||
.expect("初始化日志失败");
|
||||
} else {
|
||||
log.try_init().expect("初始化日志失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use bili_sync_entity::*;
|
||||
use rand::seq::SliceRandom;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::DatabaseTransaction;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
|
||||
use crate::adapter::{VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{PageInfo, VideoInfo};
|
||||
use crate::bilibili::VideoInfo;
|
||||
use crate::config::Config;
|
||||
use crate::utils::status::STATUS_COMPLETED;
|
||||
|
||||
/// 筛选未填充的视频
|
||||
@@ -39,6 +42,7 @@ pub async fn filter_unhandled_video_pages(
|
||||
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.and(video::Column::ShouldDownload.eq(true))
|
||||
.and(additional_expr),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
@@ -71,16 +75,8 @@ pub async fn create_videos(
|
||||
}
|
||||
|
||||
/// 尝试创建 Page Model,如果发生冲突则忽略
|
||||
pub async fn create_pages(
|
||||
pages_info: Vec<PageInfo>,
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
connection: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.into_iter()
|
||||
.map(|p| p.into_active_model(video_model))
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
for page_chunk in page_models.chunks(50) {
|
||||
pub async fn create_pages(pages_model: Vec<page::ActiveModel>, connection: &DatabaseTransaction) -> Result<()> {
|
||||
for page_chunk in pages_model.chunks(200) {
|
||||
page::Entity::insert_many(page_chunk.to_vec())
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
@@ -117,3 +113,59 @@ pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &Data
|
||||
query.exec(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有已经启用的视频源
|
||||
pub async fn get_enabled_video_sources(connection: &DatabaseConnection) -> Result<Vec<VideoSourceEnum>> {
|
||||
let (favorite, watch_later, submission, collection) = tokio::try_join!(
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::Enabled.eq(true))
|
||||
.all(connection),
|
||||
watch_later::Entity::find()
|
||||
.filter(watch_later::Column::Enabled.eq(true))
|
||||
.all(connection),
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::Enabled.eq(true))
|
||||
.all(connection),
|
||||
collection::Entity::find()
|
||||
.filter(collection::Column::Enabled.eq(true))
|
||||
.all(connection),
|
||||
)?;
|
||||
let mut sources = Vec::with_capacity(favorite.len() + watch_later.len() + submission.len() + collection.len());
|
||||
sources.extend(favorite.into_iter().map(VideoSourceEnum::from));
|
||||
sources.extend(watch_later.into_iter().map(VideoSourceEnum::from));
|
||||
sources.extend(submission.into_iter().map(VideoSourceEnum::from));
|
||||
sources.extend(collection.into_iter().map(VideoSourceEnum::from));
|
||||
// 此处将视频源随机打乱顺序,从概率上确保每个视频源都有机会优先执行,避免后面视频源的长期饥饿问题
|
||||
sources.shuffle(&mut rand::rng());
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// 从数据库中加载配置
|
||||
pub async fn load_db_config(connection: &DatabaseConnection) -> Result<Option<Result<Config>>> {
|
||||
Ok(bili_sync_entity::config::Entity::find_by_id(1)
|
||||
.one(connection)
|
||||
.await?
|
||||
.map(|model| {
|
||||
serde_json::from_str(&model.data).map_err(|e| anyhow!("Failed to deserialize config data: {}", e))
|
||||
}))
|
||||
}
|
||||
|
||||
/// 保存配置到数据库
|
||||
pub async fn save_db_config(config: &Config, connection: &DatabaseConnection) -> Result<()> {
|
||||
let data = serde_json::to_string(config).context("Failed to serialize config data")?;
|
||||
let model = bili_sync_entity::config::ActiveModel {
|
||||
id: Set(1),
|
||||
data: Set(data),
|
||||
..Default::default()
|
||||
};
|
||||
bili_sync_entity::config::Entity::insert(model)
|
||||
.on_conflict(
|
||||
OnConflict::column(bili_sync_entity::config::Column::Id)
|
||||
.update_column(bili_sync_entity::config::Column::Data)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await
|
||||
.context("Failed to save config to database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,213 +1,252 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use quick_xml::Error;
|
||||
use quick_xml::events::{BytesCData, BytesText};
|
||||
use quick_xml::writer::Writer;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
|
||||
use crate::config::NFOTimeType;
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum NFOMode {
|
||||
MOVIE,
|
||||
TVSHOW,
|
||||
EPOSODE,
|
||||
UPPER,
|
||||
pub enum NFO<'a> {
|
||||
Movie(Movie<'a>),
|
||||
TVShow(TVShow<'a>),
|
||||
Upper(Upper),
|
||||
Episode(Episode<'a>),
|
||||
}
|
||||
|
||||
pub enum ModelWrapper<'a> {
|
||||
Video(&'a video::Model),
|
||||
Page(&'a page::Model),
|
||||
pub struct Movie<'a> {
|
||||
pub name: &'a str,
|
||||
pub intro: &'a str,
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub upper_thumb: &'a str,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
|
||||
pub struct TVShow<'a> {
|
||||
pub name: &'a str,
|
||||
pub intro: &'a str,
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub upper_thumb: &'a str,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// serde xml 似乎不太好用,先这么裸着写
|
||||
/// (真是又臭又长啊
|
||||
impl NFOSerializer<'_> {
|
||||
pub async fn generate_nfo(self, nfo_time_type: &NFOTimeType) -> Result<String> {
|
||||
pub struct Upper {
|
||||
pub upper_id: String,
|
||||
pub pubtime: NaiveDateTime,
|
||||
}
|
||||
|
||||
pub struct Episode<'a> {
|
||||
pub name: &'a str,
|
||||
pub pid: String,
|
||||
}
|
||||
|
||||
impl NFO<'_> {
|
||||
pub async fn generate_nfo(self) -> Result<String> {
|
||||
let mut buffer = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
"#
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let mut tokio_buffer = tokio::io::BufWriter::new(&mut buffer);
|
||||
let mut writer = Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
|
||||
let mut tokio_buffer = BufWriter::new(&mut buffer);
|
||||
let 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 {
|
||||
writer
|
||||
.create_element("plot")
|
||||
.write_cdata_content_async(BytesCData::new(Self::format_plot(v)))
|
||||
.await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(&v.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(&v.upper_name))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = &v.tags {
|
||||
let tags: Vec<String> = serde_json::from_value(tags.clone()).unwrap_or_default();
|
||||
for tag in tags {
|
||||
writer
|
||||
.create_element("genre")
|
||||
.write_text_content_async(BytesText::new(&tag))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.create_element("uniqueid")
|
||||
.with_attribute(("type", "bilibili"))
|
||||
.write_text_content_async(BytesText::new(&v.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
NFO::Movie(movie) => {
|
||||
Self::write_movie_nfo(writer, movie).await?;
|
||||
}
|
||||
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 {
|
||||
writer
|
||||
.create_element("plot")
|
||||
.write_cdata_content_async(BytesCData::new(Self::format_plot(v)))
|
||||
.await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(&v.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(&v.upper_name))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = &v.tags {
|
||||
let tags: Vec<String> = serde_json::from_value(tags.clone()).unwrap_or_default();
|
||||
for tag in tags {
|
||||
writer
|
||||
.create_element("genre")
|
||||
.write_text_content_async(BytesText::new(&tag))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.create_element("uniqueid")
|
||||
.with_attribute(("type", "bilibili"))
|
||||
.write_text_content_async(BytesText::new(&v.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
NFO::TVShow(tvshow) => {
|
||||
Self::write_tvshow_nfo(writer, tvshow).await?;
|
||||
}
|
||||
NFOSerializer(ModelWrapper::Video(v), NFOMode::UPPER) => {
|
||||
writer
|
||||
.create_element("person")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer.create_element("plot").write_empty_async().await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("lockdata")
|
||||
.write_text_content_async(BytesText::new("false"))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("dateadded")
|
||||
.write_text_content_async(BytesText::new(
|
||||
&v.pubtime.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("sorttitle")
|
||||
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
NFO::Upper(upper) => {
|
||||
Self::write_upper_nfo(writer, upper).await?;
|
||||
}
|
||||
NFOSerializer(ModelWrapper::Page(p), NFOMode::EPOSODE) => {
|
||||
writer
|
||||
.create_element("episodedetails")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer.create_element("plot").write_empty_async().await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(&p.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("season")
|
||||
.write_text_content_async(BytesText::new("1"))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("episode")
|
||||
.write_text_content_async(BytesText::new(&p.pid.to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
NFO::Episode(episode) => {
|
||||
Self::write_episode_nfo(writer, episode).await?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
tokio_buffer.flush().await?;
|
||||
Ok(String::from_utf8(buffer)?)
|
||||
}
|
||||
|
||||
async fn write_movie_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, movie: Movie<'_>) -> Result<()> {
|
||||
writer
|
||||
.create_element("movie")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("plot")
|
||||
.write_cdata_content_async(BytesCData::new(Self::format_plot(movie.bvid, movie.intro)))
|
||||
.await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(movie.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&movie.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(movie.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(movie.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = movie.tags {
|
||||
for tag in tags {
|
||||
writer
|
||||
.create_element("genre")
|
||||
.write_text_content_async(BytesText::new(&tag))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.create_element("uniqueid")
|
||||
.with_attribute(("type", "bilibili"))
|
||||
.write_text_content_async(BytesText::new(movie.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("premiered")
|
||||
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_tvshow_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, tvshow: TVShow<'_>) -> Result<()> {
|
||||
writer
|
||||
.create_element("tvshow")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("plot")
|
||||
.write_cdata_content_async(BytesCData::new(Self::format_plot(tvshow.bvid, tvshow.intro)))
|
||||
.await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(tvshow.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&tvshow.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = tvshow.tags {
|
||||
for tag in tags {
|
||||
writer
|
||||
.create_element("genre")
|
||||
.write_text_content_async(BytesText::new(&tag))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.create_element("uniqueid")
|
||||
.with_attribute(("type", "bilibili"))
|
||||
.write_text_content_async(BytesText::new(tvshow.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("premiered")
|
||||
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_upper_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, upper: Upper) -> Result<()> {
|
||||
writer
|
||||
.create_element("person")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer.create_element("plot").write_empty_async().await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("lockdata")
|
||||
.write_text_content_async(BytesText::new("false"))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("dateadded")
|
||||
.write_text_content_async(BytesText::new(&upper.pubtime.format("%Y-%m-%d %H:%M:%S").to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(&upper.upper_id))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("sorttitle")
|
||||
.write_text_content_async(BytesText::new(&upper.upper_id))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_episode_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, episode: Episode<'_>) -> Result<()> {
|
||||
writer
|
||||
.create_element("episodedetails")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer.create_element("plot").write_empty_async().await?;
|
||||
writer.create_element("outline").write_empty_async().await?;
|
||||
writer
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(episode.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("season")
|
||||
.write_text_content_async(BytesText::new("1"))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("episode")
|
||||
.write_text_content_async(BytesText::new(&episode.pid))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn format_plot(model: &video::Model) -> String {
|
||||
fn format_plot(bvid: &str, intro: &str) -> String {
|
||||
format!(
|
||||
r#"原始视频:<a href="https://www.bilibili.com/video/{}/">{}</a><br/><br/>{}"#,
|
||||
model.bvid, model.bvid, model.intro
|
||||
bvid, bvid, intro,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +262,7 @@ mod tests {
|
||||
name: "name".to_string(),
|
||||
upper_id: 1,
|
||||
upper_name: "upper_name".to_string(),
|
||||
upper_face: "https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg".to_string(),
|
||||
favtime: chrono::NaiveDateTime::new(
|
||||
chrono::NaiveDate::from_ymd_opt(2022, 2, 2).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
@@ -232,12 +272,12 @@ mod tests {
|
||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||
),
|
||||
bvid: "BV1nWcSeeEkV".to_string(),
|
||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||
tags: Some(vec!["tag1".to_owned(), "tag2".to_owned()].into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::MOVIE)
|
||||
.generate_nfo(&NFOTimeType::PubTime)
|
||||
NFO::Movie((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -248,17 +288,18 @@ mod tests {
|
||||
<actor>
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
<thumb>https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg</thumb>
|
||||
</actor>
|
||||
<year>2033</year>
|
||||
<year>2022</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
|
||||
<aired>2033-03-03</aired>
|
||||
<premiered>2022-02-02</premiered>
|
||||
</movie>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::TVSHOW)
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
NFO::TVShow((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -269,17 +310,18 @@ mod tests {
|
||||
<actor>
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
<thumb>https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg</thumb>
|
||||
</actor>
|
||||
<year>2022</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
|
||||
<aired>2022-02-02</aired>
|
||||
<premiered>2022-02-02</premiered>
|
||||
</tvshow>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::UPPER)
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
NFO::Upper((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -298,8 +340,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Page(&page), NFOMode::EPOSODE)
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
NFO::Episode((&page).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -313,3 +355,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToNFO<'a, T> {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> T;
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, Movie<'a>> for &'a video::Model {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> Movie<'a> {
|
||||
Movie {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
},
|
||||
tags: self.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> TVShow<'a> {
|
||||
TVShow {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
},
|
||||
tags: self.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, Upper> for &'a video::Model {
|
||||
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Upper {
|
||||
Upper {
|
||||
upper_id: self.upper_id.to_string(),
|
||||
pubtime: self.pubtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, Episode<'a>> for &'a page::Model {
|
||||
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Episode<'a> {
|
||||
Episode {
|
||||
name: &self.name,
|
||||
pid: self.pid.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
crates/bili_sync/src/utils/notify.rs
Normal file
13
crates/bili_sync/src/utils/notify.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::Config;
|
||||
use crate::notifier::NotifierAllExt;
|
||||
|
||||
pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String) {
|
||||
error!("{msg}");
|
||||
if let Some(notifiers) = &config.notifiers
|
||||
&& !notifiers.is_empty()
|
||||
{
|
||||
let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone());
|
||||
tokio::spawn(async move { notifiers.notify_all(&inner_client, msg.as_str()).await });
|
||||
}
|
||||
}
|
||||
268
crates/bili_sync/src/utils/rule.rs
Normal file
268
crates/bili_sync/src/utils/rule.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use bili_sync_entity::rule::{AndGroup, Condition, Rule, RuleTarget};
|
||||
use bili_sync_entity::{page, video};
|
||||
use chrono::{Local, NaiveDateTime};
|
||||
|
||||
pub(crate) trait Evaluatable<T> {
|
||||
fn evaluate(&self, value: T) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) trait FieldEvaluatable {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool;
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool;
|
||||
}
|
||||
|
||||
impl Evaluatable<&str> for Condition<String> {
|
||||
fn evaluate(&self, value: &str) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => expected == value,
|
||||
Condition::Contains(substring) => value.contains(substring),
|
||||
Condition::IContains(substring) => value.to_lowercase().contains(&substring.to_lowercase()),
|
||||
Condition::Prefix(prefix) => value.starts_with(prefix),
|
||||
Condition::Suffix(suffix) => value.ends_with(suffix),
|
||||
Condition::MatchesRegex(_, regex) => regex.is_match(value),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluatable<usize> for Condition<usize> {
|
||||
fn evaluate(&self, value: usize) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => *expected == value,
|
||||
Condition::GreaterThan(threshold) => value > *threshold,
|
||||
Condition::LessThan(threshold) => value < *threshold,
|
||||
Condition::Between(start, end) => value > *start && value < *end,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
|
||||
fn evaluate(&self, value: &NaiveDateTime) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => expected == value,
|
||||
Condition::GreaterThan(threshold) => value > threshold,
|
||||
Condition::LessThan(threshold) => value < threshold,
|
||||
Condition::Between(start, end) => value > start && value < end,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for RuleTarget {
|
||||
/// 修改模型后进行评估,此时能访问的是未保存的 activeModel,就地使用 activeModel 评估
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
match self {
|
||||
RuleTarget::Title(cond) => video.name.try_as_ref().is_some_and(|title| cond.evaluate(title)),
|
||||
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
|
||||
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
|
||||
RuleTarget::Tags(cond) => video
|
||||
.tags
|
||||
.try_as_ref()
|
||||
.and_then(|t| t.as_ref())
|
||||
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
|
||||
RuleTarget::FavTime(cond) => video
|
||||
.favtime
|
||||
.try_as_ref()
|
||||
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
|
||||
.is_some_and(|fav_time| cond.evaluate(&fav_time)),
|
||||
RuleTarget::PubTime(cond) => video
|
||||
.pubtime
|
||||
.try_as_ref()
|
||||
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
|
||||
.is_some_and(|pub_time| cond.evaluate(&pub_time)),
|
||||
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动触发对历史视频的评估,拿到的是原始 Model,直接使用
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
match self {
|
||||
RuleTarget::Title(cond) => cond.evaluate(&video.name),
|
||||
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
|
||||
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
|
||||
RuleTarget::Tags(cond) => video
|
||||
.tags
|
||||
.as_ref()
|
||||
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
|
||||
RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()),
|
||||
RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()),
|
||||
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for AndGroup {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
self.iter().all(|target| target.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
self.iter().all(|target| target.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for Rule {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.0.iter().any(|group| group.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.0.iter().any(|group| group.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
/// 对于 Option<Rule> 如果 rule 不存在应该被认为是通过评估
|
||||
impl FieldEvaluatable for Option<Rule> {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
self.as_ref().is_none_or(|rule| rule.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
self.as_ref().is_none_or(|rule| rule.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bili_sync_entity::page;
|
||||
use chrono::NaiveDate;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let test_cases = vec![
|
||||
(
|
||||
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||
"「(标题包含“唐氏”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::Title(Condition::Prefix("街霸".to_string())),
|
||||
RuleTarget::Tags(Condition::Contains("套路".to_string())),
|
||||
]]),
|
||||
"「(标题以“街霸”开头)且(标签包含“套路”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![
|
||||
vec![
|
||||
RuleTarget::Title(Condition::Contains("Rust".to_string())),
|
||||
RuleTarget::PageCount(Condition::GreaterThan(5)),
|
||||
],
|
||||
vec![
|
||||
RuleTarget::Tags(Condition::Suffix("入门".to_string())),
|
||||
RuleTarget::PubTime(Condition::GreaterThan(
|
||||
NaiveDate::from_ymd_opt(2023, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
)),
|
||||
],
|
||||
]),
|
||||
"「(标题包含“Rust”)且(视频分页数量大于“5”)」或「(标签以“入门”结尾)且(发布时间大于“2023-01-01 00:00:00”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::Not(Box::new(RuleTarget::Title(Condition::Contains("广告".to_string())))),
|
||||
RuleTarget::PageCount(Condition::LessThan(10)),
|
||||
]]),
|
||||
"「(标题不包含“广告”)且(视频分页数量小于“10”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::FavTime(Condition::Between(
|
||||
NaiveDate::from_ymd_opt(2023, 6, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
NaiveDate::from_ymd_opt(2023, 12, 31)
|
||||
.unwrap()
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.unwrap(),
|
||||
)),
|
||||
// autocorrect-disable
|
||||
RuleTarget::Tags(Condition::MatchesRegex(
|
||||
"技术|教程".to_string(),
|
||||
regex::Regex::new("技术|教程").unwrap(),
|
||||
)),
|
||||
]]),
|
||||
"「(收藏时间在“2023-06-01 00:00:00”和“2023-12-31 23:59:59”之间)且(标签匹配“技术|教程”)」",
|
||||
// autocorrect-enable
|
||||
),
|
||||
];
|
||||
|
||||
for (rule, expected) in test_cases {
|
||||
assert_eq!(rule.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate() {
|
||||
let test_cases = vec![
|
||||
(
|
||||
(
|
||||
video::ActiveModel {
|
||||
name: Set("骂谁唐氏呢!!!".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel::default(),
|
||||
vec![page::ActiveModel::default(); 2],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::PageCount(Condition::Equals(1))]]),
|
||||
false,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel{
|
||||
tags: Set(Some(vec!["原神".to_owned(),"永雏塔菲".to_owned(),"虚拟主播".to_owned()].into())),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule (vec![vec![RuleTarget::Not(Box::new(RuleTarget::Tags(Condition::Equals(
|
||||
"原神".to_string(),
|
||||
))))]],
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel {
|
||||
name: Set(
|
||||
"万字怒扒网易《归唐》底裤!中国首款大厂买断制单机,靠谱吗?——全网最全!官方非独家幕后!关于《归唐》PV 的所有秘密~都在这里了~".to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::Not(Box::new(RuleTarget::Title(Condition::MatchesRegex(
|
||||
r"^\S+字(解析|怒扒|拆解)".to_owned(),
|
||||
regex::Regex::new(r"^\S+字(解析|怒扒)").unwrap(),
|
||||
))))]],
|
||||
),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
for ((video, pages), rule, expected) in test_cases {
|
||||
assert_eq!(rule.evaluate(&video, &pages), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use bili_sync_entity::{page, video};
|
||||
use bili_sync_migration::{ExprTrait, IntoCondition};
|
||||
use sea_orm::sea_query::Expr;
|
||||
use sea_orm::{ColumnTrait, Condition};
|
||||
|
||||
use crate::error::ExecutionStatus;
|
||||
|
||||
pub static STATUS_NOT_STARTED: u32 = 0b000;
|
||||
pub(super) static STATUS_MAX_RETRY: u32 = 0b100;
|
||||
pub static STATUS_OK: u32 = 0b111;
|
||||
pub static STATUS_COMPLETED: u32 = 1 << 31;
|
||||
@@ -10,10 +18,17 @@ pub static STATUS_COMPLETED: u32 = 1 << 31;
|
||||
/// 如果子任务执行成功,将状态设置为 0b111,该值定义为 STATUS_OK。
|
||||
/// 子任务达到最大失败次数或者执行成功时,认为该子任务已经完成。
|
||||
/// 当所有子任务都已经完成时,为最高位打上标记 1,表示整个下载任务已经完成。
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Status<const N: usize>(u32);
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Status<const N: usize, C>(u32, PhantomData<C>);
|
||||
|
||||
impl<const N: usize> Status<N> {
|
||||
impl<const N: usize, C> Default for Status<N, C> {
|
||||
fn default() -> Self {
|
||||
Self(0, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize, C> Status<N, C> {
|
||||
pub(crate) const LEN: usize = N;
|
||||
// 获取最高位的完成标记
|
||||
pub fn get_completed(&self) -> bool {
|
||||
self.0 >> 31 == 1
|
||||
@@ -33,17 +48,28 @@ impl<const N: usize> Status<N> {
|
||||
let mut changed = false;
|
||||
for i in 0..N {
|
||||
let status = self.get_status(i);
|
||||
if !(status < STATUS_MAX_RETRY || status == STATUS_OK) {
|
||||
self.set_status(i, 0);
|
||||
if status != STATUS_NOT_STARTED && status != STATUS_OK {
|
||||
self.set_status(i, STATUS_NOT_STARTED);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// 理论上 changed 可以直接从上面的循环中得到,因为 completed 标志位的改变是由子任务状态的改变引起的,子任务没有改变则 completed 也不会改变
|
||||
if changed {
|
||||
self.set_completed(false);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 重置所有失败的状态,将状态设置为 0b000,返回值表示 status 是否发生了变化
|
||||
/// force 版本在普通版本的基础上,会额外检查是否存在需要运行的任务,如果存在则修正 completed 标记位为“未完成”
|
||||
/// 这个方法的典型用例是在引入新的任务状态后重置历史视频,允许历史视频执行新引入的任务
|
||||
pub fn force_reset_failed(&mut self) -> bool {
|
||||
let mut changed = self.reset_failed();
|
||||
// 理论上上面的 changed 就足够了,因为 completed 标志位的改变是由子任务状态的改变引起的,子任务没有改变则 completed 也不会改变
|
||||
// 但考虑特殊情况,新版本引入了一个新的子任务项,此时会出现明明有子任务未执行,但 completed 标记位仍然为 true 的情况
|
||||
// 当然可以在新版本迁移文件中全局重置 completed 标记位,但这样影响范围太大感觉不太好
|
||||
// 在后面进行这部分额外判断可以兼容这种情况,在由用户手动触发的 reset_failed 调用中修正 completed 标记位
|
||||
if self.should_run().into_iter().any(|x| x) {
|
||||
changed |= self.get_completed();
|
||||
if !changed && self.get_completed() && self.should_run().into_iter().any(|x| x) {
|
||||
changed = true;
|
||||
self.set_completed(false);
|
||||
}
|
||||
changed
|
||||
@@ -110,8 +136,8 @@ impl<const N: usize> Status<N> {
|
||||
|
||||
/// 根据子任务执行结果更新子任务的状态
|
||||
fn set_result(&mut self, result: &ExecutionStatus, offset: usize) {
|
||||
// 如果任务返回 FixedFailed 状态,那么无论之前的状态如何,都将状态设置为 FixedFailed 的状态
|
||||
if let ExecutionStatus::FixedFailed(status, _) = result {
|
||||
// 如果任务返回 Fixed 状态,那么无论之前的状态如何,都将状态设置为 Fixed 的状态
|
||||
if let ExecutionStatus::Fixed(status) = result {
|
||||
assert!(*status < 0b1000, "status should be less than 0b1000");
|
||||
self.set_status(offset, *status);
|
||||
} else if self.get_status(offset) < STATUS_MAX_RETRY {
|
||||
@@ -124,20 +150,20 @@ impl<const N: usize> Status<N> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> From<u32> for Status<N> {
|
||||
impl<const N: usize, C> From<u32> for Status<N, C> {
|
||||
fn from(status: u32) -> Self {
|
||||
Status(status)
|
||||
Status(status, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> From<Status<N>> for u32 {
|
||||
fn from(status: Status<N>) -> Self {
|
||||
impl<const N: usize, C> From<Status<N, C>> for u32 {
|
||||
fn from(status: Status<N, C>) -> Self {
|
||||
status.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> From<Status<N>> for [u32; N] {
|
||||
fn from(status: Status<N>) -> Self {
|
||||
impl<const N: usize, C> From<Status<N, C>> for [u32; N] {
|
||||
fn from(status: Status<N, C>) -> Self {
|
||||
let mut result = [0; N];
|
||||
for (i, item) in result.iter_mut().enumerate() {
|
||||
*item = status.get_status(i);
|
||||
@@ -146,9 +172,9 @@ impl<const N: usize> From<Status<N>> for [u32; N] {
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> From<[u32; N]> for Status<N> {
|
||||
impl<const N: usize, C> From<[u32; N]> for Status<N, C> {
|
||||
fn from(status: [u32; N]) -> Self {
|
||||
let mut result = Status::<N>::default();
|
||||
let mut result = Self::default();
|
||||
for (i, item) in status.iter().enumerate() {
|
||||
assert!(*item < 0b1000, "status should be less than 0b1000");
|
||||
result.set_status(i, *item);
|
||||
@@ -160,21 +186,75 @@ impl<const N: usize> From<[u32; N]> for Status<N> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 包含五个子任务,从前到后依次是:视频封面、视频信息、Up 主头像、Up 主信息、分 P 下载
|
||||
pub type VideoStatus = Status<5>;
|
||||
/// 包含五个子任务,从前到后依次是:视频封面、视频信息、Up 主头像、Up 主信息、分页下载
|
||||
pub type VideoStatus = Status<5, video::Column>;
|
||||
|
||||
impl VideoStatus {
|
||||
pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, video::Column> {
|
||||
StatusQueryBuilder::new(video::Column::DownloadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/// 包含五个子任务,从前到后分别是:视频封面、视频内容、视频信息、视频弹幕、视频字幕
|
||||
pub type PageStatus = Status<5>;
|
||||
pub type PageStatus = Status<5, page::Column>;
|
||||
|
||||
impl PageStatus {
|
||||
pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, page::Column> {
|
||||
StatusQueryBuilder::new(page::Column::DownloadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatusQueryBuilder<const N: usize, C: ColumnTrait> {
|
||||
column: C,
|
||||
}
|
||||
|
||||
impl<const N: usize, C: ColumnTrait> StatusQueryBuilder<N, C> {
|
||||
fn new(column: C) -> Self {
|
||||
Self { column }
|
||||
}
|
||||
|
||||
/// 完成状态:所有子任务的状态都是成功
|
||||
pub fn succeeded(&self) -> Condition {
|
||||
let mut condition = Condition::all();
|
||||
for offset in 0..N as i32 {
|
||||
condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(7))
|
||||
}
|
||||
condition
|
||||
}
|
||||
|
||||
/// 失败状态:存在任何失败的子任务
|
||||
pub fn failed(&self) -> Condition {
|
||||
let mut condition = Condition::any();
|
||||
for offset in 0..N as i32 {
|
||||
condition = condition.add(
|
||||
Expr::col(self.column)
|
||||
.right_shift(offset * 3)
|
||||
.bit_and(7)
|
||||
.is_not_in([0, 7]),
|
||||
)
|
||||
}
|
||||
condition
|
||||
}
|
||||
|
||||
/// 等待状态:所有子任务的状态都不是失败,且其中存在未开始
|
||||
pub fn waiting(&self) -> Condition {
|
||||
let mut condition = Condition::any();
|
||||
for offset in 0..N as i32 {
|
||||
condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(0))
|
||||
}
|
||||
condition.and(self.failed().not()).into_condition()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_status_update() {
|
||||
let mut status = Status::<3>::default();
|
||||
let mut status = Status::<3, video::Column>::default();
|
||||
assert_eq!(status.should_run(), [true, true, true]);
|
||||
for _ in 0..3 {
|
||||
status.update_status(&[
|
||||
@@ -192,9 +272,9 @@ mod test {
|
||||
assert_eq!(status.should_run(), [false, false, false]);
|
||||
assert!(status.get_completed());
|
||||
status.update_status(&[
|
||||
ExecutionStatus::FixedFailed(1, anyhow!("")),
|
||||
ExecutionStatus::FixedFailed(4, anyhow!("")),
|
||||
ExecutionStatus::FixedFailed(7, anyhow!("")),
|
||||
ExecutionStatus::Fixed(1),
|
||||
ExecutionStatus::Fixed(4),
|
||||
ExecutionStatus::Fixed(7),
|
||||
]);
|
||||
assert_eq!(status.should_run(), [true, false, false]);
|
||||
assert!(!status.get_completed());
|
||||
@@ -205,7 +285,7 @@ mod test {
|
||||
fn test_status_convert() {
|
||||
let testcases = [[0, 0, 1], [1, 2, 3], [3, 1, 2], [3, 0, 7]];
|
||||
for testcase in testcases.iter() {
|
||||
let status = Status::<3>::from(testcase.clone());
|
||||
let status = Status::<3, video::Column>::from(testcase.clone());
|
||||
assert_eq!(<[u32; 3]>::from(status), *testcase);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +294,7 @@ mod test {
|
||||
fn test_status_convert_and_update() {
|
||||
let testcases = [([0, 0, 1], [1, 7, 7]), ([3, 4, 3], [4, 4, 7]), ([3, 1, 7], [4, 7, 7])];
|
||||
for (before, after) in testcases.iter() {
|
||||
let mut status = Status::<3>::from(before.clone());
|
||||
let mut status = Status::<3, video::Column>::from(before.clone());
|
||||
status.update_status(&[
|
||||
ExecutionStatus::Failed(anyhow!("")),
|
||||
ExecutionStatus::Succeeded,
|
||||
@@ -226,34 +306,43 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_status_reset_failed() {
|
||||
// 重置一个已经失败的任务
|
||||
let mut status = Status::<3>::from([3, 4, 7]);
|
||||
// 重置一个出现部分失败但还有重试次数的任务,将所有的失败状态重置为 0
|
||||
let mut status = Status::<3, video::Column>::from([3, 4, 7]);
|
||||
assert!(!status.get_completed());
|
||||
assert!(status.reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
assert_eq!(<[u32; 3]>::from(status), [3, 0, 7]);
|
||||
// 没有内容需要重置,但 completed 标记位是错误的(模拟新增一个子任务状态的情况),此时 reset_failed 会修正 completed 标记位
|
||||
assert_eq!(<[u32; 3]>::from(status), [0, 0, 7]);
|
||||
// 没有内容需要重置,但 completed 标记位是错误的(模拟新增一个子任务状态的情况)
|
||||
// 此时 reset_failed 不会修正 completed 标记位,而 force_reset_failed 会
|
||||
status.set_completed(true);
|
||||
assert!(status.get_completed());
|
||||
assert!(status.reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
// 重置一个已经成功的任务,没有改变状态,也不会修改标记位
|
||||
let mut status = Status::<3>::from([7, 7, 7]);
|
||||
assert!(status.get_completed());
|
||||
assert!(!status.reset_failed());
|
||||
assert!(status.get_completed());
|
||||
assert!(status.force_reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
// 重置一个已经成功的任务,没有改变状态,也不会修改标记位
|
||||
let mut status = Status::<3, video::Column>::from([7, 7, 7]);
|
||||
assert!(status.get_completed());
|
||||
assert!(!status.reset_failed());
|
||||
assert!(status.get_completed());
|
||||
// 重置一个全部失败的任务,修改状态并且修改标记位
|
||||
let mut status = Status::<3, video::Column>::from([4, 4, 4]);
|
||||
assert!(status.get_completed());
|
||||
assert!(status.reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
assert_eq!(<[u32; 3]>::from(status), [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_set() {
|
||||
// 设置子状态,从 completed 到 uncompleted
|
||||
let mut status = Status::<5>::from([7, 7, 7, 7, 7]);
|
||||
let mut status = Status::<5, video::Column>::from([7, 7, 7, 7, 7]);
|
||||
assert!(status.get_completed());
|
||||
status.set(4, 0);
|
||||
assert!(!status.get_completed());
|
||||
assert_eq!(<[u32; 5]>::from(status), [7, 7, 7, 7, 0]);
|
||||
// 设置子状态,从 uncompleted 到 completed
|
||||
let mut status = Status::<5>::from([4, 7, 7, 7, 0]);
|
||||
let mut status = Status::<5, video::Column>::from([4, 7, 7, 7, 0]);
|
||||
assert!(!status.get_completed());
|
||||
status.set(4, 7);
|
||||
assert!(status.get_completed());
|
||||
|
||||
23
crates/bili_sync/src/utils/validation.rs
Normal file
23
crates/bili_sync/src/utils/validation.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use std::path::Path;
|
||||
|
||||
use validator::ValidationError;
|
||||
|
||||
use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK};
|
||||
|
||||
pub fn validate_status_value(value: u32) -> Result<(), ValidationError> {
|
||||
if value == STATUS_OK || value == STATUS_NOT_STARTED {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new(
|
||||
"status_value must be either STATUS_OK or STATUS_NOT_STARTED",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_path(path: &str) -> Result<(), ValidationError> {
|
||||
if path.is_empty() || !Path::new(path).is_absolute() {
|
||||
Err(ValidationError::new("path must be a non-empty absolute path"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,45 +4,52 @@ use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use bili_sync_entity::*;
|
||||
use futures::stream::{FuturesOrdered, FuturesUnordered};
|
||||
use futures::{Future, Stream, StreamExt, TryStreamExt};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::TransactionTrait;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use tokio::fs;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::adapter::{Args, VideoSource, VideoSourceEnum, video_source_from};
|
||||
use crate::adapter::{VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Video, VideoInfo};
|
||||
use crate::config::{ARGS, CONFIG, PathSafeTemplate, TEMPLATE};
|
||||
use crate::config::{ARGS, Config, PathSafeTemplate};
|
||||
use crate::downloader::Downloader;
|
||||
use crate::error::{DownloadAbortError, ExecutionStatus, ProcessPageError};
|
||||
use crate::error::ExecutionStatus;
|
||||
use crate::utils::download_context::DownloadContext;
|
||||
use crate::utils::format_arg::{page_format_args, video_format_args};
|
||||
use crate::utils::model::{
|
||||
create_pages, create_videos, filter_unfilled_videos, filter_unhandled_video_pages, update_pages_model,
|
||||
update_videos_model,
|
||||
};
|
||||
use crate::utils::nfo::{ModelWrapper, NFOMode, NFOSerializer};
|
||||
use crate::utils::nfo::{NFO, ToNFO};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
||||
|
||||
/// 完整地处理某个视频来源
|
||||
pub async fn process_video_source(
|
||||
args: Args<'_>,
|
||||
video_source: VideoSourceEnum,
|
||||
bili_client: &BiliClient,
|
||||
path: &Path,
|
||||
connection: &DatabaseConnection,
|
||||
template: &handlebars::Handlebars<'_>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
// 预创建视频源目录,提前检测目录是否可写
|
||||
video_source.create_dir_all().await?;
|
||||
// 从参数中获取视频列表的 Model 与视频流
|
||||
let (video_source, video_streams) = video_source_from(args, path, bili_client, connection).await?;
|
||||
let (video_source, video_streams) = video_source
|
||||
.refresh(bili_client, &config.credential, connection)
|
||||
.await?;
|
||||
// 从视频流中获取新视频的简要信息,写入数据库
|
||||
refresh_video_source(&video_source, video_streams, connection).await?;
|
||||
// 单独请求视频详情接口,获取视频的详情信息与所有的分页,写入数据库
|
||||
fetch_video_details(bili_client, &video_source, connection).await?;
|
||||
fetch_video_details(bili_client, &video_source, connection, config).await?;
|
||||
if ARGS.scan_only {
|
||||
warn!("已开启仅扫描模式,跳过视频下载..");
|
||||
} else {
|
||||
// 从数据库中查找所有未下载的视频与分页,下载并处理
|
||||
download_unprocessed_videos(bili_client, &video_source, connection).await?;
|
||||
download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -58,10 +65,18 @@ pub async fn refresh_video_source<'a>(
|
||||
let mut max_datetime = latest_row_at;
|
||||
let mut error = Ok(());
|
||||
let mut video_streams = video_streams
|
||||
.take_while(|res| {
|
||||
.enumerate()
|
||||
.take_while(|(idx, res)| {
|
||||
match res {
|
||||
Err(e) => {
|
||||
error = Err(anyhow!(e.to_string()));
|
||||
// 这里拿到的 e 是引用,无法直接传递所有权
|
||||
// 对于 BiliError,我们需要克隆内部的错误并附带原来的上下文,方便外部检查错误类型
|
||||
// 对于其他错误只保留字符串信息用作提示
|
||||
if let Some(inner) = e.downcast_ref::<BiliError>() {
|
||||
error = Err(inner.clone()).context(e.to_string());
|
||||
} else {
|
||||
error = Err(anyhow!("{:#}", e));
|
||||
}
|
||||
futures::future::ready(false)
|
||||
}
|
||||
Ok(v) => {
|
||||
@@ -72,11 +87,11 @@ pub async fn refresh_video_source<'a>(
|
||||
if release_datetime > &max_datetime {
|
||||
max_datetime = *release_datetime;
|
||||
}
|
||||
futures::future::ready(video_source.should_take(release_datetime, &latest_row_at))
|
||||
futures::future::ready(video_source.should_take(*idx, release_datetime, &latest_row_at))
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
.filter_map(|(idx, res)| futures::future::ready(video_source.should_filter(idx, res, &latest_row_at)))
|
||||
.chunks(10);
|
||||
let mut count = 0;
|
||||
while let Some(videos_info) = video_streams.next().await {
|
||||
@@ -100,42 +115,56 @@ pub async fn fetch_video_details(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
connection: &DatabaseConnection,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
video_source.log_fetch_video_start();
|
||||
let videos_model = filter_unfilled_videos(video_source.filter_expr(), connection).await?;
|
||||
for video_model in videos_model {
|
||||
let video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
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?;
|
||||
let semaphore = Semaphore::new(config.concurrent_limit.video);
|
||||
let semaphore_ref = &semaphore;
|
||||
let tasks = videos_model
|
||||
.into_iter()
|
||||
.map(|video_model| async move {
|
||||
let _permit = semaphore_ref.acquire().await.context("acquire semaphore failed")?;
|
||||
let video = Video::new(bili_client, video_model.bvid.clone(), &config.credential);
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::ErrorResponse(-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((tags, mut view_info)) => {
|
||||
let VideoInfo::Detail { pages, .. } = &mut view_info else {
|
||||
unreachable!()
|
||||
};
|
||||
let pages = std::mem::take(pages);
|
||||
let pages_len = pages.len();
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_pages(pages, &video_model, &txn).await?;
|
||||
let mut video_active_model = view_info.into_detail_model(video_model);
|
||||
video_source.set_relation_id(&mut video_active_model);
|
||||
video_active_model.single_page = Set(Some(pages_len == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags)?));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((tags, mut view_info)) => {
|
||||
let VideoInfo::Detail { pages, .. } = &mut view_info else {
|
||||
unreachable!()
|
||||
};
|
||||
// 构造 page model
|
||||
let pages = std::mem::take(pages);
|
||||
let pages = pages
|
||||
.into_iter()
|
||||
.map(|p| p.into_active_model(video_model.id))
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
// 更新 video model 的各项有关属性
|
||||
let mut video_active_model = view_info.into_detail_model(video_model);
|
||||
video_source.set_relation_id(&mut video_active_model);
|
||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||
video_active_model.tags = Set(Some(tags.into()));
|
||||
video_active_model.should_download = Set(video_source.rule().evaluate(&video_active_model, &pages));
|
||||
let txn = connection.begin().await?;
|
||||
create_pages(pages, &txn).await?;
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
};
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<Vec<_>>().await?;
|
||||
video_source.log_fetch_video_end();
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,10 +174,13 @@ pub async fn download_unprocessed_videos(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
connection: &DatabaseConnection,
|
||||
template: &handlebars::Handlebars<'_>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
video_source.log_download_video_start();
|
||||
let semaphore = Semaphore::new(CONFIG.concurrent_limit.video);
|
||||
let semaphore = Semaphore::new(config.concurrent_limit.video);
|
||||
let downloader = Downloader::new(bili_client.client.clone());
|
||||
let cx = DownloadContext::new(bili_client, video_source, template, connection, &downloader, config);
|
||||
let unhandled_videos_pages = filter_unhandled_video_pages(video_source.filter_expr(), connection).await?;
|
||||
let mut assigned_upper = HashSet::new();
|
||||
let tasks = unhandled_videos_pages
|
||||
@@ -156,29 +188,20 @@ pub async fn download_unprocessed_videos(
|
||||
.map(|(video_model, pages_model)| {
|
||||
let should_download_upper = !assigned_upper.contains(&video_model.upper_id);
|
||||
assigned_upper.insert(video_model.upper_id);
|
||||
download_video_pages(
|
||||
bili_client,
|
||||
video_source,
|
||||
video_model,
|
||||
pages_model,
|
||||
connection,
|
||||
&semaphore,
|
||||
&downloader,
|
||||
should_download_upper,
|
||||
)
|
||||
download_video_pages(video_model, pages_model, &semaphore, should_download_upper, cx)
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let mut download_aborted = false;
|
||||
let mut risk_control_related_error = None;
|
||||
let mut stream = tasks
|
||||
// 触发风控时设置 download_aborted 标记并终止流
|
||||
.take_while(|res| {
|
||||
if res
|
||||
.as_ref()
|
||||
.is_err_and(|e| e.downcast_ref::<DownloadAbortError>().is_some())
|
||||
if let Err(e) = res
|
||||
&& let Some(e) = e.downcast_ref::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
download_aborted = true;
|
||||
risk_control_related_error = Some(e.clone());
|
||||
}
|
||||
futures::future::ready(!download_aborted)
|
||||
futures::future::ready(risk_control_related_error.is_none())
|
||||
})
|
||||
// 过滤掉没有触发风控的普通 Err,只保留正确返回的 Model
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
@@ -187,79 +210,75 @@ pub async fn download_unprocessed_videos(
|
||||
while let Some(models) = stream.next().await {
|
||||
update_videos_model(models, connection).await?;
|
||||
}
|
||||
if download_aborted {
|
||||
error!("下载触发风控,已终止所有任务,等待下一轮执行");
|
||||
if let Some(e) = risk_control_related_error {
|
||||
bail!(e);
|
||||
}
|
||||
video_source.log_download_video_end();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_video_pages(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
video_model: video::Model,
|
||||
pages: Vec<page::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
page_models: Vec<page::Model>,
|
||||
semaphore: &Semaphore,
|
||||
downloader: &Downloader,
|
||||
should_download_upper: bool,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<video::ActiveModel> {
|
||||
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
|
||||
let mut status = VideoStatus::from(video_model.download_status);
|
||||
let separate_status = status.should_run();
|
||||
let base_path = video_source
|
||||
.path()
|
||||
.join(TEMPLATE.path_safe_render("video", &video_format_args(&video_model))?);
|
||||
// 未记录路径时填充,已经填充过路径时使用现有的
|
||||
let base_path = if !video_model.path.is_empty() {
|
||||
PathBuf::from(&video_model.path)
|
||||
} else {
|
||||
cx.video_source.path().join(
|
||||
cx.template
|
||||
.path_safe_render("video", &video_format_args(&video_model, &cx.config.time_format))?,
|
||||
)
|
||||
};
|
||||
let upper_id = video_model.upper_id.to_string();
|
||||
let base_upper_path = &CONFIG
|
||||
let base_upper_path = cx
|
||||
.config
|
||||
.upper_path
|
||||
.join(upper_id.chars().next().context("upper_id is empty")?.to_string())
|
||||
.join(upper_id);
|
||||
let is_single_page = video_model.single_page.context("single_page is null")?;
|
||||
// 对于单页视频,page 的下载已经足够
|
||||
// 对于多页视频,page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo
|
||||
let tasks: Vec<Pin<Box<dyn Future<Output = Result<ExecutionStatus>> + Send>>> = vec![
|
||||
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
|
||||
// 下载视频封面
|
||||
Box::pin(fetch_video_poster(
|
||||
separate_status[0] && !is_single_page,
|
||||
fetch_video_poster(
|
||||
separate_status[0] && !is_single_page && !cx.config.skip_option.no_poster,
|
||||
&video_model,
|
||||
downloader,
|
||||
base_path.join("poster.jpg"),
|
||||
base_path.join("fanart.jpg"),
|
||||
)),
|
||||
cx
|
||||
),
|
||||
// 生成视频信息的 nfo
|
||||
Box::pin(generate_video_nfo(
|
||||
separate_status[1] && !is_single_page,
|
||||
generate_video_nfo(
|
||||
separate_status[1] && !is_single_page && !cx.config.skip_option.no_video_nfo,
|
||||
&video_model,
|
||||
base_path.join("tvshow.nfo"),
|
||||
)),
|
||||
cx
|
||||
),
|
||||
// 下载 Up 主头像
|
||||
Box::pin(fetch_upper_face(
|
||||
separate_status[2] && should_download_upper,
|
||||
fetch_upper_face(
|
||||
separate_status[2] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
downloader,
|
||||
base_upper_path.join("folder.jpg"),
|
||||
)),
|
||||
cx
|
||||
),
|
||||
// 生成 Up 主信息的 nfo
|
||||
Box::pin(generate_upper_nfo(
|
||||
separate_status[3] && should_download_upper,
|
||||
generate_upper_nfo(
|
||||
separate_status[3] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
base_upper_path.join("person.nfo"),
|
||||
)),
|
||||
// 分发并执行分 P 下载的任务
|
||||
Box::pin(dispatch_download_page(
|
||||
separate_status[4],
|
||||
bili_client,
|
||||
&video_model,
|
||||
pages,
|
||||
connection,
|
||||
downloader,
|
||||
&base_path,
|
||||
)),
|
||||
];
|
||||
let tasks: FuturesOrdered<_> = tasks.into_iter().collect();
|
||||
let results: Vec<ExecutionStatus> = tasks.collect::<Vec<_>>().await.into_iter().map(Into::into).collect();
|
||||
cx,
|
||||
),
|
||||
// 分发并执行分页下载的任务
|
||||
dispatch_download_page(separate_status[4], &video_model, page_models, &base_path, cx)
|
||||
);
|
||||
let results = [res_1.into(), res_2.into(), res_3.into(), res_4.into(), res_5.into()];
|
||||
status.update_status(&results);
|
||||
results
|
||||
.iter()
|
||||
@@ -270,17 +289,21 @@ pub async fn download_video_pages(
|
||||
ExecutionStatus::Succeeded => info!("处理视频「{}」{}成功", &video_model.name, task_name),
|
||||
ExecutionStatus::Ignored(e) => {
|
||||
error!(
|
||||
"处理视频「{}」{}出现常见错误,已忽略: {:#}",
|
||||
"处理视频「{}」{}出现常见错误,已忽略:{:#}",
|
||||
&video_model.name, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => {
|
||||
error!("处理视频「{}」{}失败: {:#}", &video_model.name, task_name, e)
|
||||
ExecutionStatus::Failed(e) => {
|
||||
error!("处理视频「{}」{}失败:{:#}", &video_model.name, task_name, e)
|
||||
}
|
||||
ExecutionStatus::Fixed(_) => unreachable!(),
|
||||
});
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")? {
|
||||
if e.downcast_ref::<DownloadAbortError>().is_some() {
|
||||
return Err(e);
|
||||
for result in results {
|
||||
if let ExecutionStatus::Failed(e) = result
|
||||
&& let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
bail!(e);
|
||||
}
|
||||
}
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
@@ -292,39 +315,28 @@ pub async fn download_video_pages(
|
||||
/// 分发并执行分页下载任务,当且仅当所有分页成功下载或达到最大重试次数时返回 Ok,否则根据失败原因返回对应的错误
|
||||
pub async fn dispatch_download_page(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
pages: Vec<page::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
downloader: &Downloader,
|
||||
page_models: Vec<page::Model>,
|
||||
base_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let child_semaphore = Semaphore::new(CONFIG.concurrent_limit.page);
|
||||
let tasks = pages
|
||||
let child_semaphore = Semaphore::new(cx.config.concurrent_limit.page);
|
||||
let tasks = page_models
|
||||
.into_iter()
|
||||
.map(|page_model| {
|
||||
download_page(
|
||||
bili_client,
|
||||
video_model,
|
||||
page_model,
|
||||
&child_semaphore,
|
||||
downloader,
|
||||
base_path,
|
||||
)
|
||||
})
|
||||
.map(|page_model| download_page(video_model, page_model, &child_semaphore, base_path, cx))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let (mut download_aborted, mut target_status) = (false, STATUS_OK);
|
||||
let (mut risk_control_related_error, mut target_status) = (None, STATUS_OK);
|
||||
let mut stream = tasks
|
||||
.take_while(|res| {
|
||||
match res {
|
||||
Ok(model) => {
|
||||
// 该视频的所有分页的下载状态都会在此返回,需要根据这些状态确认视频层“分 P 下载”子任务的状态
|
||||
// 该视频的所有分页的下载状态都会在此返回,需要根据这些状态确认视频层“分页下载”子任务的状态
|
||||
// 在过去的实现中,此处仅仅根据 page_download_status 的最高标志位来判断,如果最高标志位是 true 则认为完成
|
||||
// 这样会导致即使分页中有失败到 MAX_RETRY 的情况,视频层的分 P 下载状态也会被认为是 Succeeded,不够准确
|
||||
// 新版本实现会将此处取值为所有子任务状态的最小值,这样只有所有分页的子任务全部成功时才会认为视频层的分 P 下载状态是 Succeeded
|
||||
// 这样会导致即使分页中有失败到 MAX_RETRY 的情况,视频层的分页下载状态也会被认为是 Succeeded,不够准确
|
||||
// 新版本实现会将此处取值为所有子任务状态的最小值,这样只有所有分页的子任务全部成功时才会认为视频层的分页下载状态是 Succeeded
|
||||
let page_download_status = model.download_status.try_as_ref().expect("download_status must be set");
|
||||
let separate_status: [u32; 5] = PageStatus::from(*page_download_status).into();
|
||||
for status in separate_status {
|
||||
@@ -332,43 +344,78 @@ pub async fn dispatch_download_page(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<DownloadAbortError>().is_some() {
|
||||
download_aborted = true;
|
||||
if let Some(e) = e.downcast_ref::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
risk_control_related_error = Some(e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅在发生风控时终止流,其它情况继续执行
|
||||
futures::future::ready(!download_aborted)
|
||||
futures::future::ready(risk_control_related_error.is_none())
|
||||
})
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
.chunks(10);
|
||||
while let Some(models) = stream.next().await {
|
||||
update_pages_model(models, connection).await?;
|
||||
update_pages_model(models, cx.connection).await?;
|
||||
}
|
||||
if download_aborted {
|
||||
error!("下载视频「{}」的分页时触发风控,将异常向上传递..", &video_model.name);
|
||||
bail!(DownloadAbortError());
|
||||
if let Some(e) = risk_control_related_error {
|
||||
bail!(e);
|
||||
}
|
||||
if target_status != STATUS_OK {
|
||||
return Ok(ExecutionStatus::FixedFailed(target_status, ProcessPageError().into()));
|
||||
}
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
// 视频中“分页下载”任务的状态始终与所有分页的最小状态一致
|
||||
Ok(ExecutionStatus::Fixed(target_status))
|
||||
}
|
||||
|
||||
/// 下载某个分页,未发生风控且正常运行时返回 Ok(Page::ActiveModel),其中 status 字段存储了新的下载状态,发生风控时返回 DownloadAbortError
|
||||
pub async fn download_page(
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_model: page::Model,
|
||||
semaphore: &Semaphore,
|
||||
downloader: &Downloader,
|
||||
base_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<page::ActiveModel> {
|
||||
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
|
||||
let mut status = PageStatus::from(page_model.download_status);
|
||||
let separate_status = status.should_run();
|
||||
let is_single_page = video_model.single_page.context("single_page is null")?;
|
||||
let base_name = TEMPLATE.path_safe_render("page", &page_format_args(video_model, &page_model))?;
|
||||
// 未记录路径时填充,已经填充过路径时使用现有的
|
||||
let (base_path, base_name) = if let Some(old_video_path) = &page_model.path
|
||||
&& !old_video_path.is_empty()
|
||||
{
|
||||
let old_video_path = Path::new(old_video_path);
|
||||
let old_video_filename = old_video_path
|
||||
.file_name()
|
||||
.context("invalid page path format")?
|
||||
.to_string_lossy();
|
||||
if is_single_page {
|
||||
// 单页下的路径是 {base_path}/{base_name}.mp4
|
||||
(
|
||||
old_video_path.parent().context("invalid page path format")?,
|
||||
old_video_filename.trim_end_matches(".mp4").to_string(),
|
||||
)
|
||||
} else {
|
||||
// 多页下的路径是 {base_path}/Season 1/{base_name} - S01Exx.mp4
|
||||
(
|
||||
old_video_path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.context("invalid page path format")?,
|
||||
old_video_filename
|
||||
.rsplit_once(" - ")
|
||||
.context("invalid page path format")?
|
||||
.0
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(
|
||||
base_path,
|
||||
cx.template.path_safe_render(
|
||||
"page",
|
||||
&page_format_args(video_model, &page_model, &cx.config.time_format),
|
||||
)?,
|
||||
)
|
||||
};
|
||||
let (poster_path, video_path, nfo_path, danmaku_path, fanart_path, subtitle_path) = if is_single_page {
|
||||
(
|
||||
base_path.join(format!("{}-poster.jpg", &base_name)),
|
||||
@@ -413,46 +460,44 @@ pub async fn download_page(
|
||||
dimension,
|
||||
..Default::default()
|
||||
};
|
||||
let tasks: Vec<Pin<Box<dyn Future<Output = Result<ExecutionStatus>> + Send>>> = vec![
|
||||
Box::pin(fetch_page_poster(
|
||||
separate_status[0],
|
||||
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
|
||||
// 下载分页封面
|
||||
fetch_page_poster(
|
||||
separate_status[0] && !cx.config.skip_option.no_poster,
|
||||
video_model,
|
||||
&page_model,
|
||||
downloader,
|
||||
poster_path,
|
||||
fanart_path,
|
||||
)),
|
||||
Box::pin(fetch_page_video(
|
||||
separate_status[1],
|
||||
bili_client,
|
||||
video_model,
|
||||
downloader,
|
||||
&page_info,
|
||||
&video_path,
|
||||
)),
|
||||
Box::pin(generate_page_nfo(
|
||||
separate_status[2],
|
||||
cx
|
||||
),
|
||||
// 下载分页视频
|
||||
fetch_page_video(separate_status[1], video_model, &page_info, &video_path, cx),
|
||||
// 生成分页视频信息的 nfo
|
||||
generate_page_nfo(
|
||||
separate_status[2] && !cx.config.skip_option.no_video_nfo,
|
||||
video_model,
|
||||
&page_model,
|
||||
nfo_path,
|
||||
)),
|
||||
Box::pin(fetch_page_danmaku(
|
||||
separate_status[3],
|
||||
bili_client,
|
||||
cx,
|
||||
),
|
||||
// 下载分页弹幕
|
||||
fetch_page_danmaku(
|
||||
separate_status[3] && !cx.config.skip_option.no_danmaku,
|
||||
video_model,
|
||||
&page_info,
|
||||
danmaku_path,
|
||||
)),
|
||||
Box::pin(fetch_page_subtitle(
|
||||
separate_status[4],
|
||||
bili_client,
|
||||
cx,
|
||||
),
|
||||
// 下载分页字幕
|
||||
fetch_page_subtitle(
|
||||
separate_status[4] && !cx.config.skip_option.no_subtitle,
|
||||
video_model,
|
||||
&page_info,
|
||||
&subtitle_path,
|
||||
)),
|
||||
];
|
||||
let tasks: FuturesOrdered<_> = tasks.into_iter().collect();
|
||||
let results: Vec<ExecutionStatus> = tasks.collect::<Vec<_>>().await.into_iter().map(Into::into).collect();
|
||||
cx
|
||||
)
|
||||
);
|
||||
let results = [res_1.into(), res_2.into(), res_3.into(), res_4.into(), res_5.into()];
|
||||
status.update_status(&results);
|
||||
results
|
||||
.iter()
|
||||
@@ -468,19 +513,22 @@ pub async fn download_page(
|
||||
),
|
||||
ExecutionStatus::Ignored(e) => {
|
||||
error!(
|
||||
"处理视频「{}」第 {} 页{}出现常见错误,已忽略: {:#}",
|
||||
"处理视频「{}」第 {} 页{}出现常见错误,已忽略:{:#}",
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => error!(
|
||||
"处理视频「{}」第 {} 页{}失败: {:#}",
|
||||
ExecutionStatus::Failed(e) => error!(
|
||||
"处理视频「{}」第 {} 页{}失败:{:#}",
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
),
|
||||
ExecutionStatus::Fixed(_) => unreachable!(),
|
||||
});
|
||||
// 如果下载视频时触发风控,直接返回 DownloadAbortError
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")? {
|
||||
if let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>() {
|
||||
bail!(DownloadAbortError());
|
||||
for result in results {
|
||||
if let ExecutionStatus::Failed(e) = result
|
||||
&& let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
bail!(e);
|
||||
}
|
||||
}
|
||||
let mut page_active_model: page::ActiveModel = page_model.into();
|
||||
@@ -493,9 +541,9 @@ pub async fn fetch_page_poster(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
page_model: &page::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: Option<PathBuf>,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
@@ -511,7 +559,9 @@ pub async fn fetch_page_poster(
|
||||
None => video_model.cover.as_str(),
|
||||
}
|
||||
};
|
||||
downloader.fetch(url, &poster_path).await?;
|
||||
cx.downloader
|
||||
.fetch(url, &poster_path, &cx.config.concurrent_limit.download)
|
||||
.await?;
|
||||
if let Some(fanart_path) = fanart_path {
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
}
|
||||
@@ -520,47 +570,53 @@ pub async fn fetch_page_poster(
|
||||
|
||||
pub async fn fetch_page_video(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
page_info: &PageInfo,
|
||||
page_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let streams = bili_video
|
||||
.get_page_analyzer(page_info)
|
||||
.await?
|
||||
.best_stream(&CONFIG.filter_option)?;
|
||||
.best_stream(&cx.config.filter_option)?;
|
||||
match streams {
|
||||
BestStream::Mixed(mix_stream) => downloader.fetch_with_fallback(&mix_stream.urls(), page_path).await?,
|
||||
BestStream::Mixed(mix_stream) => {
|
||||
cx.downloader
|
||||
.multi_fetch(
|
||||
&mix_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: None,
|
||||
} => downloader.fetch_with_fallback(&video_stream.urls(), page_path).await?,
|
||||
} => {
|
||||
cx.downloader
|
||||
.multi_fetch(
|
||||
&video_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: Some(audio_stream),
|
||||
} => {
|
||||
let (tmp_video_path, tmp_audio_path) = (
|
||||
page_path.with_extension("tmp_video"),
|
||||
page_path.with_extension("tmp_audio"),
|
||||
);
|
||||
let res = async {
|
||||
downloader
|
||||
.fetch_with_fallback(&video_stream.urls(), &tmp_video_path)
|
||||
.await?;
|
||||
downloader
|
||||
.fetch_with_fallback(&audio_stream.urls(), &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?
|
||||
cx.downloader
|
||||
.multi_fetch_and_merge(
|
||||
&video_stream.urls(cx.config.cdn_sorting),
|
||||
&audio_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
@@ -568,34 +624,34 @@ pub async fn fetch_page_video(
|
||||
|
||||
pub async fn fetch_page_danmaku(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_info: &PageInfo,
|
||||
danmaku_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
bili_video
|
||||
.get_danmaku_writer(page_info)
|
||||
.await?
|
||||
.write(danmaku_path)
|
||||
.write(danmaku_path, &cx.config.danmaku_option)
|
||||
.await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
pub async fn fetch_page_subtitle(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_info: &PageInfo,
|
||||
subtitle_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let subtitles = bili_video.get_subtitles(page_info).await?;
|
||||
let tasks = subtitles
|
||||
.into_iter()
|
||||
@@ -613,31 +669,34 @@ pub async fn generate_page_nfo(
|
||||
video_model: &video::Model,
|
||||
page_model: &page::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let single_page = video_model.single_page.context("single_page is null")?;
|
||||
let nfo_serializer = if single_page {
|
||||
NFOSerializer(ModelWrapper::Video(video_model), NFOMode::MOVIE)
|
||||
let nfo = if single_page {
|
||||
NFO::Movie(video_model.to_nfo(cx.config.nfo_time_type))
|
||||
} else {
|
||||
NFOSerializer(ModelWrapper::Page(page_model), NFOMode::EPOSODE)
|
||||
NFO::Episode(page_model.to_nfo(cx.config.nfo_time_type))
|
||||
};
|
||||
generate_nfo(nfo_serializer, nfo_path).await?;
|
||||
generate_nfo(nfo, nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
pub async fn fetch_video_poster(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
downloader.fetch(&video_model.cover, &poster_path).await?;
|
||||
cx.downloader
|
||||
.fetch(&video_model.cover, &poster_path, &cx.config.concurrent_limit.download)
|
||||
.await?;
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
@@ -645,13 +704,19 @@ pub async fn fetch_video_poster(
|
||||
pub async fn fetch_upper_face(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
upper_face_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
downloader.fetch(&video_model.upper_face, &upper_face_path).await?;
|
||||
cx.downloader
|
||||
.fetch(
|
||||
&video_model.upper_face,
|
||||
&upper_face_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
@@ -659,12 +724,12 @@ pub async fn generate_upper_nfo(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let nfo_serializer = NFOSerializer(ModelWrapper::Video(video_model), NFOMode::UPPER);
|
||||
generate_nfo(nfo_serializer, nfo_path).await?;
|
||||
generate_nfo(NFO::Upper(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
@@ -672,97 +737,20 @@ pub async fn generate_video_nfo(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let nfo_serializer = NFOSerializer(ModelWrapper::Video(video_model), NFOMode::TVSHOW);
|
||||
generate_nfo(nfo_serializer, nfo_path).await?;
|
||||
generate_nfo(NFO::TVShow(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
/// 创建 nfo_path 的父目录,然后写入 nfo 文件
|
||||
async fn generate_nfo(serializer: NFOSerializer<'_>, nfo_path: PathBuf) -> Result<()> {
|
||||
async fn generate_nfo(nfo: NFO<'_>, nfo_path: PathBuf) -> Result<()> {
|
||||
if let Some(parent) = nfo_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(
|
||||
nfo_path,
|
||||
serializer.generate_nfo(&CONFIG.nfo_time_type).await?.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
fs::write(nfo_path, nfo.generate_nfo().await?.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use handlebars::handlebars_helper;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_template_usage() {
|
||||
let mut template = 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()
|
||||
}
|
||||
});
|
||||
template.register_helper("truncate", Box::new(truncate));
|
||||
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
|
||||
.path_safe_render("video", &json!({"bvid": "BV1b5411h7g7"}))
|
||||
.unwrap(),
|
||||
"testBV1b5411h7g7test"
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.path_safe_render(
|
||||
"test_truncate",
|
||||
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
|
||||
编译将发生在一个被称作「Cargo」的构建系统中。在这里,被引用的指针将被授予「生命周期」之力,导引对象安全。\
|
||||
你将扮演一位名为「Rustacean」的神秘角色, 在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
|
||||
征服她们、通过编译同时,逐步发掘「C++」程序崩溃的真相。"})
|
||||
)
|
||||
.unwrap(),
|
||||
"哈哈,你说得对,但是 Rust 是由 Mozilla 自主研发的一"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,8 @@ edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
derivative = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod rule;
|
||||
pub mod string_vec;
|
||||
123
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
123
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use derivative::Derivative;
|
||||
use sea_orm::FromJsonQueryResult;
|
||||
use sea_orm::prelude::DateTime;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Derivative)]
|
||||
#[derivative(PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
|
||||
pub enum Condition<T: Serialize + Display> {
|
||||
Equals(T),
|
||||
Contains(T),
|
||||
#[serde(rename = "icontains")]
|
||||
IContains(T),
|
||||
#[serde(deserialize_with = "deserialize_regex", serialize_with = "serialize_regex")]
|
||||
MatchesRegex(String, #[derivative(PartialEq = "ignore")] regex::Regex),
|
||||
Prefix(T),
|
||||
Suffix(T),
|
||||
GreaterThan(T),
|
||||
LessThan(T),
|
||||
Between(T, T),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "rule")]
|
||||
pub enum RuleTarget {
|
||||
Title(Condition<String>),
|
||||
Tags(Condition<String>),
|
||||
FavTime(Condition<DateTime>),
|
||||
PubTime(Condition<DateTime>),
|
||||
PageCount(Condition<usize>),
|
||||
Not(Box<RuleTarget>),
|
||||
}
|
||||
|
||||
pub type AndGroup = Vec<RuleTarget>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
pub struct Rule(pub Vec<AndGroup>);
|
||||
|
||||
impl<T: Serialize + Display> Display for Condition<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Condition::Equals(v) => write!(f, "等于“{}”", v),
|
||||
Condition::Contains(v) => write!(f, "包含“{}”", v),
|
||||
Condition::IContains(v) => write!(f, "包含(不区分大小写)“{}”", v),
|
||||
Condition::MatchesRegex(pat, _) => write!(f, "匹配“{}”", pat),
|
||||
Condition::Prefix(v) => write!(f, "以“{}”开头", v),
|
||||
Condition::Suffix(v) => write!(f, "以“{}”结尾", v),
|
||||
Condition::GreaterThan(v) => write!(f, "大于“{}”", v),
|
||||
Condition::LessThan(v) => write!(f, "小于“{}”", v),
|
||||
Condition::Between(start, end) => write!(f, "在“{}”和“{}”之间", start, end),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RuleTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
fn get_field_name(rt: &RuleTarget, depth: usize) -> &'static str {
|
||||
match rt {
|
||||
RuleTarget::Title(_) => "标题",
|
||||
RuleTarget::Tags(_) => "标签",
|
||||
RuleTarget::FavTime(_) => "收藏时间",
|
||||
RuleTarget::PubTime(_) => "发布时间",
|
||||
RuleTarget::PageCount(_) => "视频分页数量",
|
||||
RuleTarget::Not(inner) => {
|
||||
if depth == 0 {
|
||||
get_field_name(inner, depth + 1)
|
||||
} else {
|
||||
"格式化失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let field_name = get_field_name(self, 0);
|
||||
match self {
|
||||
RuleTarget::Not(inner) => match inner.as_ref() {
|
||||
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||
write!(f, "{}不{}", field_name, cond)
|
||||
}
|
||||
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::Not(_) => write!(f, "格式化失败"),
|
||||
},
|
||||
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
|
||||
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||
write!(f, "{}{}", field_name, cond)
|
||||
}
|
||||
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Rule {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let groups: Vec<String> = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|group| {
|
||||
let conditions: Vec<String> = group.iter().map(|target| format!("({})", target)).collect();
|
||||
format!("「{}」", conditions.join("且"))
|
||||
})
|
||||
.collect();
|
||||
write!(f, "{}", groups.join("或"))
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_regex<'de, D>(deserializer: D) -> Result<(String, regex::Regex), D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let pattern = String::deserialize(deserializer)?;
|
||||
// 反序列化时预编译 regex,优化性能
|
||||
let regex = regex::Regex::new(&pattern).map_err(serde::de::Error::custom)?;
|
||||
Ok((pattern, regex))
|
||||
}
|
||||
|
||||
fn serialize_regex<S>(pattern: &str, _regex: ®ex::Regex, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(pattern)
|
||||
}
|
||||
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use sea_orm::FromJsonQueryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// reference: https://www.sea-ql.org/SeaORM/docs/generate-entity/column-types/#json-column
|
||||
// 在 entity 中使用裸 Vec 仅在 postgres 中支持,sea-orm 会将其映射为 postgres array
|
||||
// 如果需要实现跨数据库的 array,必须将其包裹在 wrapper type 中
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
pub struct StringVec(pub Vec<String>);
|
||||
|
||||
impl From<Vec<String>> for StringVec {
|
||||
fn from(value: Vec<String>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StringVec> for Vec<String> {
|
||||
fn from(value: StringVec) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "collection")]
|
||||
pub struct Model {
|
||||
@@ -14,6 +16,8 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
17
crates/bili_sync_entity/src/entities/config.rs
Normal file
17
crates/bili_sync_entity/src/entities/config.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 = "config")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub data: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "favorite")]
|
||||
pub struct Model {
|
||||
@@ -13,6 +15,8 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod prelude;
|
||||
|
||||
pub mod collection;
|
||||
pub mod config;
|
||||
pub mod favorite;
|
||||
pub mod page;
|
||||
pub mod submission;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "submission")]
|
||||
pub struct Model {
|
||||
@@ -11,7 +13,10 @@ pub struct Model {
|
||||
pub upper_name: String,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub use_dynamic_api: bool,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::string_vec::StringVec;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
||||
#[sea_orm(table_name = "video")]
|
||||
pub struct Model {
|
||||
@@ -25,7 +27,8 @@ pub struct Model {
|
||||
pub favtime: DateTime,
|
||||
pub download_status: u32,
|
||||
pub valid: bool,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub should_download: bool,
|
||||
pub tags: Option<StringVec>,
|
||||
pub single_page: Option<bool>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "watch_later")]
|
||||
pub struct Model {
|
||||
@@ -10,6 +12,8 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
mod custom_type;
|
||||
mod entities;
|
||||
|
||||
pub use custom_type::*;
|
||||
pub use entities::*;
|
||||
|
||||
@@ -5,5 +5,4 @@ edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
async-std = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
|
||||
@@ -5,6 +5,11 @@ mod m20240505_130850_add_collection;
|
||||
mod m20240709_130914_watch_later;
|
||||
mod m20240724_161008_submission;
|
||||
mod m20250122_062926_add_latest_row_at;
|
||||
mod m20250612_090826_add_enabled;
|
||||
mod m20250613_043257_add_config;
|
||||
mod m20250712_080013_add_video_created_at_index;
|
||||
mod m20250903_094454_add_rule_and_should_download;
|
||||
mod m20251009_123713_add_use_dynamic_api;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -17,6 +22,11 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20240709_130914_watch_later::Migration),
|
||||
Box::new(m20240724_161008_submission::Migration),
|
||||
Box::new(m20250122_062926_add_latest_row_at::Migration),
|
||||
Box::new(m20250612_090826_add_enabled::Migration),
|
||||
Box::new(m20250613_043257_add_config::Migration),
|
||||
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
||||
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
|
||||
Box::new(m20251009_123713_add_use_dynamic_api::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
101
crates/bili_sync_migration/src/m20250612_090826_add_enabled.rs
Normal file
101
crates/bili_sync_migration/src/m20250612_090826_add_enabled.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
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> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.add_column(ColumnDef::new(WatchLater::Enabled).boolean().not_null().default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.add_column(ColumnDef::new(Submission::Enabled).boolean().not_null().default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.add_column(ColumnDef::new(Favorite::Enabled).boolean().not_null().default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.add_column(ColumnDef::new(Collection::Enabled).boolean().not_null().default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.drop_column(WatchLater::Enabled)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.drop_column(Submission::Enabled)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.drop_column(Favorite::Enabled)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.drop_column(Collection::Enabled)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum WatchLater {
|
||||
Table,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Favorite {
|
||||
Table,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Collection {
|
||||
Table,
|
||||
Enabled,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Config::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Config::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Config::Data).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Config::CreatedAt)
|
||||
.timestamp()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager.drop_table(Table::drop().table(Config::Table).to_owned()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Config {
|
||||
Table,
|
||||
Id,
|
||||
Data,
|
||||
CreatedAt,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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> {
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.table(Video::Table)
|
||||
.name("video_created_at_index")
|
||||
.col(Video::CreatedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("video_created_at_index")
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
CreatedAt,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
use sea_orm_migration::schema::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(boolean(Video::ShouldDownload).default(true))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.add_column(text_null(WatchLater::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.add_column(text_null(Submission::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.add_column(text_null(Favorite::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.add_column(text_null(Collection::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::ShouldDownload)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.drop_column(WatchLater::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.drop_column(Submission::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.drop_column(Favorite::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.drop_column(Collection::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
ShouldDownload,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum WatchLater {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Favorite {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Collection {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
use sea_orm_migration::schema::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.add_column(boolean(Submission::UseDynamicApi).default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.drop_column(Submission::UseDynamicApi)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
UseDynamicApi,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(bili_sync_migration::Migrator).await;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.5.0",
|
||||
text: "v2.10.2",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
@@ -45,7 +45,7 @@ export default defineConfig({
|
||||
{
|
||||
text: "细节",
|
||||
items: [
|
||||
{ text: "配置文件", link: "/configuration" },
|
||||
{ text: "配置说明", link: "/configuration" },
|
||||
{ text: "命令行参数", link: "/args" },
|
||||
{ text: "工作原理", link: "/design" },
|
||||
],
|
||||
@@ -55,10 +55,10 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "获取收藏夹信息", link: "/favorite" },
|
||||
{
|
||||
text: "获取视频合集/视频列表信息",
|
||||
text: "获取合集/列表信息",
|
||||
link: "/collection",
|
||||
},
|
||||
{ text: "获取投稿信息", link: "/submission" },
|
||||
{ text: "获取用户投稿信息", link: "/submission" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
BIN
docs/assets/config.webp
Normal file
BIN
docs/assets/config.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user