Compare commits

...

27 Commits

Author SHA1 Message Date
amtoaer
0b6fd72682 chore: 发布 bili-sync 2.1.0 2024-07-05 22:44:56 +08:00
amtoaer
e65cd36b2e chore: 固定 CNAME 文件,修改 gh-pages 分支的提交信息 2024-07-05 22:35:34 +08:00
ᴀᴍᴛᴏᴀᴇʀ
352282f277 docs: 全局修改描述,在文档中加入版本信息并在发版时自动替换 (#128)
* 暂存

* chore: 修改一些杂项
2024-07-05 22:31:26 +08:00
amtoaer
fa2bc7b5e8 docs: 采用自定义域名方式,移除 base 目录 2024-07-05 18:26:39 +08:00
amtoaer
bb90f0c6f2 docs: 修改文档的 base 目录 2024-07-05 16:58:18 +08:00
amtoaer
90f2a1d4ed docs: 添加在线文档的 workflow 构建流,修复一些问题 2024-07-05 16:45:42 +08:00
amtoaer
e2b65746dd docs: 添加独立的文档页面,移除 README 中的相关描述 (#127) 2024-07-05 02:17:42 +08:00
amtoaer
24d0da0bf3 chore: 修改 as 大小写,避免 warning 2024-07-04 01:49:04 +08:00
ᴀᴍᴛᴏᴀᴇʀ
ff1150e863 fix: 修复重构引入的若干 bug (#126) 2024-07-04 01:00:41 +08:00
amtoaer
940abd4f3b build: 修改现有的版本号,添加 release 相关选项 2024-07-03 22:11:31 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4c9ad2318c feat: 大范围重构,支持视频合集下载 (#97) 2024-07-03 03:57:12 -07:00
ᴀᴍᴛᴏᴀᴇʀ
097f885050 build: 更新依赖 (#125) 2024-06-28 03:07:45 -07:00
ᴀᴍᴛᴏᴀᴇʀ
6ebef0a414 ci: 对处于 draft 状态的 PR 禁用 workflow (#123) 2024-06-28 00:04:30 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4818e62414 refactor: 引入 clap 处理环境变量和命令行参数 (#119) 2024-06-08 10:57:08 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1744f8647b chore: 修改项目路径结构,使用 workspace 组织包 (#118) 2024-06-08 01:56:53 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c4db12b154 fix: 修复类型错误导致的数值溢出 (#115) 2024-06-01 03:21:23 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2ef99a20c9 feat: 支持自定义 NFO 文件中的视频时间,可选加入收藏夹的时间、视频发布的时间 (#114)
* feat: 支持自定义 NFO 文件中的视频时间,可选加入收藏夹的时间、视频发布的时间

* chore: 使用小写
2024-06-01 03:01:39 +08:00
ᴀᴍᴛᴏᴀᴇʀ
67de151234 ci: 使用较旧的 rust nightly 版本,避免语言变更导致的编译失败 (#113) 2024-06-01 01:51:19 +08:00
ᴀᴍᴛᴏᴀᴇʀ
73f97f937f feat: 每次执行前检查登录状态,避免凭据失效导致的非预期行为 (#112)
* feat: 每次执行前检查登录状态,避免凭据失效导致的非预期行为

* refactor: 减少代码长度
2024-06-01 01:46:15 +08:00
ky0utarou
8fee6fb97a Update README.md - compose中指定user,附加简要说明 (#102)
* Update README.md - compose中指定user

* Update README.md - compose中指定user的简要说明
2024-05-08 19:11:32 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e5e5b07978 fix: 修复当目标文件已存在时 ffmpeg 卡住的问题 (#99) 2024-05-05 17:22:35 +08:00
ᴀᴍᴛᴏᴀᴇʀ
cd2bd9cbb3 chore: 减少并发下载量与 read_timeout 值 (#96)
* chore: 减少并发下载量与 read_timeout 值

* chore: 修正注释
2024-05-03 12:48:53 +08:00
ᴀᴍᴛᴏᴀᴇʀ
f044b18337 chore: 使用 tracing 替换 env_logger (#93) 2024-05-02 03:00:16 +08:00
amtoaer
d3bfca42f6 ci: 先安装依赖再 copy 二进制文件,确保使用 docker 缓存 2024-05-02 00:45:47 +08:00
ky0utarou
10ccb47790 ci: Dockerfile - 保留tzdata (#91)
* Dockerfile - keep tzdata for correct time

* Dockerfile - install tzdata only for correct logging time

refer to https://stackoverflow.com/a/68996528
2024-05-01 21:21:22 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e732e7d616 feat: 放宽数据库连接池的连接数和获取时间,避免 time out 错误 (#87) 2024-04-29 13:46:22 +08:00
amtoaer
f81d9fc6eb chore: 修改版本并添加许可证 2024-04-29 00:59:50 +08:00
86 changed files with 2897 additions and 5677 deletions

View File

@@ -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
View 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
View File

@@ -1,6 +1,8 @@
**/target
auth_data
*.sqlite
*.json
video
debug*
node_modules
docs/.vitepress/cache
docs/.vitepress/dist

429
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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

View File

@@ -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; \

View File

@@ -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
View 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
View File

@@ -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 驱动。
## 效果演示
**注:因为可能同时存在单页视频和多页视频,媒体库类型请选择“混合内容”。**
### 概览
![概览](./assets/overview.png)
### 详情
@@ -24,152 +18,21 @@
### 文件排布
![文件](./assets/dir.png)
## 配置文件说明
> [!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_nameup 主名称、upper_midup 主 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) 本项目弹幕下载功能的缝合来源

View 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"

View 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,
);
}
}

View 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
);
}
}

View 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);
}

View File

@@ -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
}
}

View 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);
}
}
}

View File

@@ -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?;

View File

@@ -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,

View 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>,
},
}

View File

@@ -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);
}

View 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,
}

View 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?)
}

View File

@@ -40,6 +40,7 @@ impl Downloader {
audio_path.to_str().unwrap(),
"-c",
"copy",
"-y",
output_path.to_str().unwrap(),
])
.output()

View 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;
}
}

View 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!(),
}
}
}

View 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())
}

View 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(())
}

View File

@@ -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"?>

View File

@@ -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(())
}

View 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 }

View 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 {}

View File

@@ -2,6 +2,7 @@
pub mod prelude;
pub mod collection;
pub mod favorite;
pub mod page;
pub mod video;

View File

@@ -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>,

View File

@@ -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,

View 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 }

View File

@@ -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),
]
}
}

View File

@@ -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,
}

View File

@@ -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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
docs/assets/detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
docs/assets/dir.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

BIN
docs/assets/favorite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
docs/assets/overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

BIN
docs/assets/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
docs/assets/season.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
docs/assets/series.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
docs/bun.lockb Executable file

Binary file not shown.

32
docs/collection.md Normal file
View File

@@ -0,0 +1,32 @@
# 获取视频合集/视频列表信息
要说明的是,视频合集和视频列表虽然在哔哩哔哩网站交互上行为类似,但在接口层级是两个不同的概念。可以简单将视频列表理解为一个老旧版本的视频合集。
在调试过程中我注意到视频列表的 ID 可以通过某种规则转换为视频合集的 ID从而成功调用视频合集的接口但由于不清楚具体的转换策略在 bili-sync 的实现中还是将其当成两种类型处理。
## 区分方法
这两种类型可以很容易地通过如下手段区分:
1. 两者的名称前缀不同,视频合集会有显式的“合集”字样
2. 两者的图标不同
如下图所示,“合集【命运方舟全剧情解说】”是视频合集,而“阿拉德冒险记”是视频列表。
![image](./assets/collection.png)
在 bili-sync 的设计中,视频合集的 key 为 `season:{mid}:{season_id}`,而视频列表的 key 为 `series:{mid}:{series_id}`
## 参数获取
了解了区分方法后,我们可以通过如下步骤获取视频合集/视频列表的信息。
### 视频合集
![image](./assets/season.png)
该视频合集的 key 为 `season:521722088:1987140`
### 视频列表
![image](./assets/series.png)
该视频列表的 key 为 `series:521722088:387214`

181
docs/configuration.md Normal file
View 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_nameup 主名称、upper_midup 主 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
View File

@@ -0,0 +1,5 @@
# 获取收藏夹信息
收藏夹的 ID 获取非常简单,在网页端打开自己的收藏夹列表,切换到你想要获取的收藏夹,然后查看 URL 地址栏中的 `fid` 参数内容即可。
![image](./assets/favorite.png)

58
docs/index.md Normal file
View 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
View File

@@ -0,0 +1,40 @@
# bili-sync 是什么?
> [!TIP]
> 当前最新程序版本为 v2.1.0,文档将始终与最新程序版本保持一致。
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
它的基本的工作原理是使用用户填写的凭据定期扫描视频合集、收藏夹等,获取到本地未下载过的内容并保存到本地,维持本地视频库与哔哩哔哩网站的同步。
下载的内容包括视频、封面、弹幕、标签与简介信息等,这些文件整体保持与 Emby、Jellyfin 等媒体服务器软件兼容的文件布局,使得目的文件夹可以直接被作为媒体库添加到这些软件中,无需干预自动识别。
## 使用截图
> [!WARNING]
> 媒体库类型请选择“混合内容”,否则可能导致多页视频无法正常显示。
### 概览
![概览](/assets/overview.png)
### 详情
![详情](/assets/detail.png)
### 播放(使用 infuse
![播放](/assets/play.png)
### 文件排布
![文件](/assets/dir.png)
## 功能与路线图
- [x] 使用用户填写的凭据认证,并在必要时自动刷新
- [x] 支持收藏夹与视频列表/视频合集的下载
- [x] 自动选择用户设置范围内最优的视频和音频流,并在下载完成后使用 FFmpeg 合并
- [x] 使用 Tokio 与 Reqwest对视频、视频分页进行异步并发下载
- [x] 使用媒体服务器支持的文件命名,方便一键作为媒体库导入
- [x] 当前轮次下载失败会在下一轮下载时重试,失败次数过多自动丢弃
- [x] 使用数据库保存媒体信息,避免对同个视频的多次请求
- [x] 打印日志,并在请求出现风控时自动终止,等待下一轮执行
- [x] 提供多平台的二进制可执行文件,为 Linux 平台提供了立即可用的 Docker 镜像
- [ ] 支持对“稍后再看”内视频的自动扫描与下载
- [ ] 下载单个文件时支持断点续传与并发下载

12
docs/package.json Normal file
View 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
View File

@@ -0,0 +1 @@
bili-sync.allwens.work

BIN
docs/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

1
docs/public/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

168
docs/quick-start.md Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
]

View File

@@ -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
```

View File

@@ -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)
}
}

View File

@@ -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(())
}
}

View File

@@ -1,3 +0,0 @@
pub mod command;
pub mod status;
pub mod utils;

View File

@@ -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?)
}

View File

@@ -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;
}
}