Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b6fd72682 | ||
|
|
e65cd36b2e | ||
|
|
352282f277 | ||
|
|
fa2bc7b5e8 | ||
|
|
bb90f0c6f2 | ||
|
|
90f2a1d4ed | ||
|
|
e2b65746dd | ||
|
|
24d0da0bf3 | ||
|
|
ff1150e863 | ||
|
|
940abd4f3b | ||
|
|
4c9ad2318c | ||
|
|
097f885050 | ||
|
|
6ebef0a414 | ||
|
|
4818e62414 | ||
|
|
1744f8647b | ||
|
|
c4db12b154 | ||
|
|
2ef99a20c9 | ||
|
|
67de151234 | ||
|
|
73f97f937f | ||
|
|
8fee6fb97a | ||
|
|
e5e5b07978 | ||
|
|
cd2bd9cbb3 | ||
|
|
f044b18337 | ||
|
|
d3bfca42f6 | ||
|
|
10ccb47790 | ||
|
|
e732e7d616 | ||
|
|
f81d9fc6eb |
6
.github/workflows/check.yaml
vendored
@@ -5,8 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
types: ['opened', 'reopened', 'synchronize', 'ready_for_review']
|
||||
|
||||
concurrency:
|
||||
# Allow only one workflow per any non-`main` branch.
|
||||
@@ -22,11 +21,12 @@ jobs:
|
||||
tests:
|
||||
name: Run Clippy and tests
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- run: rustup toolchain install nightly && rustup default nightly && rustup component add rustfmt clippy
|
||||
- run: rustup default nightly-2024-04-30 && rustup component add rustfmt clippy
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
40
.github/workflows/doc.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Build Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
doc:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
name: Build documentation
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
- name: Build documentation
|
||||
run: bun run docs:build
|
||||
- name: Deploy Github Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs/.vitepress/dist
|
||||
force_orphan: true
|
||||
commit_message: 部署来自 main 的最新文档变更:
|
||||
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
**/target
|
||||
auth_data
|
||||
*.sqlite
|
||||
*.json
|
||||
video
|
||||
debug*
|
||||
node_modules
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
429
Cargo.lock
generated
@@ -127,9 +127,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.81"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"
|
||||
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
]
|
||||
@@ -185,9 +185,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.7"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a9249d1447a85f95810c620abea82e001fe58a31713fcce614caf52499f905"
|
||||
checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-core",
|
||||
@@ -342,9 +342,9 @@ checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.79"
|
||||
version = "0.1.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681"
|
||||
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -395,9 +395,9 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
@@ -417,25 +417,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bili-sync-rs"
|
||||
version = "2.0.0"
|
||||
name = "bili_sync"
|
||||
version = "2.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"bili_sync_entity",
|
||||
"bili_sync_migration",
|
||||
"chrono",
|
||||
"cookie 0.18.1",
|
||||
"clap",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"entity",
|
||||
"env_logger",
|
||||
"filenamify",
|
||||
"float-ord",
|
||||
"futures",
|
||||
"handlebars",
|
||||
"hex",
|
||||
"log",
|
||||
"memchr",
|
||||
"migration",
|
||||
"once_cell",
|
||||
"prost",
|
||||
"quick-xml",
|
||||
@@ -446,10 +446,28 @@ dependencies = [
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.26.2",
|
||||
"strum 0.26.3",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_entity"
|
||||
version = "2.1.0"
|
||||
dependencies = [
|
||||
"sea-orm",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_migration"
|
||||
version = "2.1.0"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"sea-orm-migration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -588,9 +606,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.37"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
|
||||
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
@@ -603,9 +621,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.4"
|
||||
version = "4.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
|
||||
checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -613,9 +631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.2"
|
||||
version = "4.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
|
||||
checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -625,9 +643,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.4"
|
||||
version = "4.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
|
||||
checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -664,33 +682,23 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.17.0"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6"
|
||||
checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa"
|
||||
dependencies = [
|
||||
"cookie 0.17.0",
|
||||
"idna 0.3.0",
|
||||
"cookie",
|
||||
"idna 0.5.0",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
@@ -732,9 +740,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.0"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
|
||||
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -853,38 +861,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "entity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"humantime",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
@@ -992,9 +968,9 @@ checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.28"
|
||||
version = "1.0.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
|
||||
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
@@ -1213,15 +1189,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
|
||||
checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
@@ -1366,17 +1342,11 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.2.0"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
|
||||
checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -1394,26 +1364,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.26.0"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c"
|
||||
checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls 0.22.3",
|
||||
"rustls 0.23.10",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 0.26.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
|
||||
checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -1422,7 +1393,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.6",
|
||||
"socket2 0.5.7",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-service",
|
||||
@@ -1618,9 +1589,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.21"
|
||||
version = "0.4.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
||||
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
@@ -1646,17 +1617,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.2"
|
||||
version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
|
||||
|
||||
[[package]]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"sea-orm-migration",
|
||||
]
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@@ -1700,6 +1663,16 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.4"
|
||||
@@ -1828,6 +1801,12 @@ dependencies = [
|
||||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.0"
|
||||
@@ -1880,9 +1859,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.7.9"
|
||||
version = "2.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "311fb059dee1a7b802f036316d790138c613a4e8b180c822e3925a662e9f0c95"
|
||||
checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror",
|
||||
@@ -1891,9 +1870,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.7.9"
|
||||
version = "2.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f73541b156d32197eecda1a4014d7f868fd2bcb3c550d5386087cfba442bf69c"
|
||||
checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1901,9 +1880,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.7.9"
|
||||
version = "2.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c35eeed0a3fab112f75165fdc026b3913f4183133f19b49be773ac9ea966e8bd"
|
||||
checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1914,9 +1893,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.7.9"
|
||||
version = "2.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2adbf29bb9776f28caece835398781ab24435585fe0d4dc1374a61db5accedca"
|
||||
checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"pest",
|
||||
@@ -2080,9 +2059,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.12.4"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f5d036824e4761737860779c906171497f6d55681139d8312388f8fe398922"
|
||||
checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
@@ -2090,9 +2069,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.12.4"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48"
|
||||
checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
@@ -2139,14 +2118,61 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.31.0"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||
checksum = "86e446ed58cef1bbfe847bc2fda0e2e4ea9f0e57b90c507d4781292590d72a4e"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddf517c03a109db8100448a4be38d498df8a210a99fe0e1b9eaf39e78c640efe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.10",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.7",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.35"
|
||||
@@ -2214,9 +2240,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.4"
|
||||
version = "1.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
|
||||
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -2267,14 +2293,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10"
|
||||
checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.22.0",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie 0.17.0",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
@@ -2293,7 +2319,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.22.3",
|
||||
"quinn",
|
||||
"rustls 0.23.10",
|
||||
"rustls-pemfile 2.1.2",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
@@ -2309,7 +2336,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 0.26.1",
|
||||
"webpki-roots 0.26.2",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
@@ -2400,6 +2427,12 @@ version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.37.27"
|
||||
@@ -2429,9 +2462,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.10"
|
||||
version = "0.21.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
@@ -2440,14 +2473,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.22.3"
|
||||
version = "0.23.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99008d7ad0bbbea527ec27bddbc0e432c5b87d8175178cee68d2eec9c4a1813c"
|
||||
checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.2",
|
||||
"rustls-webpki 0.102.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2467,15 +2500,15 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d"
|
||||
dependencies = [
|
||||
"base64 0.22.0",
|
||||
"base64 0.22.1",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.4.1"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247"
|
||||
checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
@@ -2489,9 +2522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.2"
|
||||
version = "0.102.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faaa0a62740bedb9b2ef5afa303da42764c012f743917351dc9a237ea1663610"
|
||||
checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -2500,9 +2533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.14"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
|
||||
checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -2693,18 +2726,18 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.197"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
|
||||
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.197"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
|
||||
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2713,9 +2746,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.115"
|
||||
version = "1.0.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd"
|
||||
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
@@ -2724,9 +2757,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
|
||||
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -2776,9 +2809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
|
||||
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -2826,9 +2859,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.6"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871"
|
||||
checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -2913,7 +2946,7 @@ dependencies = [
|
||||
"paste",
|
||||
"percent-encoding",
|
||||
"rust_decimal",
|
||||
"rustls 0.21.10",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3117,20 +3150,20 @@ checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.2"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.2"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
@@ -3179,9 +3212,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
@@ -3203,18 +3236,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.58"
|
||||
version = "1.0.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
|
||||
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.58"
|
||||
version = "1.0.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
|
||||
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3279,9 +3312,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@@ -3291,16 +3324,16 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.5.6",
|
||||
"socket2 0.5.7",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3309,11 +3342,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
|
||||
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
|
||||
dependencies = [
|
||||
"rustls 0.22.3",
|
||||
"rustls 0.23.10",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3331,35 +3364,34 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
|
||||
checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.12"
|
||||
version = "0.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3"
|
||||
checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit 0.22.9",
|
||||
"toml_edit 0.22.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
|
||||
checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -3377,15 +3409,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.9"
|
||||
version = "0.22.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4"
|
||||
checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow 0.6.5",
|
||||
"winnow 0.6.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3401,7 +3433,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3446,6 +3477,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3454,13 +3497,17 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3552,6 +3599,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.8.1"
|
||||
@@ -3694,9 +3747,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.1"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009"
|
||||
checksum = "3c452ad30530b54a4d8e71952716a212b08efd0f3562baa66c29a618b07da7c3"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -3885,9 +3938,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.5"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8"
|
||||
checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
85
Cargo.toml
@@ -1,54 +1,75 @@
|
||||
[package]
|
||||
name = "bili-sync-rs"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.81", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7", features = ["serde"] }
|
||||
[workspace.package]
|
||||
version = "2.1.0"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
bili_sync_entity = { path = "crates/bili_sync_entity" }
|
||||
bili_sync_migration = { path = "crates/bili_sync_migration" }
|
||||
|
||||
anyhow = { version = "1.0.86", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7.1", features = ["serde"] }
|
||||
async-std = { version = "1.12.0", features = ["attributes", "tokio1"] }
|
||||
async-stream = "0.3.5"
|
||||
chrono = { version = "0.4.35", features = ["serde"] }
|
||||
cookie = "0.18.0"
|
||||
async-trait = "0.1.80"
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
clap = { version = "4.5.8", features = ["env"] }
|
||||
cookie = "0.18.1"
|
||||
dirs = "5.0.1"
|
||||
entity = { path = "entity" }
|
||||
env_logger = "0.11.3"
|
||||
filenamify = "0.1.0"
|
||||
float-ord = "0.3.2"
|
||||
futures = "0.3.30"
|
||||
handlebars = "5.1.2"
|
||||
hex = "0.4.3"
|
||||
log = "0.4.21"
|
||||
memchr = "2.5.0"
|
||||
migration = { path = "migration" }
|
||||
memchr = "2.7.4"
|
||||
once_cell = "1.19.0"
|
||||
prost = "0.12.4"
|
||||
quick-xml = { version = "0.31.0", features = ["async-tokio"] }
|
||||
prost = "0.12.6"
|
||||
quick-xml = { version = "0.35.0", features = ["async-tokio"] }
|
||||
rand = "0.8.5"
|
||||
regex = "1.10.3"
|
||||
reqwest = { version = "0.12.4", features = [
|
||||
"json",
|
||||
"stream",
|
||||
regex = "1.10.5"
|
||||
reqwest = { version = "0.12.5", features = [
|
||||
"charset",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"charset",
|
||||
"http2",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"stream",
|
||||
], default-features = false }
|
||||
rsa = { version = "0.9.6", features = ["sha2"] }
|
||||
sea-orm = { version = "0.12", features = [
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
sea-orm = { version = "0.12.15", features = [
|
||||
"macros",
|
||||
"runtime-tokio-rustls",
|
||||
"sqlx-sqlite",
|
||||
] }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
thiserror = "1.0.58"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.8.12"
|
||||
sea-orm-migration = { version = "0.12.15", features = [] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.120"
|
||||
strum = { version = "0.26.3", features = ["derive"] }
|
||||
thiserror = "1.0.61"
|
||||
tokio = { version = "1.38.0", features = ["full"] }
|
||||
toml = "0.8.14"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["chrono"] }
|
||||
|
||||
[workspace]
|
||||
members = [".", "entity", "migration"]
|
||||
[workspace.metadata.release]
|
||||
release = false
|
||||
tag-message = ""
|
||||
tag-prefix = ""
|
||||
pre-release-commit-message = "chore: 发布 bili-sync {{version}}"
|
||||
publish = false
|
||||
pre-release-replacements = [
|
||||
{ file = "../../docs/.vitepress/config.mts", search = "\"v[0-9\\.]+\"", replace = "\"v{{version}}\"", exactly = 1 },
|
||||
{ file = "../../docs/introduction.md", search = " v[0-9\\.]+,", replace = " v{{version}},", exactly = 1 },
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
11
Dockerfile
@@ -1,18 +1,15 @@
|
||||
FROM alpine as base
|
||||
FROM alpine AS base
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./*-bili-sync-rs ./targets/
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& apk del tzdata
|
||||
ffmpeg
|
||||
|
||||
COPY ./*-bili-sync-rs ./targets/
|
||||
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
mv ./targets/Linux-x86_64-bili-sync-rs ./bili-sync-rs; \
|
||||
|
||||
13
Justfile
@@ -7,4 +7,15 @@ build:
|
||||
build-docker: build
|
||||
cp target/x86_64-unknown-linux-musl/release/bili-sync-rs ./Linux-x86_64-bili-sync-rs
|
||||
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
|
||||
just clean
|
||||
just clean
|
||||
|
||||
copy-config:
|
||||
rm -rf ~/.config/bili-sync
|
||||
cp -r ~/.config/nas/bili-sync-rs ~/.config/bili-sync
|
||||
sed -i -e 's/\/Bilibilis/\/Test_Bilibilis/g' -e 's/.config\/nas/.config\/test_nas/g' ~/.config/bili-sync/config.toml
|
||||
|
||||
run:
|
||||
cargo run
|
||||
|
||||
debug: copy-config
|
||||
just run
|
||||
21
Licence
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ᴀᴍᴛᴏᴀᴇʀ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
167
README.md
@@ -3,18 +3,12 @@
|
||||
## 简介
|
||||
|
||||
> [!NOTE]
|
||||
> 此为 v2.x 版本文档,v1.x 版本文档请前往[此处](https://github.com/amtoaer/bili-sync/tree/v1.x)查看。
|
||||
|
||||
|
||||
为 NAS 用户编写的 BILIBILI 收藏夹同步工具,可使用 EMBY 等媒体库工具浏览。
|
||||
|
||||
支持展示视频封面、名称、加入日期、标签、分页等。
|
||||
> [点击此处](https://bili-sync.allwens.work/)查看文档
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust & Tokio 驱动。
|
||||
|
||||
## 效果演示
|
||||
|
||||
**注:因为可能同时存在单页视频和多页视频,媒体库类型请选择“混合内容”。**
|
||||
|
||||
### 概览
|
||||

|
||||
### 详情
|
||||
@@ -24,152 +18,21 @@
|
||||
### 文件排布
|
||||

|
||||
|
||||
## 配置文件说明
|
||||
|
||||
> [!NOTE]
|
||||
> 在 Docker 环境中,`~` 会被展开为 `/app`。
|
||||
## 功能与路线图
|
||||
|
||||
程序默认会将配置文件存储于 `~/.config/bili-sync/config.toml`,数据库文件存储于 `~/.config/bili-sync/data.sqlite`,如果发现不存在会新建并写入默认配置。
|
||||
- [x] 使用用户填写的凭据认证,并在必要时自动刷新
|
||||
- [x] 支持收藏夹与视频列表/视频合集的下载
|
||||
- [x] 自动选择用户设置范围内最优的视频和音频流,并在下载完成后使用 FFmpeg 合并
|
||||
- [x] 使用 Tokio 与 Reqwest,对视频、视频分页进行异步并发下载
|
||||
- [x] 使用媒体服务器支持的文件命名,方便一键作为媒体库导入
|
||||
- [x] 当前轮次下载失败会在下一轮下载时重试,失败次数过多自动丢弃
|
||||
- [x] 使用数据库保存媒体信息,避免对同个视频的多次请求
|
||||
- [x] 打印日志,并在请求出现风控时自动终止,等待下一轮执行
|
||||
- [x] 提供多平台的二进制可执行文件,为 Linux 平台提供了立即可用的 Docker 镜像
|
||||
- [ ] 支持对“稍后再看”内视频的自动扫描与下载
|
||||
- [ ] 下载单个文件时支持断点续传与并发下载
|
||||
|
||||
配置文件加载时会进行简单校验,默认配置无法通过校验,程序会报错终止运行。
|
||||
|
||||
可以下载程序后直接运行程序,看到报错后参考报错信息对默认配置进行修改,修改正确后即可正常运行。
|
||||
|
||||
对于配置文件中的 `credential`,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)。
|
||||
|
||||
配置文件中的 `video_name` 和 `page_name` 支持使用模板,模板的替换语法请参考示例。模板中的内容在执行时会被动态替换为对应的内容。
|
||||
|
||||
video_name 支持设置 bvid(视频编号)、title(视频标题)、upper_name(up 主名称)、upper_mid(up 主 id)。
|
||||
|
||||
page_name 除支持 video 的全部参数外,还支持 ptitle(分 P 标题)、pid(分 P 页号)。
|
||||
|
||||
对于每个 favorite_list 的下载路径,程序会在其下建立如下的文件夹:
|
||||
|
||||
1. 单页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── {page_name}.mp4
|
||||
│ ├── {page_name}.nfo
|
||||
│ └── {page_name}-poster.jpg
|
||||
```
|
||||
|
||||
2. 多页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── poster.jpg
|
||||
│ ├── Season 1
|
||||
│ │ ├── {page_name} - S01E01.mp4
|
||||
│ │ ├── {page_name} - S01E01.nfo
|
||||
│ │ ├── {page_name} - S01E01-thumb.jpg
|
||||
│ │ ├── {page_name} - S01E02.mp4
|
||||
│ │ ├── {page_name} - S01E02.nfo
|
||||
│ │ └── {page_name} - S01E02-thumb.jpg
|
||||
│ └── tvshow.nfo
|
||||
```
|
||||
|
||||
对于 filter_option 的可选值,请前往 [analyzer.rs](https://github.com/amtoaer/bili-sync/blob/main/src/bilibili/analyzer.rs) 查看。
|
||||
|
||||
对于 danmaku_option 的项含义,请前往 [danmaku/mod.rs](https://github.com/amtoaer/bili-sync/blob/main/src/bilibili/danmaku/canvas/mod.rs) 与 [原项目的说明](https://github.com/gwy15/danmu2ass?tab=readme-ov-file#%E5%91%BD%E4%BB%A4%E8%A1%8C) 查看。
|
||||
|
||||
## 配置文件示例
|
||||
|
||||
```toml
|
||||
# 视频所处文件夹的名称
|
||||
video_name = "{{title}}"
|
||||
# 视频分页文件的命名
|
||||
page_name = "{{bvid}}"
|
||||
# 扫描运行的间隔(单位:秒)
|
||||
interval = 1200
|
||||
# emby 演员信息的保存位置
|
||||
upper_path = "/home/amtoaer/.config/nas/emby/metadata/people/"
|
||||
|
||||
[credential]
|
||||
# Bilibili 的 Web 端身份凭据,需要凭据才能下载高清视频
|
||||
sessdata = ""
|
||||
bili_jct = ""
|
||||
buvid3 = ""
|
||||
dedeuserid = ""
|
||||
ac_time_value = ""
|
||||
|
||||
[filter_option]
|
||||
# 视频、音频流的筛选选项,程序会使用范围内质量最高的流
|
||||
# 注意设置范围过小可能导致无满足条件的流,推荐仅调整质量上限和编码优先级
|
||||
video_max_quality = "Quality8k"
|
||||
video_min_quality = "Quality360p"
|
||||
audio_max_quality = "QualityHiRES"
|
||||
audio_min_quality = "Quality64k"
|
||||
codecs = [
|
||||
"AV1",
|
||||
"HEV",
|
||||
"AVC",
|
||||
]
|
||||
no_dolby_video = false
|
||||
no_dolby_audio = false
|
||||
no_hdr = false
|
||||
no_hires = false
|
||||
|
||||
[danmaku_option]
|
||||
# 弹幕的一些相关选项,如字体、字号、透明度、停留时间、是否加粗等
|
||||
duration = 12.0
|
||||
font = "黑体"
|
||||
font_size = 25
|
||||
width_ratio = 1.2
|
||||
horizontal_gap = 20.0
|
||||
lane_size = 32
|
||||
float_percentage = 0.5
|
||||
bottom_percentage = 0.3
|
||||
opacity = 76
|
||||
bold = true
|
||||
outline = 0.8
|
||||
time_offset = 0.0
|
||||
|
||||
[favorite_list]
|
||||
# 收藏夹 ID = 存储的位置
|
||||
52642258 = "/home/amtoaer/HDDs/Videos/Bilibilis/混剪"
|
||||
```
|
||||
|
||||
## Docker Compose 文件示例
|
||||
|
||||
该项目为 `Linux/amd64` 与 `Linux/arm64` 提供了 Docker 版本镜像。
|
||||
|
||||
Docker 版包含该平台对应版本的可执行文件(位于`/app/bili-sync-rs`),并预装了 FFmpeg,其它用法与普通版本完全一致。(可查看 [用于构建镜像的 Dockerfile](./Dockerfile) )
|
||||
|
||||
以下是一个 Docker Compose 的编写示例:
|
||||
```yaml
|
||||
services:
|
||||
bili-sync-rs:
|
||||
image: amtoaer/bili-sync-rs:v2.0.0
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
tty: true # 该选项请仅在日志终端支持彩色输出时启用,否则日志中可能会出现乱码
|
||||
hostname: bili-sync-rs
|
||||
container_name: bili-sync-rs
|
||||
volumes:
|
||||
- /home/amtoaer/.config/nas/bili-sync-rs:/app/.config/bili-sync
|
||||
# 以及一些其它必要的挂载,确保此处的挂载与 bili-sync-rs 的配置相匹配
|
||||
# ...
|
||||
logging:
|
||||
driver: "local"
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
- [x] 凭证认证
|
||||
- [x] 视频选优
|
||||
- [x] 视频下载
|
||||
- [x] 支持并发下载
|
||||
- [x] 支持作为 daemon 运行
|
||||
- [x] 构建 nfo 和 poster 文件,方便以单集形式导入 emby
|
||||
- [x] 支持收藏夹翻页,下载全部历史视频
|
||||
- [x] 对接数据库,提前检查,按需下载
|
||||
- [x] 支持弹幕下载
|
||||
- [x] 更好的错误处理
|
||||
- [x] 更好的日志
|
||||
- [x] 请求过快出现风控的 workaround
|
||||
- [x] 提供简单易用的打包(如 docker)
|
||||
- [ ] 支持 UP 主合集下载
|
||||
|
||||
## 参考与借鉴
|
||||
|
||||
@@ -177,4 +40,4 @@ services:
|
||||
|
||||
+ [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) B 站的第三方接口文档
|
||||
+ [bilibili-api](https://github.com/Nemo2011/bilibili-api) 使用 Python 调用接口的参考实现
|
||||
+ [danmu2ass](https://github.com/gwy15/danmu2ass) 本项目弹幕下载功能的缝合来源
|
||||
+ [danmu2ass](https://github.com/gwy15/danmu2ass) 本项目弹幕下载功能的缝合来源
|
||||
|
||||
50
crates/bili_sync/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "bili_sync"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
readme = "../../README.md"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bili_sync_entity = { workspace = true }
|
||||
bili_sync_migration = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
cookie = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
filenamify = { workspace = true }
|
||||
float-ord = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
handlebars = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[package.metadata.release]
|
||||
release = true
|
||||
|
||||
[[bin]]
|
||||
name = "bili-sync-rs"
|
||||
path = "src/main.rs"
|
||||
241
crates/bili_sync/src/adapter/collection.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::OnConflict;
|
||||
use filenamify::filenamify;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, QuerySelect, TransactionTrait};
|
||||
|
||||
use super::VideoListModel;
|
||||
use crate::bilibili::{BiliClient, BiliError, Collection, CollectionItem, CollectionType, Video, VideoInfo};
|
||||
use crate::config::TEMPLATE;
|
||||
use crate::utils::id_time_key;
|
||||
use crate::utils::model::create_video_pages;
|
||||
use crate::utils::status::Status;
|
||||
|
||||
pub async fn collection_from<'a>(
|
||||
collection_item: &'a CollectionItem,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let collection = Collection::new(bili_client, collection_item);
|
||||
let collection_info = collection.get_info().await?;
|
||||
collection::Entity::insert(collection::ActiveModel {
|
||||
s_id: Set(collection_info.sid),
|
||||
m_id: Set(collection_info.mid),
|
||||
r#type: Set(collection_info.collection_type.into()),
|
||||
name: Set(collection_info.name.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
collection::Column::SId,
|
||||
collection::Column::MId,
|
||||
collection::Column::Type,
|
||||
])
|
||||
.update_columns([collection::Column::Name, collection::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
collection::Entity::find()
|
||||
.filter(
|
||||
collection::Column::SId
|
||||
.eq(collection_item.sid.clone())
|
||||
.and(collection::Column::MId.eq(collection_item.mid.clone()))
|
||||
.and(collection::Column::Type.eq(Into::<i32>::into(collection_item.collection_type.clone()))),
|
||||
)
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(collection.into_simple_video_stream()),
|
||||
))
|
||||
}
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for collection::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(video::Column::CollectionId.eq(self.id))
|
||||
.count(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::CollectionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null()),
|
||||
)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::CollectionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null()),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
let bvids = videos_info.iter().map(|v| v.bvid().to_string()).collect::<Vec<_>>();
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::CollectionId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Bvid.is_in(bvids)),
|
||||
)
|
||||
.select_only()
|
||||
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||
.into_tuple()
|
||||
.all(connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(bvid, time)| id_time_key(&bvid, &time))
|
||||
.collect::<HashSet<_>>())
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.collection_id = Set(Some(self.id));
|
||||
if let Some(fmt_args) = &video_info.to_fmt_args() {
|
||||
video_model.path = Set(Path::new(&self.path)
|
||||
.join(filenamify(
|
||||
TEMPLATE
|
||||
.render("video", fmt_args)
|
||||
.unwrap_or_else(|_| video_info.bvid().to_string()),
|
||||
))
|
||||
.to_string_lossy()
|
||||
.to_string());
|
||||
}
|
||||
video_model
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
bili_clent: &BiliClient,
|
||||
videos_model: Vec<video::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
for video_model in videos_model {
|
||||
let video = Video::new(bili_clent, video_model.bvid.clone());
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, view_info)) => {
|
||||
let VideoInfo::View { pages, .. } = &view_info else {
|
||||
unreachable!("view_info must be VideoInfo::View")
|
||||
};
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_video_pages(pages, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
|
||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!(
|
||||
"开始获取{} {} - {} 的视频与分页信息...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!(
|
||||
"获取{} {} - {} 的视频与分页信息完成",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!(
|
||||
"开始下载{}: {} - {} 中所有未处理过的视频...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!(
|
||||
"下载{}: {} - {} 中未处理过的视频完成",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!(
|
||||
"开始扫描{}: {} - {} 的新视频...",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描{}: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
CollectionType::from(self.r#type),
|
||||
self.s_id,
|
||||
self.name,
|
||||
got_count,
|
||||
new_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
199
crates/bili_sync/src/adapter/favorite.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::OnConflict;
|
||||
use filenamify::filenamify;
|
||||
use futures::Stream;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, QuerySelect, TransactionTrait};
|
||||
|
||||
use super::VideoListModel;
|
||||
use crate::bilibili::{BiliClient, BiliError, FavoriteList, Video, VideoInfo};
|
||||
use crate::config::TEMPLATE;
|
||||
use crate::utils::id_time_key;
|
||||
use crate::utils::model::create_video_pages;
|
||||
use crate::utils::status::Status;
|
||||
|
||||
pub async fn favorite_from<'a>(
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
let favorite = FavoriteList::new(bili_client, fid.to_owned());
|
||||
let favorite_info = favorite.get_info().await?;
|
||||
favorite::Entity::insert(favorite::ActiveModel {
|
||||
f_id: Set(favorite_info.id),
|
||||
name: Set(favorite_info.title.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(favorite::Column::FId)
|
||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
Box::new(
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::FId.eq(favorite_info.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap(),
|
||||
),
|
||||
Box::pin(favorite.into_video_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
impl VideoListModel for favorite::Model {
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(video::Column::FavoriteId.eq(self.id))
|
||||
.count(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null()),
|
||||
)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null()),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn exist_labels(
|
||||
&self,
|
||||
videos_info: &[VideoInfo],
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<String>> {
|
||||
let bvids = videos_info.iter().map(|v| v.bvid().to_string()).collect::<Vec<_>>();
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(self.id)
|
||||
.and(video::Column::Bvid.is_in(bvids)),
|
||||
)
|
||||
.select_only()
|
||||
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||
.into_tuple()
|
||||
.all(connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(bvid, time)| id_time_key(&bvid, &time))
|
||||
.collect::<HashSet<_>>())
|
||||
}
|
||||
|
||||
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||
let mut video_model = video_info.to_model(base_model);
|
||||
video_model.favorite_id = Set(Some(self.id));
|
||||
if let Some(fmt_args) = &video_info.to_fmt_args() {
|
||||
video_model.path = Set(Path::new(&self.path)
|
||||
.join(filenamify(
|
||||
TEMPLATE
|
||||
.render("video", fmt_args)
|
||||
.unwrap_or_else(|_| video_info.bvid().to_string()),
|
||||
))
|
||||
.to_string_lossy()
|
||||
.to_string());
|
||||
}
|
||||
video_model
|
||||
}
|
||||
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
bili_clent: &BiliClient,
|
||||
videos_model: Vec<video::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
for video_model in videos_model {
|
||||
let video = Video::new(bili_clent, video_model.bvid.clone());
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
|
||||
match info {
|
||||
Ok((tags, pages_info)) => {
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_fetch_video_start(&self) {
|
||||
info!("开始获取收藏夹 {} - {} 的视频与分页信息...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_fetch_video_end(&self) {
|
||||
info!("获取收藏夹 {} - {} 的视频与分页信息完成", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_start(&self) {
|
||||
info!("开始下载收藏夹: {} - {} 中所有未处理过的视频...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_download_video_end(&self) {
|
||||
info!("下载收藏夹: {} - {} 中未处理过的视频完成", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描收藏夹: {} - {} 的新视频...", self.f_id, self.name);
|
||||
}
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||
info!(
|
||||
"扫描收藏夹: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||
self.f_id, self.name, got_count, new_count
|
||||
);
|
||||
}
|
||||
}
|
||||
81
crates/bili_sync/src/adapter/mod.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
mod collection;
|
||||
mod favorite;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
pub use collection::collection_from;
|
||||
pub use favorite::favorite_from;
|
||||
use futures::Stream;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::bilibili::{BiliClient, CollectionItem, VideoInfo};
|
||||
|
||||
pub enum Args<'a> {
|
||||
Favorite { fid: &'a str },
|
||||
Collection { collection_item: &'a CollectionItem },
|
||||
}
|
||||
|
||||
pub async fn video_list_from<'a>(
|
||||
args: Args<'a>,
|
||||
path: &Path,
|
||||
bili_client: &'a BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||
match args {
|
||||
Args::Favorite { fid } => favorite_from(fid, path, bili_client, connection).await,
|
||||
Args::Collection { collection_item } => collection_from(collection_item, path, bili_client, connection).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VideoListModel {
|
||||
/* 逻辑相关 */
|
||||
|
||||
/// 获取与视频列表关联的视频总数
|
||||
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64>;
|
||||
|
||||
/// 获取未填充的视频
|
||||
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<bili_sync_entity::video::Model>>;
|
||||
|
||||
/// 获取未处理的视频和分页
|
||||
async fn unhandled_video_pages(
|
||||
&self,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(bili_sync_entity::video::Model, Vec<bili_sync_entity::page::Model>)>>;
|
||||
|
||||
/// 获取该批次视频的存在标记
|
||||
async fn exist_labels(&self, videos_info: &[VideoInfo], connection: &DatabaseConnection)
|
||||
-> Result<HashSet<String>>;
|
||||
|
||||
/// 获取视频信息对应的视频 model
|
||||
fn video_model_by_info(
|
||||
&self,
|
||||
video_info: &VideoInfo,
|
||||
base_model: Option<bili_sync_entity::video::Model>,
|
||||
) -> bili_sync_entity::video::ActiveModel;
|
||||
|
||||
/// 获取视频 model 中缺失的信息
|
||||
async fn fetch_videos_detail(
|
||||
&self,
|
||||
bili_client: &BiliClient,
|
||||
videos_model: Vec<bili_sync_entity::video::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()>;
|
||||
|
||||
/* 日志相关 */
|
||||
fn log_fetch_video_start(&self);
|
||||
|
||||
fn log_fetch_video_end(&self);
|
||||
|
||||
fn log_download_video_start(&self);
|
||||
|
||||
fn log_download_video_end(&self);
|
||||
|
||||
fn log_refresh_video_start(&self);
|
||||
|
||||
fn log_refresh_video_end(&self, got_count: usize, new_count: u64);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use reqwest::{header, Method};
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
@@ -29,7 +29,7 @@ impl Client {
|
||||
.default_headers(headers)
|
||||
.gzip(true)
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(std::time::Duration::from_secs(30))
|
||||
.read_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -85,4 +85,13 @@ impl BiliClient {
|
||||
CONFIG.credential.store(Some(Arc::new(new_credential)));
|
||||
CONFIG.save()
|
||||
}
|
||||
|
||||
/// 检查凭据是否已设置且有效
|
||||
pub async fn is_login(&self) -> Result<()> {
|
||||
let credential = CONFIG.credential.load();
|
||||
let Some(credential) = credential.as_deref() else {
|
||||
bail!("no credential found");
|
||||
};
|
||||
credential.is_login(&self.client).await
|
||||
}
|
||||
}
|
||||
265
crates/bili_sync/src/bilibili/collection.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
|
||||
pub enum CollectionType {
|
||||
Series,
|
||||
Season,
|
||||
}
|
||||
|
||||
impl From<CollectionType> for i32 {
|
||||
fn from(v: CollectionType) -> Self {
|
||||
match v {
|
||||
CollectionType::Series => 1,
|
||||
CollectionType::Season => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for CollectionType {
|
||||
fn from(v: i32) -> Self {
|
||||
match v {
|
||||
1 => CollectionType::Series,
|
||||
2 => CollectionType::Season,
|
||||
_ => panic!("invalid collection type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CollectionType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
CollectionType::Series => "视频列表",
|
||||
CollectionType::Season => "视频合集",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
pub struct CollectionItem {
|
||||
pub mid: String,
|
||||
pub sid: String,
|
||||
pub collection_type: CollectionType,
|
||||
}
|
||||
|
||||
pub struct Collection<'a> {
|
||||
client: &'a BiliClient,
|
||||
collection: &'a CollectionItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CollectionInfo {
|
||||
pub name: String,
|
||||
pub mid: i64,
|
||||
pub sid: i64,
|
||||
pub collection_type: CollectionType,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CollectionInfo {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionInfoRaw {
|
||||
mid: i64,
|
||||
name: String,
|
||||
season_id: Option<i64>,
|
||||
series_id: Option<i64>,
|
||||
}
|
||||
let raw = CollectionInfoRaw::deserialize(deserializer)?;
|
||||
let (sid, collection_type) = match (raw.season_id, raw.series_id) {
|
||||
(Some(sid), None) => (sid, CollectionType::Season),
|
||||
(None, Some(sid)) => (sid, CollectionType::Series),
|
||||
_ => return Err(serde::de::Error::custom("invalid collection info")),
|
||||
};
|
||||
Ok(CollectionInfo {
|
||||
mid: raw.mid,
|
||||
name: raw.name,
|
||||
sid,
|
||||
collection_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Collection<'a> {
|
||||
pub fn new(client: &'a BiliClient, collection: &'a CollectionItem) -> Self {
|
||||
Self { client, collection }
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<CollectionInfo> {
|
||||
let meta = match self.collection.collection_type {
|
||||
// 没有找到专门获取 Season 信息的接口,所以直接获取第一页,从里面取 meta 信息
|
||||
CollectionType::Season => self.get_videos(1).await?["data"]["meta"].take(),
|
||||
CollectionType::Series => self.get_series_info().await?["data"]["meta"].take(),
|
||||
};
|
||||
Ok(serde_json::from_value(meta)?)
|
||||
}
|
||||
|
||||
async fn get_series_info(&self) -> Result<Value> {
|
||||
assert!(
|
||||
self.collection.collection_type == CollectionType::Series,
|
||||
"collection type is not series"
|
||||
);
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series")
|
||||
.query(&[("series_id", self.collection.sid.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
let page = page.to_string();
|
||||
let (url, query) = match self.collection.collection_type {
|
||||
CollectionType::Series => (
|
||||
"https://api.bilibili.com/x/series/archives",
|
||||
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"),
|
||||
],
|
||||
),
|
||||
CollectionType::Season => (
|
||||
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||
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"),
|
||||
],
|
||||
),
|
||||
};
|
||||
self.client
|
||||
.request(Method::GET, url)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_simple_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
|
||||
stream! {
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let mut videos = match self.get_videos(page).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to get videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if !videos["data"]["archives"].is_array() {
|
||||
warn!("no videos found in collection {:?} page {}", self.collection, page);
|
||||
break;
|
||||
}
|
||||
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["archives"].take()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("failed to parse videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||
break;
|
||||
},
|
||||
};
|
||||
for video_info in videos_info.into_iter(){
|
||||
yield video_info;
|
||||
}
|
||||
let fields = match self.collection.collection_type{
|
||||
CollectionType::Series => ["num", "size", "total"],
|
||||
CollectionType::Season => ["page_num", "page_size", "total"],
|
||||
};
|
||||
let fields = fields.into_iter().map(|f| videos["data"]["page"][f].as_i64()).collect::<Option<Vec<i64>>>().map(|v| (v[0], v[1], v[2]));
|
||||
let Some((num, size, total)) = fields else {
|
||||
error!("failed to get pages of collection {:?} page {}: {:?}", self.collection, page, fields);
|
||||
break;
|
||||
};
|
||||
if num * size >= total {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_collection_info_parse() {
|
||||
let testcases = vec![
|
||||
(
|
||||
r#"
|
||||
{
|
||||
"category": 0,
|
||||
"cover": "https://archive.biliimg.com/bfs/archive/a6fbf7a7b9f4af09d9cf40482268634df387ef68.jpg",
|
||||
"description": "",
|
||||
"mid": 521722088,
|
||||
"name": "合集·【命运方舟全剧情解说】",
|
||||
"ptime": 1714701600,
|
||||
"season_id": 1987140,
|
||||
"total": 10
|
||||
}
|
||||
"#,
|
||||
CollectionInfo {
|
||||
mid: 521722088,
|
||||
name: "合集·【命运方舟全剧情解说】".to_owned(),
|
||||
sid: 1987140,
|
||||
collection_type: CollectionType::Season,
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"
|
||||
{
|
||||
"series_id": 387212,
|
||||
"mid": 521722088,
|
||||
"name": "提瓦特冒险记",
|
||||
"description": "原神沙雕般的游戏体验",
|
||||
"keywords": [
|
||||
""
|
||||
],
|
||||
"creator": "",
|
||||
"state": 2,
|
||||
"last_update_ts": 1633167320,
|
||||
"total": 3,
|
||||
"ctime": 1633167320,
|
||||
"mtime": 1633167320,
|
||||
"raw_keywords": "",
|
||||
"category": 1
|
||||
}
|
||||
"#,
|
||||
CollectionInfo {
|
||||
mid: 521722088,
|
||||
name: "提瓦特冒险记".to_owned(),
|
||||
sid: 387212,
|
||||
collection_type: CollectionType::Series,
|
||||
},
|
||||
),
|
||||
];
|
||||
for (json, expect) in testcases {
|
||||
let info: CollectionInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info, expect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,24 @@ impl Credential {
|
||||
res["data"]["refresh"].as_bool().ok_or(anyhow!("check refresh failed"))
|
||||
}
|
||||
|
||||
/// 需要使用一个需要鉴权的接口来检查是否登录
|
||||
/// 此处使用查看用户状态数的接口,该接口返回内容少,请求成本低
|
||||
pub async fn is_login(&self, client: &Client) -> Result<()> {
|
||||
client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/nav/stat",
|
||||
Some(self),
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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?;
|
||||
@@ -1,11 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate};
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
pub struct FavoriteList<'a> {
|
||||
client: &'a BiliClient,
|
||||
fid: String,
|
||||
@@ -17,24 +15,6 @@ pub struct FavoriteListInfo {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct VideoInfo {
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub vtype: i32,
|
||||
pub bvid: String,
|
||||
pub intro: String,
|
||||
pub cover: String,
|
||||
pub upper: Upper,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub ctime: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub fav_time: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub pubtime: DateTime<Utc>,
|
||||
pub attr: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Upper {
|
||||
pub mid: i64,
|
||||
94
crates/bili_sync/src/bilibili/mod.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use client::{BiliClient, Client};
|
||||
pub use collection::{Collection, CollectionItem, CollectionType};
|
||||
pub use credential::Credential;
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
pub use video::{Dimension, PageInfo, Video};
|
||||
|
||||
mod analyzer;
|
||||
mod client;
|
||||
mod collection;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod video;
|
||||
|
||||
pub(crate) trait Validate {
|
||||
type Output;
|
||||
|
||||
fn validate(self) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl Validate for serde_json::Value {
|
||||
type Output = serde_json::Value;
|
||||
|
||||
fn validate(self) -> Result<Self::Output> {
|
||||
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
/// 注意此处的顺序是有要求的,因为对于 untagged 的 enum 来说,serde 会按照顺序匹配
|
||||
/// > There is no explicit tag identifying which variant the data contains.
|
||||
/// > Serde will try to match the data against each variant in order and the first one that deserializes successfully is the one returned.
|
||||
pub enum VideoInfo {
|
||||
/// 从视频详情接口获取的视频信息
|
||||
View {
|
||||
title: String,
|
||||
bvid: String,
|
||||
#[serde(rename = "desc")]
|
||||
intro: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "owner")]
|
||||
upper: Upper,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
pages: Vec<PageInfo>,
|
||||
state: i32,
|
||||
},
|
||||
/// 从收藏夹中获取的视频信息
|
||||
Detail {
|
||||
title: String,
|
||||
#[serde(rename = "type")]
|
||||
vtype: i32,
|
||||
bvid: String,
|
||||
intro: String,
|
||||
cover: String,
|
||||
upper: Upper,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
fav_time: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
attr: i32,
|
||||
},
|
||||
/// 从视频列表中获取的视频信息
|
||||
Simple {
|
||||
bvid: String,
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use reqwest::Method;
|
||||
use crate::bilibili::analyzer::PageAnalyzer;
|
||||
use crate::bilibili::client::BiliClient;
|
||||
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
|
||||
use crate::bilibili::Validate;
|
||||
use crate::bilibili::{Validate, VideoInfo};
|
||||
|
||||
static MASK_CODE: u64 = 2251799813685247;
|
||||
static XOR_CODE: u64 = 23442827791579;
|
||||
@@ -39,7 +39,7 @@ impl serde::Serialize for Tag {
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct PageInfo {
|
||||
pub cid: i32,
|
||||
pub cid: i64,
|
||||
pub page: i32,
|
||||
#[serde(rename = "part")]
|
||||
pub name: String,
|
||||
@@ -61,6 +61,22 @@ impl<'a> Video<'a> {
|
||||
Self { client, aid, bvid }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// 直接调用视频信息接口获取详细的视频信息
|
||||
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")
|
||||
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||
let mut res = self
|
||||
.client
|
||||
@@ -92,7 +108,7 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||
let tasks = FuturesUnordered::new();
|
||||
for i in 1..=(page.duration + 359) / 360 {
|
||||
tasks.push(self.get_danmaku_segment(page, i as i32));
|
||||
tasks.push(self.get_danmaku_segment(page, i as i64));
|
||||
}
|
||||
let result: Vec<Vec<DanmakuElem>> = tasks.try_collect().await?;
|
||||
let mut result: Vec<DanmakuElem> = result.into_iter().flatten().collect();
|
||||
@@ -100,7 +116,7 @@ impl<'a> Video<'a> {
|
||||
Ok(DanmakuWriter::new(page, result.into_iter().map(|x| x.into()).collect()))
|
||||
}
|
||||
|
||||
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i32) -> Result<Vec<DanmakuElem>> {
|
||||
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i64) -> Result<Vec<DanmakuElem>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "http://api.bilibili.com/x/v2/dm/web/seg.so")
|
||||
@@ -158,8 +174,8 @@ fn bvid_to_aid(bvid: &str) -> u64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bvid_to_aid() {
|
||||
#[test]
|
||||
fn test_bvid_to_aid() {
|
||||
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
||||
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
||||
}
|
||||
250
crates/bili_sync/src/config.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use handlebars::handlebars_helper;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::de::{Deserializer, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{CollectionItem, CollectionType, Credential, DanmakuOption, FilterOption};
|
||||
|
||||
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||
if s.chars().count() > len {
|
||||
s.chars().take(len).collect::<String>()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars
|
||||
.register_template_string("video", &CONFIG.video_name)
|
||||
.unwrap();
|
||||
handlebars.register_template_string("page", &CONFIG.page_name).unwrap();
|
||||
handlebars
|
||||
});
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||
let config = Config::load().unwrap_or_else(|err| {
|
||||
if err
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.map_or(true, |e| e.kind() != std::io::ErrorKind::NotFound)
|
||||
{
|
||||
panic!("加载配置文件失败,错误为: {err}");
|
||||
}
|
||||
warn!("配置文件不存在,使用默认配置...");
|
||||
Config::default()
|
||||
});
|
||||
// 放到外面,确保新的配置项被保存
|
||||
info!("配置加载完毕,覆盖刷新原有配置");
|
||||
config.save().unwrap();
|
||||
// 检查配置文件内容
|
||||
info!("校验配置文件内容...");
|
||||
config.check();
|
||||
config
|
||||
});
|
||||
|
||||
pub static ARGS: Lazy<Args> = Lazy::new(Args::parse);
|
||||
|
||||
pub static CONFIG_DIR: Lazy<PathBuf> =
|
||||
Lazy::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
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>,
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NFOTimeType {
|
||||
#[default]
|
||||
FavTime,
|
||||
PubTime,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
credential: ArcSwapOption::from(Some(Arc::new(Credential::default()))),
|
||||
filter_option: FilterOption::default(),
|
||||
danmaku_option: DanmakuOption::default(),
|
||||
favorite_list: HashMap::new(),
|
||||
collection_list: HashMap::new(),
|
||||
video_name: Cow::Borrowed("{{title}}"),
|
||||
page_name: Cow::Borrowed("{{bvid}}"),
|
||||
interval: 1200,
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
nfo_time_type: NFOTimeType::FavTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// 简单的预检查
|
||||
pub fn check(&self) {
|
||||
let mut ok = true;
|
||||
if self.favorite_list.is_empty() && self.collection_list.is_empty() {
|
||||
ok = false;
|
||||
error!("未设置需监听的收藏夹或视频合集,程序空转没有意义");
|
||||
}
|
||||
for path in self.favorite_list.values() {
|
||||
if !path.is_absolute() {
|
||||
ok = false;
|
||||
error!("收藏夹保存的路径应为绝对路径,检测到: {}", 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 !ok {
|
||||
panic!(
|
||||
"位于 {} 的配置文件不合法,请参考提示信息修复后继续运行",
|
||||
CONFIG_DIR.join("config.toml").display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load() -> Result<Self> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
Ok(toml::from_str(&config_content)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
std::fs::create_dir_all(&*CONFIG_DIR)?;
|
||||
std::fs::write(config_path, toml::to_string_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[arg(short, long, env = "SCAN_ONLY")]
|
||||
pub scan_only: bool,
|
||||
|
||||
#[arg(short, long, default_value = "None,bili_sync=info", env = "RUST_LOG")]
|
||||
pub log_level: String,
|
||||
}
|
||||
25
crates/bili_sync/src/database.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
|
||||
fn database_url() -> String {
|
||||
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
|
||||
}
|
||||
|
||||
pub async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url());
|
||||
option
|
||||
.max_connections(100)
|
||||
.min_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(90));
|
||||
Ok(Database::connect(option).await?)
|
||||
}
|
||||
|
||||
pub async fn migrate_database() -> Result<()> {
|
||||
// 注意此处使用内部构造的 DatabaseConnection,而不是通过 database_connection() 获取
|
||||
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
|
||||
let connection = Database::connect(database_url()).await?;
|
||||
Ok(Migrator::up(&connection, None).await?)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ impl Downloader {
|
||||
audio_path.to_str().unwrap(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
output_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
63
crates/bili_sync/src/main.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
#[macro_use]
|
||||
extern crate tracing;
|
||||
|
||||
mod adapter;
|
||||
mod bilibili;
|
||||
mod config;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
mod utils;
|
||||
mod workflow;
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::time;
|
||||
|
||||
use crate::adapter::Args;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::{ARGS, CONFIG};
|
||||
use crate::database::{database_connection, migrate_database};
|
||||
use crate::utils::init_logger;
|
||||
use crate::workflow::process_video_list;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_logger(&ARGS.log_level);
|
||||
Lazy::force(&CONFIG);
|
||||
migrate_database().await.expect("数据库迁移失败");
|
||||
let connection = database_connection().await.expect("获取数据库连接失败");
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
let bili_client = BiliClient::new();
|
||||
loop {
|
||||
if let Err(e) = bili_client.is_login().await {
|
||||
error!("检查登录状态时遇到错误:{e},等待下一轮执行");
|
||||
time::sleep(Duration::from_secs(CONFIG.interval)).await;
|
||||
continue;
|
||||
}
|
||||
if anchor != chrono::Local::now().date_naive() {
|
||||
if let Err(e) = bili_client.check_refresh().await {
|
||||
error!("检查刷新 Credential 遇到错误:{e},等待下一轮执行");
|
||||
time::sleep(Duration::from_secs(CONFIG.interval)).await;
|
||||
continue;
|
||||
}
|
||||
anchor = chrono::Local::now().date_naive();
|
||||
}
|
||||
for (fid, path) in &CONFIG.favorite_list {
|
||||
if let Err(e) = process_video_list(Args::Favorite { fid }, &bili_client, path, &connection).await {
|
||||
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有收藏夹处理完毕");
|
||||
for (collection_item, path) in &CONFIG.collection_list {
|
||||
if let Err(e) =
|
||||
process_video_list(Args::Collection { collection_item }, &bili_client, path, &connection).await
|
||||
{
|
||||
error!("处理合集 {collection_item:?} 时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有合集处理完毕");
|
||||
info!("本轮任务执行完毕,等待下一轮执行");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(CONFIG.interval)).await;
|
||||
}
|
||||
}
|
||||
132
crates/bili_sync/src/utils/convert.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use sea_orm::ActiveValue::NotSet;
|
||||
use sea_orm::{IntoActiveModel, Set};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::bilibili::VideoInfo;
|
||||
use crate::utils::id_time_key;
|
||||
|
||||
impl VideoInfo {
|
||||
/// 将 VideoInfo 转换为 ActiveModel
|
||||
pub fn to_model(&self, base_model: Option<bili_sync_entity::video::Model>) -> bili_sync_entity::video::ActiveModel {
|
||||
let base_model = match base_model {
|
||||
Some(base_model) => base_model.into_active_model(),
|
||||
None => {
|
||||
let mut tmp_model = bili_sync_entity::video::Model::default().into_active_model();
|
||||
// 注意此处要把 id 和 created_at 设置为 NotSet,方便在 sql 中忽略这些字段,交由数据库自动生成
|
||||
tmp_model.id = NotSet;
|
||||
tmp_model.created_at = NotSet;
|
||||
tmp_model
|
||||
}
|
||||
};
|
||||
match self {
|
||||
VideoInfo::Simple {
|
||||
bvid,
|
||||
cover,
|
||||
ctime,
|
||||
pubtime,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||
valid: Set(true),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::Detail {
|
||||
title,
|
||||
vtype,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
ctime,
|
||||
fav_time,
|
||||
pubtime,
|
||||
attr,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
category: Set(*vtype),
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(*attr == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name.clone()),
|
||||
upper_face: Set(upper.face.clone()),
|
||||
..base_model
|
||||
},
|
||||
VideoInfo::View {
|
||||
title,
|
||||
bvid,
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
ctime,
|
||||
pubtime,
|
||||
state,
|
||||
..
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid.clone()),
|
||||
name: Set(title.clone()),
|
||||
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||
intro: Set(intro.clone()),
|
||||
cover: Set(cover.clone()),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(pubtime.naive_utc()), // 合集不包括 fav_time,使用发布时间代替
|
||||
download_status: Set(0),
|
||||
valid: Set(*state == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name.clone()),
|
||||
upper_face: Set(upper.face.clone()),
|
||||
..base_model
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_fmt_args(&self) -> Option<serde_json::Value> {
|
||||
match self {
|
||||
VideoInfo::Simple { .. } => None, // 不能从简单的视频信息中构造格式化参数
|
||||
VideoInfo::Detail { title, bvid, upper, .. } => Some(json!({
|
||||
"bvid": &bvid,
|
||||
"title": &title,
|
||||
"upper_name": &upper.name,
|
||||
"upper_mid": &upper.mid,
|
||||
})),
|
||||
VideoInfo::View { title, bvid, upper, .. } => Some(json!({
|
||||
"bvid": &bvid,
|
||||
"title": &title,
|
||||
"upper_name": &upper.name,
|
||||
"upper_mid": &upper.mid,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn video_key(&self) -> String {
|
||||
match self {
|
||||
// 对于合集没有 fav_time,只能用 pubtime 代替
|
||||
VideoInfo::Simple { bvid, pubtime, .. } => id_time_key(bvid, pubtime),
|
||||
VideoInfo::Detail { bvid, fav_time, .. } => id_time_key(bvid, fav_time),
|
||||
// 详情接口返回的数据仅用于填充详情,不会被作为 video_key
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bvid(&self) -> &str {
|
||||
match self {
|
||||
VideoInfo::Simple { bvid, .. } => bvid,
|
||||
VideoInfo::Detail { bvid, .. } => bvid,
|
||||
// 同上
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
23
crates/bili_sync/src/utils/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
pub mod convert;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod status;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
pub fn init_logger(log_level: &str) {
|
||||
tracing_subscriber::fmt::Subscriber::builder()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
|
||||
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
||||
"%Y-%m-%d %H:%M:%S%.3f".to_owned(),
|
||||
))
|
||||
.finish()
|
||||
.try_init()
|
||||
.expect("初始化日志失败");
|
||||
}
|
||||
|
||||
/// 生成视频的唯一标记,均由 bvid 和时间戳构成
|
||||
pub fn id_time_key(bvid: &String, time: &DateTime<Utc>) -> String {
|
||||
format!("{}-{}", bvid, time.timestamp())
|
||||
}
|
||||
96
crates/bili_sync/src/utils/model.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::OnConflict;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
|
||||
use crate::adapter::VideoListModel;
|
||||
use crate::bilibili::{PageInfo, VideoInfo};
|
||||
|
||||
/// 尝试创建 Video Model,如果发生冲突则忽略
|
||||
pub async fn create_videos(
|
||||
videos_info: &[VideoInfo],
|
||||
video_list_model: &dyn VideoListModel,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let video_models = videos_info
|
||||
.iter()
|
||||
.map(|v| video_list_model.video_model_by_info(v, None))
|
||||
.collect::<Vec<_>>();
|
||||
video::Entity::insert_many(video_models)
|
||||
// 这里想表达的是 on 索引名,但 sea-orm 的 api 似乎只支持列名而不支持索引名,好在留空可以达到相同的目的
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建视频的所有分 P
|
||||
pub async fn create_video_pages(
|
||||
pages_info: &[PageInfo],
|
||||
video_model: &video::Model,
|
||||
connection: &impl ConnectionTrait,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.iter()
|
||||
.map(move |p| {
|
||||
let (width, height) = match &p.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
(Some(d.width), Some(d.height))
|
||||
} else {
|
||||
(Some(d.height), Some(d.width))
|
||||
}
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
cid: Set(p.cid),
|
||||
pid: Set(p.page),
|
||||
name: Set(p.name.clone()),
|
||||
width: Set(width),
|
||||
height: Set(height),
|
||||
duration: Set(p.duration),
|
||||
image: Set(p.first_frame.clone()),
|
||||
download_status: Set(0),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
page::Entity::insert_many(page_models)
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频 model 的下载状态
|
||||
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
video::Entity::insert_many(videos)
|
||||
.on_conflict(
|
||||
OnConflict::column(video::Column::Id)
|
||||
.update_column(video::Column::DownloadStatus)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频页 model 的下载状态
|
||||
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
let query = page::Entity::insert_many(pages).on_conflict(
|
||||
OnConflict::column(page::Column::Id)
|
||||
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
||||
.to_owned(),
|
||||
);
|
||||
query.exec(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,41 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use entity::*;
|
||||
use filenamify::filenamify;
|
||||
use handlebars::handlebars_helper;
|
||||
use migration::OnConflict;
|
||||
use once_cell::sync::Lazy;
|
||||
use bili_sync_entity::*;
|
||||
use quick_xml::events::{BytesCData, BytesText};
|
||||
use quick_xml::writer::Writer;
|
||||
use quick_xml::Error;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::QuerySelect;
|
||||
use serde_json::json;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::bilibili::{FavoriteListInfo, PageInfo, VideoInfo};
|
||||
use crate::config::CONFIG;
|
||||
use crate::core::status::Status;
|
||||
|
||||
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||
if s.chars().count() > len {
|
||||
s.chars().take(len).collect::<String>()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars
|
||||
.register_template_string("video", &CONFIG.video_name)
|
||||
.unwrap();
|
||||
handlebars.register_template_string("page", &CONFIG.page_name).unwrap();
|
||||
handlebars
|
||||
});
|
||||
use crate::config::NFOTimeType;
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum NFOMode {
|
||||
@@ -52,229 +22,10 @@ pub enum ModelWrapper<'a> {
|
||||
|
||||
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
|
||||
|
||||
/// 根据获得的收藏夹信息,插入或更新数据库中的收藏夹,并返回收藏夹对象
|
||||
pub async fn handle_favorite_info(
|
||||
info: &FavoriteListInfo,
|
||||
path: &Path,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
favorite::Entity::insert(favorite::ActiveModel {
|
||||
f_id: Set(info.id),
|
||||
name: Set(info.title.clone()),
|
||||
path: Set(path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(favorite::Column::FId)
|
||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(favorite::Entity::find()
|
||||
.filter(favorite::Column::FId.eq(info.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// 获取数据库中存在的与该视频 favorite_id 和 bvid 重合的视频中的 bvid 和 favtime
|
||||
/// 如果 bvid 和 favtime 均相同,说明到达了上次处理到的位置
|
||||
pub async fn exist_labels(
|
||||
videos_info: &[VideoInfo],
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<HashSet<(String, DateTime)>> {
|
||||
let bvids = videos_info.iter().map(|v| v.bvid.clone()).collect::<Vec<String>>();
|
||||
let exist_labels = video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Bvid.is_in(bvids)),
|
||||
)
|
||||
.select_only()
|
||||
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||
.into_tuple()
|
||||
.all(connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<HashSet<(String, DateTime)>>();
|
||||
Ok(exist_labels)
|
||||
}
|
||||
|
||||
/// 尝试创建 Video Model,如果发生冲突则忽略
|
||||
pub async fn create_videos(
|
||||
videos_info: &[VideoInfo],
|
||||
favorite: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let video_models = videos_info
|
||||
.iter()
|
||||
.map(move |v| video::ActiveModel {
|
||||
favorite_id: Set(favorite.id),
|
||||
bvid: Set(v.bvid.clone()),
|
||||
name: Set(v.title.clone()),
|
||||
path: Set(Path::new(&favorite.path)
|
||||
.join(filenamify(
|
||||
TEMPLATE
|
||||
.render(
|
||||
"video",
|
||||
&json!({
|
||||
"bvid": &v.bvid,
|
||||
"title": &v.title,
|
||||
"upper_name": &v.upper.name,
|
||||
"upper_mid": &v.upper.mid,
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|_| v.bvid.clone()),
|
||||
))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned()),
|
||||
category: Set(v.vtype),
|
||||
intro: Set(v.intro.clone()),
|
||||
cover: Set(v.cover.clone()),
|
||||
ctime: Set(v.ctime.naive_utc()),
|
||||
pubtime: Set(v.pubtime.naive_utc()),
|
||||
favtime: Set(v.fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(v.attr == 0),
|
||||
tags: Set(None),
|
||||
single_page: Set(None),
|
||||
upper_id: Set(v.upper.mid),
|
||||
upper_name: Set(v.upper.name.clone()),
|
||||
upper_face: Set(v.upper.face.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<video::ActiveModel>>();
|
||||
video::Entity::insert_many(video_models)
|
||||
.on_conflict(
|
||||
OnConflict::columns([video::Column::FavoriteId, video::Column::Bvid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn total_video_count(favorite_model: &favorite::Model, connection: &DatabaseConnection) -> Result<u64> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(video::Column::FavoriteId.eq(favorite_model.id))
|
||||
.count(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 筛选所有未
|
||||
pub async fn filter_unfilled_videos(
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<video::Model>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.eq(0))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_null()),
|
||||
)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 创建视频的所有分 P
|
||||
pub async fn create_video_pages(
|
||||
pages_info: &[PageInfo],
|
||||
video_model: &video::Model,
|
||||
connection: &impl ConnectionTrait,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.iter()
|
||||
.map(move |p| {
|
||||
let (width, height) = match &p.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
(Some(d.width), Some(d.height))
|
||||
} else {
|
||||
(Some(d.height), Some(d.width))
|
||||
}
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
cid: Set(p.cid),
|
||||
pid: Set(p.page),
|
||||
name: Set(p.name.clone()),
|
||||
width: Set(width),
|
||||
height: Set(height),
|
||||
duration: Set(p.duration),
|
||||
image: Set(p.first_frame.clone()),
|
||||
download_status: Set(0),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
page::Entity::insert_many(page_models)
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有未处理的视频和页
|
||||
pub async fn unhandled_videos_pages(
|
||||
favorite_model: &favorite::Model,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||
Ok(video::Entity::find()
|
||||
.filter(
|
||||
video::Column::FavoriteId
|
||||
.eq(favorite_model.id)
|
||||
.and(video::Column::Valid.eq(true))
|
||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null()),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
.all(connection)
|
||||
.await?)
|
||||
}
|
||||
/// 更新视频 model 的下载状态
|
||||
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
video::Entity::insert_many(videos)
|
||||
.on_conflict(
|
||||
OnConflict::column(video::Column::Id)
|
||||
.update_column(video::Column::DownloadStatus)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新视频页 model 的下载状态
|
||||
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||
let query = page::Entity::insert_many(pages).on_conflict(
|
||||
OnConflict::column(page::Column::Id)
|
||||
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
||||
.to_owned(),
|
||||
);
|
||||
query.exec(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// serde xml 似乎不太好用,先这么裸着写
|
||||
/// (真是又臭又长啊
|
||||
impl<'a> NFOSerializer<'a> {
|
||||
pub async fn generate_nfo(self) -> Result<String> {
|
||||
pub async fn generate_nfo(self, nfo_time_type: &NFOTimeType) -> Result<String> {
|
||||
let mut buffer = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
"#
|
||||
.as_bytes()
|
||||
@@ -283,6 +34,10 @@ impl<'a> NFOSerializer<'a> {
|
||||
let mut writer = Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
|
||||
match self {
|
||||
NFOSerializer(ModelWrapper::Video(v), NFOMode::MOVIE) => {
|
||||
let nfo_time = match nfo_time_type {
|
||||
NFOTimeType::FavTime => v.favtime,
|
||||
NFOTimeType::PubTime => v.pubtime,
|
||||
};
|
||||
writer
|
||||
.create_element("movie")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
@@ -316,7 +71,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(tags) = &v.tags {
|
||||
@@ -337,7 +92,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y-%m-%d").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(writer)
|
||||
@@ -346,6 +101,10 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
}
|
||||
NFOSerializer(ModelWrapper::Video(v), NFOMode::TVSHOW) => {
|
||||
let nfo_time = match nfo_time_type {
|
||||
NFOTimeType::FavTime => v.favtime,
|
||||
NFOTimeType::PubTime => v.pubtime,
|
||||
};
|
||||
writer
|
||||
.create_element("tvshow")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
@@ -379,7 +138,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(tags) = &v.tags {
|
||||
@@ -400,7 +159,7 @@ impl<'a> NFOSerializer<'a> {
|
||||
.unwrap();
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&v.favtime.format("%Y-%m-%d").to_string()))
|
||||
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(writer)
|
||||
@@ -490,8 +249,8 @@ mod tests {
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
),
|
||||
pubtime: chrono::NaiveDateTime::new(
|
||||
chrono::NaiveDate::from_ymd_opt(2022, 2, 2).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
chrono::NaiveDate::from_ymd_opt(2033, 3, 3).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||
),
|
||||
bvid: "bvid".to_string(),
|
||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||
@@ -499,7 +258,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::MOVIE)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::PubTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -511,16 +270,16 @@ mod tests {
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
</actor>
|
||||
<year>2022</year>
|
||||
<year>2033</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">bvid</uniqueid>
|
||||
<aired>2022-02-02</aired>
|
||||
<aired>2033-03-03</aired>
|
||||
</movie>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::TVSHOW)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -541,7 +300,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Video(&video), NFOMode::UPPER)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -549,7 +308,7 @@ mod tests {
|
||||
<plot/>
|
||||
<outline/>
|
||||
<lockdata>false</lockdata>
|
||||
<dateadded>2022-02-02 02:02:02</dateadded>
|
||||
<dateadded>2033-03-03 03:03:03</dateadded>
|
||||
<title>1</title>
|
||||
<sorttitle>1</sorttitle>
|
||||
</person>"#,
|
||||
@@ -561,7 +320,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
NFOSerializer(ModelWrapper::Page(&page), NFOMode::EPOSODE)
|
||||
.generate_nfo()
|
||||
.generate_nfo(&NFOTimeType::FavTime)
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
@@ -1,164 +1,98 @@
|
||||
#![allow(dead_code, unused_variables)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::{args, var};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use entity::{favorite, page, video};
|
||||
use bili_sync_entity::{page, video};
|
||||
use filenamify::filenamify;
|
||||
use futures::stream::{FuturesOrdered, FuturesUnordered};
|
||||
use futures::{pin_mut, Future, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use futures::{Future, Stream, StreamExt};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::TransactionTrait;
|
||||
use serde_json::json;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, FavoriteList, PageInfo, Video};
|
||||
use crate::config::CONFIG;
|
||||
use crate::core::status::{PageStatus, VideoStatus};
|
||||
use crate::core::utils::{
|
||||
create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count,
|
||||
unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE,
|
||||
};
|
||||
use crate::adapter::{video_list_from, Args, VideoListModel};
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Video, VideoInfo};
|
||||
use crate::config::{ARGS, CONFIG, TEMPLATE};
|
||||
use crate::downloader::Downloader;
|
||||
use crate::error::{DownloadAbortError, ProcessPageError};
|
||||
use crate::utils::model::{create_videos, update_pages_model, update_videos_model};
|
||||
use crate::utils::nfo::{ModelWrapper, NFOMode, NFOSerializer};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
pub static SCAN_ONLY: Lazy<bool> = Lazy::new(|| var("SCAN_ONLY").is_ok() || args().any(|arg| arg == "--scan-only"));
|
||||
|
||||
/// 处理某个收藏夹,首先刷新收藏夹信息,然后下载收藏夹中未下载成功的视频
|
||||
pub async fn process_favorite_list(
|
||||
pub async fn process_video_list(
|
||||
args: Args<'_>,
|
||||
bili_client: &BiliClient,
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
let favorite_model = refresh_favorite_list(bili_client, fid, path, connection).await?;
|
||||
let favorite_model = fetch_video_details(bili_client, favorite_model, connection).await?;
|
||||
if *SCAN_ONLY {
|
||||
let (video_list_model, video_streams) = video_list_from(args, path, bili_client, connection).await?;
|
||||
let video_list_model = refresh_video_list(bili_client, video_list_model, video_streams, connection).await?;
|
||||
let video_list_model = fetch_video_details(bili_client, video_list_model, connection).await?;
|
||||
if ARGS.scan_only {
|
||||
warn!("已开启仅扫描模式,跳过视频下载...");
|
||||
return Ok(());
|
||||
}
|
||||
download_unprocessed_videos(bili_client, favorite_model, connection).await
|
||||
download_unprocessed_videos(bili_client, video_list_model, connection).await
|
||||
}
|
||||
|
||||
/// 获取收藏夹 Model,从收藏夹列表中获取所有新添加的视频,将其写入数据库
|
||||
pub async fn refresh_favorite_list(
|
||||
bili_client: &BiliClient,
|
||||
fid: &str,
|
||||
path: &Path,
|
||||
/// 请求接口,获取视频列表中所有新添加的视频信息,将其写入数据库
|
||||
pub async fn refresh_video_list<'a>(
|
||||
bili_client: &'a BiliClient,
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
video_streams: Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
let bili_favorite_list = FavoriteList::new(bili_client, fid.to_owned());
|
||||
let favorite_list_info = bili_favorite_list.get_info().await?;
|
||||
let favorite_model = handle_favorite_info(&favorite_list_info, path, connection).await?;
|
||||
info!("开始扫描收藏夹: {} - {}...", favorite_model.f_id, favorite_model.name);
|
||||
// 每十个视频一组,避免太多的数据库操作
|
||||
let video_stream = bili_favorite_list.into_video_stream().chunks(10);
|
||||
pin_mut!(video_stream);
|
||||
) -> Result<Box<dyn VideoListModel>> {
|
||||
video_list_model.log_refresh_video_start();
|
||||
let mut video_streams = video_streams.chunks(10);
|
||||
let mut got_count = 0;
|
||||
let total_count = total_video_count(&favorite_model, connection).await?;
|
||||
while let Some(videos_info) = video_stream.next().await {
|
||||
let mut new_count = video_list_model.video_count(connection).await?;
|
||||
while let Some(videos_info) = video_streams.next().await {
|
||||
got_count += videos_info.len();
|
||||
let exist_labels = exist_labels(&videos_info, &favorite_model, connection).await?;
|
||||
let exist_labels = video_list_model.exist_labels(&videos_info, connection).await?;
|
||||
// 如果发现有视频的收藏时间和 bvid 和数据库中重合,说明到达了上次处理到的地方,可以直接退出
|
||||
let should_break = videos_info
|
||||
.iter()
|
||||
.any(|v| exist_labels.contains(&(v.bvid.clone(), v.fav_time.naive_utc())));
|
||||
let should_break = videos_info.iter().any(|v| exist_labels.contains(&v.video_key()));
|
||||
// 将视频信息写入数据库
|
||||
create_videos(&videos_info, &favorite_model, connection).await?;
|
||||
create_videos(&videos_info, video_list_model.as_ref(), connection).await?;
|
||||
if should_break {
|
||||
info!("到达上一次处理的位置,提前中止");
|
||||
break;
|
||||
}
|
||||
}
|
||||
let total_count = total_video_count(&favorite_model, connection).await? - total_count;
|
||||
info!(
|
||||
"扫描收藏夹: {} - {} 完成, 获取了 {} 条视频, 其中有 {} 条新视频",
|
||||
favorite_model.f_id, favorite_model.name, got_count, total_count
|
||||
);
|
||||
Ok(favorite_model)
|
||||
new_count = video_list_model.video_count(connection).await? - new_count;
|
||||
video_list_model.log_refresh_video_end(got_count, new_count);
|
||||
Ok(video_list_model)
|
||||
}
|
||||
|
||||
/// 筛选出所有没有获取到分页信息和 tag 的视频,请求分页信息和 tag 并写入数据库
|
||||
/// 筛选出所有未获取到全部信息的视频,尝试补充其详细信息
|
||||
pub async fn fetch_video_details(
|
||||
bili_client: &BiliClient,
|
||||
favorite_model: favorite::Model,
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<favorite::Model> {
|
||||
info!(
|
||||
"开始获取收藏夹 {} - {} 的视频与分页信息...",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
let videos_model = filter_unfilled_videos(&favorite_model, connection).await?;
|
||||
for video_model in videos_model {
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let tags = match bili_video.get_tags().await {
|
||||
Ok(tags) => tags,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的标签失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
||||
if *code == -404 {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pages_info = match bili_video.get_pages().await {
|
||||
Ok(pages) => pages,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的分页信息失败,错误为:{}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
||||
if *code == -404 {
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||
// 将页标记和 tag 写入数据库
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
info!(
|
||||
"获取收藏夹 {} - {} 的视频与分页信息完成",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
Ok(favorite_model)
|
||||
) -> Result<Box<dyn VideoListModel>> {
|
||||
video_list_model.log_fetch_video_start();
|
||||
let videos_model = video_list_model.unfilled_videos(connection).await?;
|
||||
video_list_model
|
||||
.fetch_videos_detail(bili_client, videos_model, connection)
|
||||
.await?;
|
||||
video_list_model.log_fetch_video_end();
|
||||
Ok(video_list_model)
|
||||
}
|
||||
|
||||
/// 下载所有未处理成功的视频
|
||||
pub async fn download_unprocessed_videos(
|
||||
bili_client: &BiliClient,
|
||||
favorite_model: favorite::Model,
|
||||
video_list_model: Box<dyn VideoListModel>,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<()> {
|
||||
info!(
|
||||
"开始下载收藏夹: {} - {} 中所有未处理过的视频...",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
let unhandled_videos_pages = unhandled_videos_pages(&favorite_model, connection).await?;
|
||||
// 对于视频,允许五个同时下载(视频内还有分页、不同分页还有多种下载任务)
|
||||
let semaphore = Semaphore::new(5);
|
||||
video_list_model.log_download_video_start();
|
||||
let unhandled_videos_pages = video_list_model.unhandled_video_pages(connection).await?;
|
||||
// 对于视频,允许三个同时下载(视频内还有分页、不同分页还有多种下载任务)
|
||||
let semaphore = Semaphore::new(3);
|
||||
let downloader = Downloader::new(bili_client.client.clone());
|
||||
let mut uppers_mutex: HashMap<i64, (Mutex<()>, Mutex<()>)> = HashMap::new();
|
||||
for (video_model, _) in &unhandled_videos_pages {
|
||||
@@ -201,10 +135,7 @@ pub async fn download_unprocessed_videos(
|
||||
if !models.is_empty() {
|
||||
update_videos_model(models, connection).await?;
|
||||
}
|
||||
info!(
|
||||
"下载收藏夹: {} - {} 中未处理过的视频完成",
|
||||
favorite_model.f_id, favorite_model.name
|
||||
);
|
||||
video_list_model.log_download_video_end();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -312,8 +243,8 @@ pub async fn dispatch_download_page(
|
||||
if !should_run {
|
||||
return Ok(());
|
||||
}
|
||||
// 对于视频的分页,允许同时下载三个同时下载(绝大部分是单页视频)
|
||||
let child_semaphore = Semaphore::new(5);
|
||||
// 对于视频的分页,允许两个同时下载(绝大部分是单页视频)
|
||||
let child_semaphore = Semaphore::new(2);
|
||||
let mut tasks = pages
|
||||
.into_iter()
|
||||
.map(|page_model| download_page(bili_client, video_model, page_model, &child_semaphore, downloader))
|
||||
@@ -657,7 +588,11 @@ async fn generate_nfo(serializer: NFOSerializer<'_>, nfo_path: PathBuf) -> Resul
|
||||
if let Some(parent) = nfo_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(nfo_path, serializer.generate_nfo().await?.as_bytes()).await?;
|
||||
fs::write(
|
||||
nfo_path,
|
||||
serializer.generate_nfo(&CONFIG.nfo_time_type).await?.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
9
crates/bili_sync_entity/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "bili_sync_entity"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
sea-orm = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
21
crates/bili_sync_entity/src/entities/collection.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "collection")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub s_id: i64,
|
||||
pub m_id: i64,
|
||||
pub name: String,
|
||||
pub r#type: i32,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod collection;
|
||||
pub mod favorite;
|
||||
pub mod page;
|
||||
pub mod video;
|
||||
@@ -8,7 +8,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub cid: i32,
|
||||
pub cid: i64,
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
pub width: Option<u32>,
|
||||
@@ -7,7 +7,8 @@ use sea_orm::entity::prelude::*;
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub favorite_id: i32,
|
||||
pub collection_id: Option<i32>,
|
||||
pub favorite_id: Option<i32>,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: String,
|
||||
pub upper_face: String,
|
||||
9
crates/bili_sync_migration/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "bili_sync_migration"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
async-std = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
@@ -1,12 +1,16 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20240322_000001_create_table;
|
||||
mod m20240505_130850_add_collection;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20240322_000001_create_table::Migration)]
|
||||
vec![
|
||||
Box::new(m20240322_000001_create_table::Migration),
|
||||
Box::new(m20240505_130850_add_collection::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Collection::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Collection::Id)
|
||||
.unsigned()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Collection::SId).unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::MId).unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::Name).string().not_null())
|
||||
.col(ColumnDef::new(Collection::Type).small_unsigned().not_null())
|
||||
.col(ColumnDef::new(Collection::Path).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Collection::CreatedAt)
|
||||
.timestamp()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.table(Collection::Table)
|
||||
.name("idx_collection_sid_mid_type")
|
||||
.col(Collection::SId)
|
||||
.col(Collection::MId)
|
||||
.col(Collection::Type)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_favorite_id_bvid")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::CollectionId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
// 在唯一索引中,NULL 不等于 NULL,所以需要使用 ifnull 函数排除空的情况
|
||||
db.execute_unprepared("CREATE UNIQUE INDEX `idx_video_cid_fid_bvid` ON `video` (ifnull(`collection_id`, -1), ifnull(`favorite_id`, -1), `bvid`)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_cid_fid_bvid")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("DELETE FROM video WHERE favorite_id IS NULL")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
// 向存在记录的表中添加非空列时,必须提供默认值
|
||||
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().not_null().default(0))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::CollectionId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.table(Video::Table)
|
||||
.name("idx_video_favorite_id_bvid")
|
||||
.col(Video::FavoriteId)
|
||||
.col(Video::Bvid)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Collection::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Collection {
|
||||
Table,
|
||||
Id,
|
||||
SId,
|
||||
MId,
|
||||
Name,
|
||||
Type,
|
||||
Path,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
FavoriteId,
|
||||
TempFavoriteId,
|
||||
CollectionId,
|
||||
Bvid,
|
||||
}
|
||||
@@ -2,5 +2,5 @@ use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
cli::run_cli(bili_sync_migration::Migrator).await;
|
||||
}
|
||||
100
docs/.vitepress/config.mts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import taskLists from "markdown-it-task-lists";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "bili-sync",
|
||||
description: "由 Rust & Tokio 驱动的哔哩哔哩同步工具",
|
||||
lang: "zh-Hans",
|
||||
sitemap: {
|
||||
hostname: "https://bili-sync.github.io",
|
||||
},
|
||||
lastUpdated: true,
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
themeConfig: {
|
||||
outline: {
|
||||
label: "页面导航",
|
||||
level: "deep",
|
||||
},
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.1.0",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
link: "https://github.com/amtoaer/bili-sync/releases",
|
||||
},
|
||||
{
|
||||
text: "文档更新",
|
||||
link: "https://github.com/search?q=repo:amtoaer/bili-sync+docs&type=commits",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "简介",
|
||||
items: [
|
||||
{ text: "什么是 bili-sync?", link: "/introduction" },
|
||||
{ text: "快速开始", link: "/quick-start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "细节",
|
||||
items: [
|
||||
{ text: "配置文件", link: "/configuration" },
|
||||
{ text: "命令行参数", link: "/args" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "参考",
|
||||
items: [
|
||||
{ text: "获取收藏夹信息", link: "/favorite" },
|
||||
{
|
||||
text: "获取视频合集/视频列表信息",
|
||||
link: "/collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/amtoaer/bili-sync" },
|
||||
],
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
notFound: {
|
||||
title: "你来到了没有知识的荒原",
|
||||
quote: "这里什么都没有",
|
||||
linkText: "返回首页",
|
||||
},
|
||||
docFooter: {
|
||||
prev: "上一页",
|
||||
next: "下一页",
|
||||
},
|
||||
lastUpdated: {
|
||||
text: "上次更新于",
|
||||
},
|
||||
returnToTopLabel: "回到顶部",
|
||||
sidebarMenuLabel: "菜单",
|
||||
darkModeSwitchLabel: "主题",
|
||||
lightModeSwitchTitle: "切换到浅色模式",
|
||||
darkModeSwitchTitle: "切换到深色模式",
|
||||
},
|
||||
markdown: {
|
||||
config: (md) => {
|
||||
md.use(taskLists);
|
||||
},
|
||||
theme: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
},
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/svg+xml", href: "/icon.svg" }],
|
||||
["link", { rel: "icon", type: "image/png", href: "/icon.png" }],
|
||||
],
|
||||
});
|
||||
27
docs/args.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 命令行参数
|
||||
|
||||
程序支持有限的命令行参数,可以通过执行 `bili-sync-rs --help` 查看说明。
|
||||
|
||||
```shell
|
||||
bili-sync/target/debug main* ⇡
|
||||
❯ ./bili-sync-rs --help
|
||||
由 Rust & Tokio 驱动的哔哩哔哩同步工具
|
||||
|
||||
Usage: bili-sync-rs [OPTIONS]
|
||||
|
||||
Options:
|
||||
-s, --scan-only [env: SCAN_ONLY=]
|
||||
-l, --log-level <LOG_LEVEL> [env: RUST_LOG=] [default: None,bili_sync=info]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
可以看到除版本和帮助信息外,程序仅支持两个参数,参数除可以通过命令行设置外,还可通过环境变量设置。
|
||||
|
||||
## `--scan-only`
|
||||
|
||||
`--scan-only` 参数用于仅扫描列表,而不实际执行下载操作。该参数的主要目的是[方便用户从 v1 迁移](https://github.com/amtoaer/bili-sync/issues/66#issuecomment-2066642481),新用户不需要关注。
|
||||
|
||||
## `--log-level`
|
||||
|
||||
`--log-level` 参数用于设置日志级别,一般可以维持默认。该参数与 Rust 程序中 `RUST_LOG` 的语义相同,可以查看[相关文档](https://docs.rs/env_logger/latest/env_logger/#enabling-logging)获取详细信息。
|
||||
BIN
docs/assets/collection.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
docs/assets/detail.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
docs/assets/dir.png
Normal file
|
After Width: | Height: | Size: 1015 KiB |
BIN
docs/assets/favorite.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
docs/assets/overview.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
docs/assets/play.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
docs/assets/season.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
docs/assets/series.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
docs/bun.lockb
Executable file
32
docs/collection.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 获取视频合集/视频列表信息
|
||||
|
||||
要说明的是,视频合集和视频列表虽然在哔哩哔哩网站交互上行为类似,但在接口层级是两个不同的概念。可以简单将视频列表理解为一个老旧版本的视频合集。
|
||||
|
||||
在调试过程中我注意到视频列表的 ID 可以通过某种规则转换为视频合集的 ID,从而成功调用视频合集的接口,但由于不清楚具体的转换策略,在 bili-sync 的实现中还是将其当成两种类型处理。
|
||||
|
||||
## 区分方法
|
||||
|
||||
这两种类型可以很容易地通过如下手段区分:
|
||||
1. 两者的名称前缀不同,视频合集会有显式的“合集”字样
|
||||
2. 两者的图标不同
|
||||
|
||||
如下图所示,“合集【命运方舟全剧情解说】”是视频合集,而“阿拉德冒险记”是视频列表。
|
||||

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

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

|
||||
|
||||
该视频列表的 key 为 `series:521722088:387214`。
|
||||
181
docs/configuration.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 配置文件
|
||||
|
||||
默认的配置文件已经在[快速开始](/quick-start)中给出,该文档对配置文件的各个参数依次详细解释。
|
||||
|
||||
## video_name 与 page_name
|
||||
|
||||
`video_name` 与 `page_name` 用于设置下载文件的命名规则,对于所有下载的内容,将会维持如下的目录结构:
|
||||
|
||||
1. 单页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── {page_name}.mp4
|
||||
│ ├── {page_name}.nfo
|
||||
│ └── {page_name}-poster.jpg
|
||||
```
|
||||
|
||||
2. 多页视频:
|
||||
|
||||
```bash
|
||||
├── {video_name}
|
||||
│ ├── poster.jpg
|
||||
│ ├── Season 1
|
||||
│ │ ├── {page_name} - S01E01.mp4
|
||||
│ │ ├── {page_name} - S01E01.nfo
|
||||
│ │ ├── {page_name} - S01E01-thumb.jpg
|
||||
│ │ ├── {page_name} - S01E02.mp4
|
||||
│ │ ├── {page_name} - S01E02.nfo
|
||||
│ │ └── {page_name} - S01E02-thumb.jpg
|
||||
│ └── tvshow.nfo
|
||||
```
|
||||
|
||||
这两个参数支持使用模板,其中用 <code v-pre>{{ }}</code> 包裹的模板变量在执行时会被动态替换为对应的内容。
|
||||
|
||||
对于 `video_name`,支持设置 bvid(视频编号)、title(视频标题)、upper_name(up 主名称)、upper_mid(up 主 id)。
|
||||
|
||||
对于 `page_name`,除支持 video 的全部参数外,还支持 ptitle(分 P 标题)、pid(分 P 页号)。
|
||||
|
||||
为了解决文件名可能过长的问题,程序为模板引入了 `truncate` 函数。如 <code v-pre>{{ truncate title 10 }}</code> 表示截取 `title` 的前 10 个字符。
|
||||
|
||||
## `interval`
|
||||
|
||||
表示程序每次执行扫描下载的间隔时间,单位为秒。
|
||||
|
||||
## `upper_path`
|
||||
|
||||
UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务器的用户,需确保此处路径指向 Emby、Jellyfin 配置中的 `/metadata/people/` 才能够正常在媒体服务器中显示 UP 主的头像。
|
||||
|
||||
## `nfo_time_type`
|
||||
|
||||
表示在视频信息中使用的时间类型,可选值为 `favtime`(收藏时间)和 `pubtime`(发布时间)。
|
||||
|
||||
视频合集/视频列表不存在 `favtime`,程序实现中将 `favtime` 设置为与 `pubtime` 相同,因此该设置对视频合集/视频列表没有影响。
|
||||
|
||||
## `credential`
|
||||
|
||||
哔哩哔哩账号的身份凭据,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)获取并对应填写至配置文件中,后续 bili-sync 会在必要时自动刷新身份凭据,不再需要手动管理。
|
||||
|
||||
推荐使用匿名窗口获取,避免潜在的冲突。
|
||||
|
||||
## `filter_option`
|
||||
|
||||
过滤选项,用于设置程序的过滤规则,程序会从过滤结果中选择最优的视频、音频流下载。
|
||||
|
||||
这些内容的可选值可前往 [analyzer.rs](https://github.com/amtoaer/bili-sync/blob/24d0da0bf3ea65fd45d07587e4dcdbb24d11a589/crates/bili_sync/src/bilibili/analyzer.rs#L10-L55) 中查看。
|
||||
|
||||
注意将过滤范围设置过小可能导致筛选不到符合要求的流导致下载失败,建议谨慎修改。
|
||||
|
||||
### `video_max_quality`
|
||||
|
||||
视频流允许的最高质量。
|
||||
|
||||
### `video_min_quality`
|
||||
|
||||
视频流允许的最低质量。
|
||||
|
||||
### `audio_max_quality`
|
||||
|
||||
音频流允许的最高质量。
|
||||
|
||||
### `audio_min_quality`
|
||||
|
||||
音频流允许的最低质量。
|
||||
|
||||
### `codecs`
|
||||
|
||||
这是 bili-sync 选择视频编码的优先级顺序,优先级按顺序从高到低。此处对编码格式做一个简单说明:
|
||||
|
||||
+ AVC 又称 H.264,是目前使用最广泛的视频编码格式,绝大部分设备可以使用硬件解码播放该格式的视频(也因此播放普遍流畅),但是同等画质下视频体积较大。
|
||||
|
||||
+ HEV(C) 又称 H.265,与 AV1 都是新一代的视频编码格式。这两种编码相比 AVC 有更好的压缩率,同等画质下视频体积更小,但由于相对较新,硬件解码支持不如 AVC 广泛。如果你的播放设备不支持则只能使用软件解码播放,这种情况下可能导致播放卡顿、机器发热等问题。
|
||||
|
||||
建议查阅自己常用播放设备对这三种编码的硬件解码支持情况以选择合适的编码格式,如果硬件支持 HEV 或 AV1,那么可以将其优先级调高。
|
||||
|
||||
而如果你的设备不支持,或者单纯懒得查询,那么推荐将 AVC 放在第一位以获得最好的兼容性。
|
||||
|
||||
### `no_dolby_video`
|
||||
|
||||
是否禁用杜比视频流。
|
||||
|
||||
### `no_dolby_audio`
|
||||
|
||||
是否禁用杜比音频流。
|
||||
|
||||
### `no_hdr`
|
||||
|
||||
是否禁用 HDR 视频流。
|
||||
|
||||
### `no_hires`
|
||||
|
||||
是否禁用 Hi-Res 音频流。
|
||||
|
||||
## `danmaku_option`
|
||||
|
||||
弹幕的设置选项,用于设置下载弹幕的样式,几乎全部取自[上游仓库](https://github.com/gwy15/danmu2ass)。
|
||||
|
||||
### `duration`
|
||||
|
||||
弹幕在屏幕上的持续时间,单位为秒。
|
||||
|
||||
### `font`
|
||||
|
||||
弹幕的字体。
|
||||
|
||||
### `font_size`
|
||||
|
||||
弹幕的字体大小。
|
||||
|
||||
### `width_ratio`
|
||||
|
||||
计算弹幕宽度的比例,为避免重叠可以调大这个数值。
|
||||
|
||||
### `horizontal_gap`
|
||||
|
||||
两条弹幕之间最小的水平距离。
|
||||
|
||||
### `lane_size`
|
||||
|
||||
弹幕所占据的高度,即“行高度/行间距”。
|
||||
|
||||
### `float_percentage`
|
||||
|
||||
屏幕上滚动弹幕最多高度百分比。
|
||||
|
||||
### `bottom_percentage`
|
||||
|
||||
屏幕上底部弹幕最多高度百分比。
|
||||
|
||||
### `opacity`
|
||||
|
||||
透明度,取值范围为 0-255。透明度可以通过 opacity / 255 计算得到。
|
||||
|
||||
### `bold`
|
||||
|
||||
是否加粗。
|
||||
|
||||
### `outline`
|
||||
|
||||
描边宽度。
|
||||
|
||||
### `time_offset`
|
||||
|
||||
时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒。
|
||||
|
||||
## `favorite_list`
|
||||
|
||||
你想要下载的收藏夹与想要保存的位置。简单示例:
|
||||
```toml
|
||||
3115878158 = "/home/amtoaer/Downloads/bili-sync/测试收藏夹"
|
||||
```
|
||||
收藏夹 ID 的获取方式可以参考[这里](/favorite)。
|
||||
|
||||
## `collection_list`
|
||||
|
||||
你想要下载的视频合集/视频列表与想要保存的位置。注意“视频合集”与“视频列表”是两种不同的类型。在配置文件中需要做区分:
|
||||
```toml
|
||||
"series:387051756:432248" = "/home/amtoaer/Downloads/bili-sync/测试视频列表"
|
||||
"season:1728547:101343" = "/home/amtoaer/Downloads/bili-sync/测试合集"
|
||||
```
|
||||
|
||||
具体说明可以参考[这里](/collection)。
|
||||
5
docs/favorite.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 获取收藏夹信息
|
||||
|
||||
收藏夹的 ID 获取非常简单,在网页端打开自己的收藏夹列表,切换到你想要获取的收藏夹,然后查看 URL 地址栏中的 `fid` 参数内容即可。
|
||||
|
||||

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

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

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

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

|
||||
|
||||
## 功能与路线图
|
||||
|
||||
- [x] 使用用户填写的凭据认证,并在必要时自动刷新
|
||||
- [x] 支持收藏夹与视频列表/视频合集的下载
|
||||
- [x] 自动选择用户设置范围内最优的视频和音频流,并在下载完成后使用 FFmpeg 合并
|
||||
- [x] 使用 Tokio 与 Reqwest,对视频、视频分页进行异步并发下载
|
||||
- [x] 使用媒体服务器支持的文件命名,方便一键作为媒体库导入
|
||||
- [x] 当前轮次下载失败会在下一轮下载时重试,失败次数过多自动丢弃
|
||||
- [x] 使用数据库保存媒体信息,避免对同个视频的多次请求
|
||||
- [x] 打印日志,并在请求出现风控时自动终止,等待下一轮执行
|
||||
- [x] 提供多平台的二进制可执行文件,为 Linux 平台提供了立即可用的 Docker 镜像
|
||||
- [ ] 支持对“稍后再看”内视频的自动扫描与下载
|
||||
- [ ] 下载单个文件时支持断点续传与并发下载
|
||||
12
docs/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"vitepress": "^1.2.3"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
"docs:build": "vitepress build",
|
||||
"docs:preview": "vitepress preview"
|
||||
}
|
||||
}
|
||||
1
docs/public/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
bili-sync.allwens.work
|
||||
BIN
docs/public/icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
1
docs/public/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="24" fill="none" viewBox="0 0 25 24" id="bilibili"><rect width="24" height="24" x=".463" fill="url(#paint0_linear_302_5217)" rx="8"></rect><path fill="#fff" fill-rule="evenodd" d="M8.74933 5.15976C8.53632 4.94675 8.19095 4.94675 7.97794 5.15976C7.76493 5.37277 7.76493 5.71814 7.97794 5.93115L9.41043 7.36363H6.18182C4.97684 7.36363 4 8.34047 4 9.54545V16.8182C4 18.0232 4.97684 19 6.18182 19H7.45455V19.3636C7.45455 19.6649 7.69876 19.9091 8 19.9091C8.30124 19.9091 8.54545 19.6649 8.54545 19.3636V19H15.4545V19.3636C15.4545 19.6649 15.6988 19.9091 16 19.9091C16.3012 19.9091 16.5455 19.6649 16.5455 19.3636V19H17.8182C19.0232 19 20 18.0232 20 16.8182V9.54545C20 8.34047 19.0232 7.36363 17.8182 7.36363H14.5896L16.022 5.93115C16.2351 5.71814 16.2351 5.37277 16.022 5.15976C15.809 4.94675 15.4637 4.94675 15.2507 5.15976L13.0689 7.34158C13.0617 7.34878 13.0547 7.35614 13.048 7.36363H10.952C10.9453 7.35614 10.9383 7.34878 10.9311 7.34158L8.74933 5.15976ZM5.81818 10.2727C5.81818 9.67023 6.3066 9.18182 6.90909 9.18182H17.0909C17.6934 9.18182 18.1818 9.67023 18.1818 10.2727V16.0909C18.1818 16.6934 17.6934 17.1818 17.0909 17.1818H6.90909C6.3066 17.1818 5.81818 16.6934 5.81818 16.0909V10.2727ZM13.6417 12.4328L16.5508 13.1601L16.9036 11.7489L13.9945 11.0217L13.6417 12.4328ZM10.3582 12.4328L7.44911 13.1601L7.09633 11.7489L10.0054 11.0217L10.3582 12.4328ZM12.4866 15.1623C12.4231 15.1108 12.3765 15.0596 12.3468 15.0225C12.2515 14.9042 12.1757 14.7648 12.1011 14.6276C12.0677 14.5663 12.0345 14.5053 12 14.4469C11.9655 14.5053 11.9324 14.5663 11.899 14.6276C11.8244 14.7648 11.7485 14.9041 11.6532 15.0225C11.6235 15.0596 11.5769 15.1108 11.5134 15.1623C11.3883 15.2636 11.2015 15.3636 10.9393 15.3636C10.8537 15.3636 10.7596 15.307 10.6612 15.1607C10.6164 15.094 10.5823 15.0248 10.5592 14.9708C10.5423 14.9312 10.5299 14.8946 10.5299 14.8946L9.83423 15.1068C9.84994 15.1577 9.86943 15.2075 9.89033 15.2564C9.92551 15.3388 9.98022 15.4514 10.0577 15.5666C10.2039 15.7839 10.4886 16.0909 10.9393 16.0909C11.4043 16.0909 11.7479 15.9084 11.9713 15.7273C11.9811 15.7193 11.9907 15.7114 12 15.7035C12.0094 15.7114 12.0189 15.7193 12.0287 15.7273C12.2521 15.9084 12.5957 16.0909 13.0608 16.0909C13.5114 16.0909 13.7961 15.7839 13.9423 15.5666C14.0198 15.4514 14.0745 15.3388 14.1097 15.2564C14.1306 15.2075 14.1501 15.1577 14.1658 15.1068L13.4701 14.8946C13.4701 14.8946 13.4577 14.9312 13.4409 14.9708C13.4178 15.0248 13.3837 15.094 13.3389 15.1607C13.2404 15.307 13.1464 15.3636 13.0608 15.3636C12.7985 15.3636 12.6117 15.2636 12.4866 15.1623Z" clip-rule="evenodd"></path><defs><linearGradient id="paint0_linear_302_5217" x1=".463" x2="24.463" y1="12" y2="12" gradientUnits="userSpaceOnUse"><stop stop-color="#ED6D6B"></stop><stop offset="1" stop-color="#F0B076"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
docs/public/logo.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
168
docs/quick-start.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 快速开始
|
||||
|
||||
程序使用 Rust 编写,不需要 Runtime 且并为各个平台提供了预编译文件,绝大多数情况下是没有使用障碍的。
|
||||
|
||||
## 程序获取
|
||||
|
||||
程序为各个平台提供了预构建的二进制文件,并且打包了 `Linux/amd64` 与 `Linux/arm64` 两个平台的 Docker 镜像。用户可以自行选择使用哪种方式运行。
|
||||
|
||||
### 其一:下载平台二进制文件运行
|
||||
|
||||
> [!CAUTION]
|
||||
> 如果你使用这种方式运行,请确保 FFmpeg 已被正确安装且位于 PATH 中,可通过执行 `ffmpeg` 命令访问。
|
||||
|
||||
在[程序发布页](https://github.com/amtoaer/bili-sync/releases)选择最新版本中对应机器架构的压缩包,解压后会获取一个名为 `bili-sync-rs` 的可执行文件,直接双击执行。
|
||||
|
||||
### 其二: 使用 Docker Compose 运行
|
||||
|
||||
Linux/amd64 与 Linux/arm64 两个平台可直接使用 Docker 或 Docker Compose 运行,此处以 Compose 为例:
|
||||
> 请注意其中的注释,有不清楚的地方可以先继续往下看。
|
||||
|
||||
```yaml
|
||||
services:
|
||||
bili-sync-rs:
|
||||
# 不推荐使用 latest 这种模糊的 tag,最好直接指明版本号
|
||||
image: amtoaer/bili-sync-rs:latest
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
# 该选项请仅在日志终端支持彩色输出时启用,否则日志中可能会出现乱码
|
||||
tty: true
|
||||
# 非必需设置项,推荐设置为宿主机用户的 uid 及 gid (`$uid:$gid`)
|
||||
# 可以执行 `id ${user}` 获取 `user` 用户的 uid 及 gid
|
||||
# 程序下载的所有文件权限将与此处的用户保持一致,不设置默认为 Root
|
||||
user: 1000:1000
|
||||
hostname: bili-sync-rs
|
||||
container_name: bili-sync-rs
|
||||
volumes:
|
||||
- ${你希望存储程序配置的目录}:/app/.config/bili-sync
|
||||
# 还需要有一些其它必要的挂载,包括 up 主信息位置、视频下载位置
|
||||
# 这些目录不是固定的,只需要确保此处的挂载与 bili-sync-rs 的配置文件相匹配
|
||||
# ...
|
||||
# 如果你使用的是群晖系统,请移除最后的 logging 配置,否则会导致日志不显示
|
||||
logging:
|
||||
driver: "local"
|
||||
```
|
||||
|
||||
使用该 compose 文件,执行 `docker compose up -d` 即可运行。
|
||||
|
||||
## 程序配置
|
||||
|
||||
> [!NOTE]
|
||||
> 在 Docker 环境中,`~` 会被展开为 `/app`。
|
||||
|
||||
你是否遇到了程序的 panic?别担心,这是正常情况。
|
||||
|
||||
程序默认会将配置文件存储于 `~/.config/bili-sync/config.toml`,数据库文件存储于 `~/.config/bili-sync/data.sqlite`。
|
||||
|
||||
在启动时程序会尝试加载配置文件,如果发现不存在会新建并写入默认配置。
|
||||
|
||||
获得配置内容后,程序会对其做一次简单的校验,因为默认配置中不包含凭据信息与要下载的收藏夹、视频合集/视频列表,因此程序会拒绝运行而发生 panic。我们只需要在程序生成的默认配置上做一些简单修改即可成功运行。
|
||||
|
||||
当前版本的默认示例文件如下:
|
||||
```toml
|
||||
video_name = "{{title}}"
|
||||
page_name = "{{bvid}}"
|
||||
interval = 1200
|
||||
upper_path = "/Users/amtoaer/Library/Application Support/bili-sync/upper_face"
|
||||
nfo_time_type = "favtime"
|
||||
|
||||
[credential]
|
||||
sessdata = ""
|
||||
bili_jct = ""
|
||||
buvid3 = ""
|
||||
dedeuserid = ""
|
||||
ac_time_value = ""
|
||||
|
||||
[filter_option]
|
||||
video_max_quality = "Quality8k"
|
||||
video_min_quality = "Quality360p"
|
||||
audio_max_quality = "QualityHiRES"
|
||||
audio_min_quality = "Quality64k"
|
||||
codecs = [
|
||||
"AV1",
|
||||
"HEV",
|
||||
"AVC",
|
||||
]
|
||||
no_dolby_video = false
|
||||
no_dolby_audio = false
|
||||
no_hdr = false
|
||||
no_hires = false
|
||||
|
||||
[danmaku_option]
|
||||
duration = 15.0
|
||||
font = "黑体"
|
||||
font_size = 25
|
||||
width_ratio = 1.2
|
||||
horizontal_gap = 20.0
|
||||
lane_size = 32
|
||||
float_percentage = 0.5
|
||||
bottom_percentage = 0.3
|
||||
opacity = 76
|
||||
bold = true
|
||||
outline = 0.8
|
||||
time_offset = 0.0
|
||||
|
||||
[favorite_list]
|
||||
|
||||
[collection_list]
|
||||
```
|
||||
|
||||
看起来很长,但绝大部分选项是不需要做修改的。正常情况下,我们只需要关注:
|
||||
+ `interval`
|
||||
+ `upper_path`
|
||||
+ `credential`
|
||||
+ `codecs`
|
||||
+ `favorite_list`
|
||||
+ `collection_list`
|
||||
|
||||
以下逐条说明。
|
||||
|
||||
### `interval`
|
||||
|
||||
表示程序每次执行扫描下载的间隔时间,单位为秒。
|
||||
|
||||
### `upper_path`
|
||||
|
||||
UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务器的用户,需确保此处路径指向 Emby、Jellyfin 配置中的 `/metadata/people/` 才能够正常在媒体服务器中显示 UP 主的头像。
|
||||
|
||||
### `credential`
|
||||
|
||||
哔哩哔哩账号的身份凭据,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)获取并对应填写至配置文件中,后续 bili-sync 会在必要时自动刷新身份凭据,不再需要手动管理。
|
||||
|
||||
推荐使用匿名窗口获取,避免潜在的冲突。
|
||||
|
||||
### `codecs`
|
||||
|
||||
这是 bili-sync 选择视频编码的优先级顺序,优先级按顺序从高到低。此处对编码格式做一个简单说明:
|
||||
|
||||
+ AVC 又称 H.264,是目前使用最广泛的视频编码格式,绝大部分设备可以使用硬件解码播放该格式的视频(也因此播放普遍流畅),但是同等画质下视频体积较大。
|
||||
|
||||
+ HEV(C) 又称 H.265,与 AV1 都是新一代的视频编码格式。这两种编码相比 AVC 有更好的压缩率,同等画质下视频体积更小,但由于相对较新,硬件解码支持不如 AVC 广泛。如果你的播放设备不支持则只能使用软件解码播放,这种情况下可能导致播放卡顿、机器发热等问题。
|
||||
|
||||
建议查阅自己常用播放设备对这三种编码的硬件解码支持情况以选择合适的编码格式,如果硬件支持 HEV 或 AV1,那么可以将其优先级调高。
|
||||
|
||||
而如果你的设备不支持,或者单纯懒得查询,那么推荐将 AVC 放在第一位以获得最好的兼容性。
|
||||
|
||||
### `favorite_list`
|
||||
|
||||
你想要下载的收藏夹与想要保存的位置。简单示例:
|
||||
```toml
|
||||
3115878158 = "/home/amtoaer/Downloads/bili-sync/测试收藏夹"
|
||||
```
|
||||
收藏夹 ID 的获取方式可以参考[这里](/favorite)。
|
||||
|
||||
### `collection_list`
|
||||
|
||||
你想要下载的视频合集/视频列表与想要保存的位置。注意“视频合集”与“视频列表”是两种不同的类型。在配置文件中需要做区分:
|
||||
```toml
|
||||
"series:387051756:432248" = "/home/amtoaer/Downloads/bili-sync/测试视频列表"
|
||||
"season:1728547:101343" = "/home/amtoaer/Downloads/bili-sync/测试合集"
|
||||
```
|
||||
|
||||
具体说明可以参考[这里](/collection)。
|
||||
|
||||
## 运行
|
||||
|
||||
在配置文件填写完毕后,我们可以直接运行程序。如果配置文件无误,程序会自动开始下载收藏夹中的视频。并每隔 `interval` 秒重新扫描一次。
|
||||
|
||||
如果你希望了解更详细的配置项说明,可以查询[这里](/configuration)。
|
||||
2173
entity/Cargo.lock
generated
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "entity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
sea-orm = { version = "0.12" }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
2395
migration/Cargo.lock
generated
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "0.12.0"
|
||||
features = [
|
||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||
# e.g.
|
||||
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||
# "sqlx-sqlite", # `DATABASE_DRIVER` feature
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
```sh
|
||||
cargo run -- generate MIGRATION_NAME
|
||||
```
|
||||
- Apply all pending migrations
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
- Apply first 10 pending migrations
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
- Rollback last applied migrations
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
- Rollback last 10 applied migrations
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
- Rollback all applied migrations
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
- Check the status of all migrations
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{bail, Result};
|
||||
pub use client::{BiliClient, Client};
|
||||
pub use credential::Credential;
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::{FavoriteList, FavoriteListInfo, VideoInfo};
|
||||
pub use video::{Dimension, PageInfo, Video};
|
||||
|
||||
mod analyzer;
|
||||
mod client;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod video;
|
||||
|
||||
pub(crate) trait Validate {
|
||||
type Output;
|
||||
|
||||
fn validate(self) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl Validate for serde_json::Value {
|
||||
type Output = serde_json::Value;
|
||||
|
||||
fn validate(self) -> Result<Self::Output> {
|
||||
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
if code != 0 {
|
||||
bail!(BiliError::RequestFailed(code, msg.to_owned()));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
133
src/config.rs
@@ -1,133 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||
let config = Config::load().unwrap_or_else(|err| {
|
||||
if err
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.map_or(true, |e| e.kind() != std::io::ErrorKind::NotFound)
|
||||
{
|
||||
panic!("加载配置文件失败,错误为: {err}");
|
||||
}
|
||||
warn!("配置文件不存在,使用默认配置...");
|
||||
Config::new()
|
||||
});
|
||||
// 放到外面,确保新的配置项被保存
|
||||
info!("配置加载完毕,覆盖刷新原有配置");
|
||||
config.save().unwrap();
|
||||
// 检查配置文件内容
|
||||
info!("校验配置文件内容...");
|
||||
config.check();
|
||||
config
|
||||
});
|
||||
|
||||
pub static CONFIG_DIR: Lazy<PathBuf> =
|
||||
Lazy::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub credential: ArcSwapOption<Credential>,
|
||||
pub filter_option: FilterOption,
|
||||
#[serde(default)]
|
||||
pub danmaku_option: DanmakuOption,
|
||||
pub favorite_list: HashMap<String, PathBuf>,
|
||||
pub video_name: Cow<'static, str>,
|
||||
pub page_name: Cow<'static, str>,
|
||||
pub interval: u64,
|
||||
pub upper_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
credential: ArcSwapOption::from(Some(Arc::new(Credential::default()))),
|
||||
filter_option: FilterOption::default(),
|
||||
danmaku_option: DanmakuOption::default(),
|
||||
favorite_list: HashMap::new(),
|
||||
video_name: Cow::Borrowed("{{title}}"),
|
||||
page_name: Cow::Borrowed("{{bvid}}"),
|
||||
interval: 1200,
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的预检查
|
||||
pub fn check(&self) {
|
||||
let mut ok = true;
|
||||
if self.favorite_list.is_empty() {
|
||||
ok = false;
|
||||
error!("未设置需监听的收藏夹,程序空转没有意义");
|
||||
}
|
||||
for path in self.favorite_list.values() {
|
||||
if !path.is_absolute() {
|
||||
ok = false;
|
||||
error!("收藏夹保存的路径应为绝对路径,检测到: {}", 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 !ok {
|
||||
panic!(
|
||||
"位于 {} 的配置文件不合法,请参考提示信息修复后继续运行",
|
||||
CONFIG_DIR.join("config.toml").display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load() -> Result<Self> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
Ok(toml::from_str(&config_content)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let config_path = CONFIG_DIR.join("config.toml");
|
||||
std::fs::create_dir_all(&*CONFIG_DIR)?;
|
||||
std::fs::write(config_path, toml::to_string_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod command;
|
||||
pub mod status;
|
||||
pub mod utils;
|
||||
@@ -1,15 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
pub async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let target = CONFIG_DIR.join("data.sqlite");
|
||||
fs::create_dir_all(&*CONFIG_DIR).await?;
|
||||
Ok(Database::connect(format!("sqlite://{}?mode=rwc", target.to_str().unwrap())).await?)
|
||||
}
|
||||
|
||||
pub async fn migrate_database(connection: &DatabaseConnection) -> Result<()> {
|
||||
Ok(Migrator::up(connection, None).await?)
|
||||
}
|
||||
46
src/main.rs
@@ -1,46 +0,0 @@
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
mod bilibili;
|
||||
mod config;
|
||||
mod core;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
|
||||
use env_logger::Env;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::CONFIG;
|
||||
use crate::core::command::{process_favorite_list, SCAN_ONLY};
|
||||
use crate::database::{database_connection, migrate_database};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ! {
|
||||
env_logger::init_from_env(Env::default().default_filter_or("None,bili_sync=info"));
|
||||
Lazy::force(&SCAN_ONLY);
|
||||
Lazy::force(&CONFIG);
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
let bili_client = BiliClient::new();
|
||||
let connection = database_connection().await.unwrap();
|
||||
migrate_database(&connection).await.unwrap();
|
||||
loop {
|
||||
if anchor != chrono::Local::now().date_naive() {
|
||||
if let Err(e) = bili_client.check_refresh().await {
|
||||
error!("检查刷新 Credential 遇到错误:{e},等待下一轮执行");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(CONFIG.interval)).await;
|
||||
continue;
|
||||
}
|
||||
anchor = chrono::Local::now().date_naive();
|
||||
}
|
||||
for (fid, path) in &CONFIG.favorite_list {
|
||||
if let Err(e) = process_favorite_list(&bili_client, fid, path, &connection).await {
|
||||
// 可预期的错误都被内部处理了,这里漏出来应该是大问题
|
||||
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
|
||||
}
|
||||
}
|
||||
info!("所有收藏夹处理完毕,等待下一轮执行");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(CONFIG.interval)).await;
|
||||
}
|
||||
}
|
||||