Compare commits

...

113 Commits

Author SHA1 Message Date
amtoaer
e196afa8ce chore: 发布 bili-sync 2.6.0 2025-07-12 19:32:58 +08:00
amtoaer
9b2da75391 chore: 同样为前端加入版本号,并在发版时进行修改 2025-07-12 19:32:15 +08:00
amtoaer
664e1d9f21 docs: 在 readme 中加入管理页 2025-07-12 19:28:10 +08:00
amtoaer
31c26f033e docs: 文档跟进最新代码变化 2025-07-12 19:23:42 +08:00
ᴀᴍᴛᴏᴀᴇʀ
29d78dabdd perf: 优化 dashboard 的查询性能 (#393) 2025-07-12 16:06:16 +08:00
ᴀᴍᴛᴏᴀᴇʀ
87fb597ba4 fix: 修复本地测试发现的若干问题 (#392) 2025-07-12 15:17:54 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c8f7a2267d chore: 更新 rust 依赖 (#391) 2025-07-11 20:44:38 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2837bb5234 feat: WebSocket connect 使用 Promise,确保 sendMessage 发生在 connect 后 (#390) 2025-07-11 20:00:15 +08:00
ᴀᴍᴛᴏᴀᴇʀ
0990a276ff fix: 移动端 sidebar 在点按后自动收起 (#389) 2025-07-11 19:15:13 +08:00
ᴀᴍᴛᴏᴀᴇʀ
adc2e32e58 feat: 重置任务状态时支持 force 参数,默认不启用 (#388) 2025-07-11 19:01:01 +08:00
ᴀᴍᴛᴏᴀᴇʀ
267e9373f9 feat: 加入设置页里缺失的设置项,密码表单允许修改可见性 (#387) 2025-07-11 01:53:03 +08:00
ᴀᴍᴛᴏᴀᴇʀ
dd23d1db58 feat: 事件推送由 SSE 切换到 WebSocket (#386) 2025-07-11 00:14:20 +08:00
ᴀᴍᴛᴏᴀᴇʀ
cc25749445 feat: 前端添加下载状态卡片 (#385) 2025-07-10 15:13:25 +08:00
ᴀᴍᴛᴏᴀᴇʀ
655b4389b7 feat: 支持 "在 b 站打开" 的快捷操作,一些细节优化 (#384) 2025-07-10 01:46:34 +08:00
ᴀᴍᴛᴏᴀᴇʀ
486dab5355 chore: 添加前端压缩 (#383) 2025-07-10 00:03:16 +08:00
ᴀᴍᴛᴏᴀᴇʀ
74a45526f0 fix: 修复日志页面自动滚动问题 (#382) 2025-07-09 23:34:50 +08:00
ᴀᴍᴛᴏᴀᴇʀ
ce60838244 fix: 修复筛选器查询无效 (#381) 2025-07-09 21:50:16 +08:00
ᴀᴍᴛᴏᴀᴇʀ
35866888e8 fix: 新订阅添加后应该默认启用 (#380) 2025-07-08 15:29:57 +08:00
ᴀᴍᴛᴏᴀᴇʀ
fbb7623ee1 fix: 尝试修复下载错误 (#379) 2025-07-08 14:37:31 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1affe4d594 feat: 修改交互逻辑,支持前端查看日志 (#378) 2025-07-08 12:48:51 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7c73a2f01a feat: 添加 dashboard 页面 (#377) 2025-07-07 23:32:46 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a627584fb0 refactor: 根据路径分割 api,避免单文件内容过多 (#376) 2025-07-07 01:51:40 +08:00
ᴀᴍᴛᴏᴀᴇʀ
636a843bda chore: 移除 utoipa (#375) 2025-07-07 01:01:15 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7bb4e7bc44 feat: 前端支持根据 ID 手动添加订阅 (#374) 2025-07-06 22:49:17 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e50318870e feat: 支持前端编辑、提交 Config (#370) 2025-06-18 16:50:16 +08:00
ᴀᴍᴛᴏᴀᴇʀ
28971c3ff3 feat: 添加视频源管理页,支持修改路径与启用状态 (#369) 2025-06-17 18:55:45 +08:00
ᴀᴍᴛᴏᴀᴇʀ
f47ce92a51 chore: 通过移除依赖 debuginfo 的方式加快 debug 构建 (#368) 2025-06-17 13:56:36 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a35794ed7a refactor: 在后端处理字段映射与 invalid 判断 (#367) 2025-06-17 13:44:23 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bad00af147 chore: 移除无用的依赖 (#366) 2025-06-17 02:45:48 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4539e9379d feat: 迁移所有配置到数据库,并支持运行时重载 (#364) 2025-06-17 02:15:11 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a46c2572b1 chore: 为 video sources 添加 enabled 字段 (#362) 2025-06-13 12:00:10 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a41efdbe78 chore: 移除订阅卡片的单行最大宽度限制,支持铺满屏幕 (#359) 2025-06-09 12:17:19 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a98e49347b feat: 支持 webui 加载用户的订阅与收藏,一键点击订阅 (#357) 2025-06-09 11:16:33 +08:00
ᴀᴍᴛᴏᴀᴇʀ
586d5ec4ee chore: 大幅缩减构建结果的二进制文件体积 (#356) 2025-06-06 23:34:46 +08:00
ᴀᴍᴛᴏᴀᴇʀ
65a047b0fa feat: 支持手动编辑某个视频、分页状态,优化部分代码 (#355) 2025-06-06 07:39:17 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c0ed37750f refactor: 固定大小的任务省去装箱,直接使用 tokio::join! (#354) 2025-06-05 16:30:09 +08:00
ᴀᴍᴛᴏᴀᴇʀ
0e98f484ef chore: 前端跑一遍 format、lint,尝试在 ci 中加入前端 lint 检查 (#353) 2025-06-04 21:37:26 +08:00
ᴀᴍᴛᴏᴀᴇʀ
6226fa7c4d fix: 修复一些小问题,优化细节体验 (#352) 2025-06-04 21:15:19 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c528152986 feat: 重构优化部分 API,支持重置全体失败的任务 (#351) 2025-06-04 17:04:15 +08:00
ᴀᴍᴛᴏᴀᴇʀ
45849957ff refactor: 优化填充视频详情时的性能 (#350) 2025-06-02 00:56:02 +08:00
ᴀᴍᴛᴏᴀᴇʀ
8510aa318e feat: 支持获取我的收藏夹、收藏的视频合集与关注的 up 主 (#349) 2025-06-02 00:15:21 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c07e475fe6 chore: 换用更美观、现代的前端页面 (#348) 2025-06-01 13:42:10 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a574d005c3 refactor: 重构 nfo,增强拓展性和可读性,方便后续变更 (#345) 2025-05-30 17:28:42 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e9d1c9eadb refactor: 移除无意义的 bvid 转 aid 逻辑 (#344) 2025-05-30 14:28:14 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a9f604a07d feat: 支持单个文件的并发下载 (#343) 2025-05-30 02:19:23 +08:00
amtoaer
6383730706 ci: 除 fmt 外一律使用 stable toolchain 2025-05-29 01:56:11 +08:00
ᴀᴍᴛᴏᴀᴇʀ
34d3e47b2d refactor: 调整视频列表/视频合集的扫描逻辑,优化性能 (#342) 2025-05-29 01:50:06 +08:00
amtoaer
d7ec0584bc chore: 发布 bili-sync 2.5.1 2025-05-19 22:54:40 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1ec015856b fix: 修复杜比视界合并后变为普通 HDR 的错误 (#333)
* fix: dolby hrd download not correct

* chore: 仅保留对 dolby vision 有效的参数

---------

Co-authored-by: njzydark <njzydark@gmail.com>
2025-05-19 20:57:42 +08:00
amtoaer
99d4d900e6 build: 将 git2 设置为 0.20.2 版本,尝试修复 windows 构建 2025-05-19 18:51:29 +08:00
amtoaer
f85f105e69 refactor: 修改奇怪的 if else 顺序 2025-05-19 17:06:28 +08:00
ᴀᴍᴛᴏᴀᴇʀ
8a1395458c fix: 改进视频流编码判断逻辑 (#332)
* fix: 改进视频流编码判断逻辑

* test: 添加新的单元测试,确保 HDR、杜比视界获取正常
2025-05-19 17:04:48 +08:00
amtoaer
bafb4af8dd chore: 升级依赖,修正新引入的 clippy 规则 2025-05-19 16:53:40 +08:00
amtoaer
f52724b974 chore: 发布 bili-sync 2.5.0 2025-02-27 14:04:40 +08:00
amtoaer
4e1e0c40cf docs: 文档跟进最新代码变化 2025-02-27 14:03:47 +08:00
ᴀᴍᴛᴏᴀᴇʀ
439513e5ab chore: 修改 error 判断,考虑 chain (#291) 2025-02-27 13:39:00 +08:00
ᴀᴍᴛᴏᴀᴇʀ
33a61ec08d fix: 视频合集/视频列表改为全量拉取,确保正确更新 (#290) 2025-02-25 20:55:50 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a6d0d6b777 feat: 下载时考虑 backup_url,支持按照 cdn 优先级排序 (#288) 2025-02-24 19:48:07 +08:00
amtoaer
ae685cbe61 ci: 打 tag 时仅触发 release,跳过 commit 2025-02-21 21:44:36 +08:00
amtoaer
16e14fc371 chore: 发布 bili-sync 2.4.1 2025-02-21 21:22:52 +08:00
amtoaer
b4a5dee236 ci: 使 ci 版本带有版本标签 2025-02-21 21:15:10 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2b3e6f9547 chore: 程序开始时打印欢迎信息,调整日志和构建流 (#285) 2025-02-21 21:04:39 +08:00
ᴀᴍᴛᴏᴀᴇʀ
f8b93d2c76 fix: 修复配置初始化的检测 (#284) 2025-02-21 19:32:46 +08:00
ᴀᴍᴛᴏᴀᴇʀ
94462ca706 chore: 更新 rust edition 到 2024,更新依赖 (#283) 2025-02-21 17:47:49 +08:00
amtoaer
9cbefc26ab chore: 发布 bili-sync 2.4.0 2025-02-19 22:20:39 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2bfd69c15e docs: 文档跟进最新代码变化 (#275) 2025-02-19 22:12:47 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4765d6f50a fix: API TOKEN 输入框应该设置 password 类型 (#274) 2025-02-19 21:22:08 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bf306dfec3 chore: 补上缺失的 error_for_status 调用,修改一个 clippy 格式错误 (#273) 2025-02-19 20:40:40 +08:00
ᴀᴍᴛᴏᴀᴇʀ
a6425f11a2 fix: 修复 video 中分 p 下载状态的设置 (#272) 2025-02-19 19:04:51 +08:00
ᴀᴍᴛᴏᴀᴇʀ
395ef0013a ci: 统一使用 ubuntu 24.04 运行 ci(20.04 将被弃用) (#271) 2025-02-19 17:28:04 +08:00
ᴀᴍᴛᴏᴀᴇʀ
ab0533210f chore: error 会打印更加详细的信息,修正常见错误的判断 (#270) 2025-02-19 16:53:26 +08:00
ᴀᴍᴛᴏᴀᴇʀ
3eb2f0b14d ci: 修复并优化 ci 流程 (#269) 2025-02-19 14:33:47 +08:00
ᴀᴍᴛᴏᴀᴇʀ
42272b1294 ci: 调整构建流,在 commit 时同样构建 binary (#266) 2025-02-19 04:21:33 +08:00
ᴀᴍᴛᴏᴀᴇʀ
d1168f35f3 build: 在 version 中展示详细的构建信息 (#265)
* build: 在 version 中展示详细的构建信息

* chore: 修改
2025-02-19 03:47:01 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bc27778366 chore: 前端支持取消视频来源筛选(点击来源两次),调整 API TOKEN 填写位置 (#264) 2025-02-19 02:18:20 +08:00
ᴀᴍᴛᴏᴀᴇʀ
9c5f3452e9 fix: 修复 reset 执行问题 (#263) 2025-02-19 01:52:18 +08:00
ᴀᴍᴛᴏᴀᴇʀ
d3b4559b2d feat: 加入塑料前端 (#262) 2025-02-19 01:47:09 +08:00
ᴀᴍᴛᴏᴀᴇʀ
59305c0bb4 feat: reset_failed 支持修正标记位,这允许用户手动触发新的子任务 (#261) 2025-02-18 23:36:44 +08:00
ᴀᴍᴛᴏᴀᴇʀ
32214d5d5f chore: 将 video list model / video list 重命名为 video source (#260) 2025-02-18 22:36:25 +08:00
ᴀᴍᴛᴏᴀᴇʀ
315ad13703 feat: 在状态更新时忽略掉一些常见的错误 (#259) 2025-02-18 22:22:29 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e12a9cda95 feat: 加入重置单个视频状态的 API,视频接口返回下载状态 (#258) 2025-02-18 19:24:55 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c995b3bf72 feat: 加入带有详细类型注释的 swagger 文档 (#257) 2025-02-18 01:55:54 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1467c262a1 feat: 添加部分简单 API,相应修改程序入口的初始化流程 (#251) 2025-02-17 16:58:51 +08:00
amtoaer
7251802202 chore: 格式化代码 2025-02-16 03:56:47 +08:00
dragonlanc
e1285ff49a chore: 修改拼写错误 seprate -> separate (#253) 2025-02-16 03:38:19 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e01a22136e refactor: 使用 const 泛型约束 status (#250) 2025-02-13 21:41:05 +08:00
ᴀᴍᴛᴏᴀᴇʀ
eba69ff82a chore: 拆分主函数,支持响应终止信号 (#247)
* chore: 拆分主函数,支持响应 Ctrl + C 信号

* chore: unix 应该处理 SIGTERM
2025-02-12 03:34:17 +08:00
amtoaer
5af6fe5e6e chore: 移除多余的空格 2025-02-12 01:36:08 +08:00
ᴀᴍᴛᴏᴀᴇʀ
9d8e398cbe refactor: 下载部分使用 tokio 的封装代替手动实现 (#245) 2025-02-05 02:33:15 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7097b2a6b9 fix: 修改错误拼写 (#244) 2025-02-05 02:28:52 +08:00
ᴀᴍᴛᴏᴀᴇʀ
acf7359d56 chore: 简化 up 主处理逻辑,支持 up 主信息更新 (#243) 2025-02-04 23:59:51 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7c514b2dcc feat: 将视频的原始 URL 放到简介中 (#241) 2025-02-04 23:25:54 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2c4fa441e7 fix: 等待 task 执行 (#238) 2025-02-01 20:13:58 +08:00
ᴀᴍᴛᴏᴀᴇʀ
51672e8607 chore: 使用 tokio::spawn 运行主任务 (#237) 2025-02-01 18:47:27 +08:00
ᴀᴍᴛᴏᴀᴇʀ
cc7f773300 feat: 支持下载 cc 字幕 (#234) 2025-01-30 01:20:53 +08:00
amtoaer
802565e4f6 chore: 发布 bili-sync 2.3.0 2025-01-25 00:34:47 +08:00
amtoaer
4984026017 docs: 更新文档,跟进最新代码变化 2025-01-25 00:29:12 +08:00
amtoaer
2a98359085 chore: 隐藏 target 并调整表述,缩减日志长度 2025-01-25 00:11:22 +08:00
amtoaer
979294bb94 fix: 修复 video path 未正确设置问题 2025-01-24 14:05:16 +08:00
ᴀᴍᴛᴏᴀᴇʀ
40cf22a7fa refactor: 引入 enum_dispatch 静态分发,提升性能 (#232) 2025-01-24 13:44:27 +08:00
ᴀᴍᴛᴏᴀᴇʀ
9e5a8b0573 feat: 确保 video stream 在出现错误时返回 Err (#231) 2025-01-24 13:17:12 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7c220f0d2b refactor: 精简代码,统一逻辑 (#229) 2025-01-24 01:11:59 +08:00
amtoaer
aa88f97eff refactor: 尝试将任务处理部分重构为 stream 写法,增补注释 2025-01-23 17:13:51 +08:00
ᴀᴍᴛᴏᴀᴇʀ
b4177d4ffc feat: 引入更健壮的新视频检测方法 (#228)
* feat: 为各个 video list 表添加 latest_row_at 字段

* chore: 为 model 引入新增的字段

* feat: 实现新版中断条件(待测试)

* test: 更新测试
2025-01-22 23:53:18 +08:00
amtoaer
b888db6a61 refactor: 数据块已经在内存中,直接使用 write_all 2025-01-22 01:52:32 +08:00
amtoaer
6ae87364b4 feat: 为下载加入 flush 与 content-length 检查 2025-01-22 00:18:04 +08:00
amtoaer
18c966a0f9 refactor: 避免一些不必要的 to_string 2025-01-21 22:59:16 +08:00
amtoaer
ab84a8dad1 refactor: 签名时按需使用 String 2025-01-21 22:54:20 +08:00
amtoaer
1a32e38dc3 refactor: 使用 context 代替 ok_or 和 ok_or_else 2025-01-21 18:06:54 +08:00
amtoaer
0f25923c52 refactor: 继续调整优化部分代码,移除主体代码的所有 unwrap 2025-01-21 17:17:14 +08:00
amtoaer
cdc30e1b32 refactor: 优化部分代码,移除一批 unwrap 2025-01-21 03:12:45 +08:00
NKDark
c10c14c125 chore: 修改配置文件写入逻辑 (#222) 2025-01-21 01:39:48 +08:00
amtoaer
60604aeb33 docs: 更新文档描述,简化视频合集/视频列表的配置 2025-01-17 17:53:32 +08:00
304 changed files with 18003 additions and 3216 deletions

View File

@@ -1,24 +1,52 @@
name: Build Binary And Release
name: Build Binary
on:
push:
tags:
- v*
workflow_call:
jobs:
build-frontend:
name: Build frontend
runs-on: ubuntu-24.04
defaults:
run:
working-directory: web
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Build Frontend
run: bun run build
- name: Upload Web Build Artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: web/build
build:
name: Release for ${{ matrix.platform.release_for }}
name: Build bili-sync-rs for ${{ matrix.platform.release_for }}
needs: build-frontend
runs-on: ${{ matrix.platform.os }}
strategy:
matrix:
platform:
- release_for: Linux-x86_64
os: ubuntu-20.04
os: ubuntu-24.04
target: x86_64-unknown-linux-musl
bin: bili-sync-rs
name: bili-sync-rs-Linux-x86_64-musl.tar.gz
- release_for: Linux-aarch64
os: ubuntu-20.04
os: ubuntu-24.04
target: aarch64-unknown-linux-musl
bin: bili-sync-rs
name: bili-sync-rs-Linux-aarch64-musl.tar.gz
@@ -37,10 +65,16 @@ jobs:
target: x86_64-pc-windows-msvc
bin: bili-sync-rs.exe
name: bili-sync-rs-Windows-x86_64.zip
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download Web Build Artifact
uses: actions/download-artifact@v4
with:
name: web-build
path: web/build
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Install musl-tools
@@ -57,7 +91,6 @@ jobs:
- name: Package as archive
shell: bash
run: |
cp target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} ${{ matrix.platform.release_for }}-${{ matrix.platform.bin }}
cd target/${{ matrix.platform.target }}/release
if [[ "${{ matrix.platform.target }}" == "x86_64-pc-windows-msvc" ]]; then
7z a ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
@@ -68,62 +101,5 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: bili-sync-rs-${{ matrix.platform.release_for }}
# contains raw binary and compressed archive
path: |
${{ github.workspace }}/${{ matrix.platform.release_for }}-${{ matrix.platform.bin }}
${{ github.workspace }}/${{ matrix.platform.name }}
release:
name: Create GitHub Release & Docker Image
needs: build
runs-on: ubuntu-20.04
permissions:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Download release artifact
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
files: bili-sync-rs*
tag_name: ${{ github.ref_name }}
draft: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync-rs
tags: |
type=raw,value=latest
type=raw,value=${{ github.ref_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
- name: Update DockerHub description
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync-rs

View File

@@ -1,15 +1,16 @@
name: Build Docs
name: Build Main Docs
on:
push:
branches:
- main
paths:
- 'docs/**'
jobs:
doc:
if: ${{ github.ref == 'refs/heads/main' }}
name: Build documentation
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
defaults:
run:
working-directory: docs

View File

@@ -1,43 +0,0 @@
name: Check
on:
push:
branches:
- main
pull_request:
types: ['opened', 'reopened', 'synchronize', 'ready_for_review']
concurrency:
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
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 default nightly && rustup component add rustfmt clippy
- name: Cache dependencies
uses: swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: cargo fmt check
run: cargo fmt --check
- name: cargo clippy
run: cargo clippy
- name: cargo test
run: cargo test

11
.github/workflows/commit-build.yaml vendored Normal file
View File

@@ -0,0 +1,11 @@
name: Build Main Binary
on:
push:
branches:
- main
jobs:
build-binary:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: amtoaer/bili-sync/.github/workflows/build-binary.yaml@main

68
.github/workflows/pr-check.yaml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Check
on:
push:
branches:
- main
pull_request:
types: ["opened", "reopened", "synchronize", "ready_for_review"]
concurrency:
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
jobs:
check-backend:
name: Run backend checks
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
- run: rustup default stable && rustup component add clippy && rustup component add rustfmt --toolchain nightly
- name: Cache dependencies
uses: swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: cargo fmt check
run: cargo +nightly fmt --check
- name: cargo clippy
run: cargo clippy
- name: cargo test
run: cargo test
check-frontend:
name: Run frontend checks
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
defaults:
run:
working-directory: web
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Check Frontend
run: bun run lint

78
.github/workflows/release-build.yaml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Build Main Binary And Release
on:
push:
tags:
- v*
jobs:
build-binary:
uses: amtoaer/bili-sync/.github/workflows/build-binary.yaml@main
github-release:
name: Create GitHub Release
needs: build-binary
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Download release artifact
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
files: bili-sync-rs*
tag_name: ${{ github.ref_name }}
draft: true
docker-release:
name: Create Docker Image
needs: build-binary
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Download release artifact
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync-rs
tags: |
type=raw,value=latest
type=raw,value=${{ github.ref_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
- name: Update DockerHub description
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync-rs

1
.gitignore vendored
View File

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

1663
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,63 +4,79 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.2.0"
version = "2.6.0"
authors = ["amtoaer <amtoaer@gmail.com>"]
license = "MIT"
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
edition = "2021"
edition = "2024"
publish = false
[workspace.dependencies]
bili_sync_entity = { path = "crates/bili_sync_entity" }
bili_sync_migration = { path = "crates/bili_sync_migration" }
anyhow = { version = "1.0.95", features = ["backtrace"] }
anyhow = { version = "1.0.98", features = ["backtrace"] }
arc-swap = { version = "1.7.1", features = ["serde"] }
async-std = { version = "1.13.0", features = ["attributes", "tokio1"] }
assert_matches = "1.5.0"
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
async-stream = "0.3.6"
async-trait = "0.1.85"
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.26", features = ["env"] }
async-trait = "0.1.88"
axum = { version = "0.8.4", features = ["macros", "ws"] }
base64 = "0.22.1"
built = { version = "0.7.7", features = ["git2", "chrono"] }
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5.41", features = ["env", "string"] }
cookie = "0.18.1"
cow-utils = "0.1.3"
dashmap = "6.1.0"
dirs = "6.0.0"
enum_dispatch = "0.3.13"
float-ord = "0.3.2"
futures = "0.3.31"
handlebars = "6.3.0"
git2 = { version = "0.20.2", features = [], default-features = false }
handlebars = "6.3.2"
hex = "0.4.3"
leaky-bucket = "1.1.2"
md5 = "0.7.0"
memchr = "2.7.4"
once_cell = "1.20.2"
prost = "0.13.4"
quick-xml = { version = "0.37.2", features = ["async-tokio"] }
rand = "0.8.5"
md5 = "0.8.0"
memchr = "2.7.5"
once_cell = "1.21.3"
parking_lot = "0.12.4"
prost = "0.14.1"
quick-xml = { version = "0.38.0", features = ["async-tokio"] }
rand = "0.9.1"
regex = "1.11.1"
reqwest = { version = "0.12.12", features = [
"charset",
"cookies",
"gzip",
"http2",
"json",
"rustls-tls",
"stream",
reqwest = { version = "0.12.22", features = [
"charset",
"cookies",
"gzip",
"http2",
"json",
"rustls-tls",
"stream",
], default-features = false }
rsa = { version = "0.9.7", features = ["sha2"] }
sea-orm = { version = "1.1.4", features = [
"macros",
"runtime-tokio-rustls",
"sqlx-sqlite",
rsa = { version = "0.10.0-rc.3", features = ["sha2"] }
rust-embed-for-web = { git = "https://github.com/amtoaer/rust-embed-for-web", tag = "v1.0.0" }
sea-orm = { version = "1.1.13", features = [
"macros",
"runtime-tokio-rustls",
"sqlx-sqlite",
] }
sea-orm-migration = { version = "1.1.4", features = [] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
sea-orm-migration = { version = "1.1.13", features = [] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_urlencoded = "0.7.1"
strum = { version = "0.26.3", features = ["derive"] }
thiserror = "2.0.11"
tokio = { version = "1.43.0", features = ["full"] }
toml = "0.8.19"
strum = { version = "0.27.1", features = ["derive"] }
sysinfo = "0.36.0"
thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] }
tokio-stream = { version = "0.1.17", features = ["sync"] }
tokio-util = { version = "0.7.15", features = ["io", "rt"] }
toml = "0.9.1"
tower = "0.5.2"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["chrono"] }
tracing-subscriber = { version = "0.3.19", features = ["chrono", "json"] }
uuid = { version = "1.17.0", features = ["v4"] }
validator = { version = "0.20.0", features = ["derive"] }
[workspace.metadata.release]
release = false
@@ -69,10 +85,14 @@ 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 },
{ file = "../../docs/.vitepress/config.mts", search = "\"v[0-9\\.]+\"", replace = "\"v{{version}}\"", exactly = 1 },
{ file = "../../docs/introduction.md", search = " v[0-9\\.]+", replace = " v{{version}}", exactly = 1 },
{ file = "../../web/package.json", search = "\"version\": \"[0-9\\.]+\"", replace = "\"version\": \"{{version}}\"", exactly = 1 },
]
[profile.dev.package."*"]
debug = false
[profile.release]
strip = true
lto = "thin"

View File

@@ -9,12 +9,12 @@ RUN apk update && apk add --no-cache \
tzdata \
ffmpeg
COPY ./*-bili-sync-rs ./targets/
COPY ./bili-sync-rs-Linux-*.tar.gz ./targets/
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
mv ./targets/Linux-x86_64-bili-sync-rs ./bili-sync-rs; \
tar xzvf ./targets/bili-sync-rs-Linux-x86_64-musl.tar.gz -C ./; \
else \
mv ./targets/Linux-aarch64-bili-sync-rs ./bili-sync-rs; \
tar xzvf ./targets/bili-sync-rs-Linux-aarch64-musl.tar.gz -C ./; \
fi
RUN rm -rf ./targets && chmod +x ./bili-sync-rs

View File

@@ -1,21 +1,24 @@
clean:
rm -rf ./*-bili-sync-rs
rm -rf ./bili-sync-rs-Linux*.tar.gz
build:
build-frontend:
cd ./web && bun run build && cd ..
build: build-frontend
cargo build --target x86_64-unknown-linux-musl --release
build-debug: build-frontend
cargo build --target x86_64-unknown-linux-musl
build-docker: build
cp target/x86_64-unknown-linux-musl/release/bili-sync-rs ./Linux-x86_64-bili-sync-rs
tar czvf ./bili-sync-rs-Linux-x86_64-musl.tar.gz -C ./target/x86_64-unknown-linux-musl/release/ ./bili-sync-rs
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
just clean
copy-config:
rm -rf ~/.config/bili-sync
cp -r ~/.config/nas/bili-sync-rs ~/.config/bili-sync
sed -i -e 's/\/Bilibilis/\/Test_Bilibilis/g' -e 's/.config\/nas/.config\/test_nas/g' ~/.config/bili-sync/config.toml
build-docker-debug: build-debug
tar czvf ./bili-sync-rs-Linux-x86_64-musl.tar.gz -C ./target/x86_64-unknown-linux-musl/debug/ ./bili-sync-rs
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
just clean
run:
debug: build-frontend
cargo run
debug: copy-config
just run

View File

@@ -9,10 +9,12 @@ bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust
## 效果演示
### 概览
![概览](./assets/overview.webp)
### 详情
![详情](./assets/detail.webp)
### 管理页
![管理页](/assets/webui.webp)
### 媒体库概览
![媒体库概览](./assets/overview.webp)
### 媒体库详情
![媒体库详情](./assets/detail.webp)
### 播放(使用 infuse
![播放](./assets/play.webp)
### 文件排布
@@ -33,7 +35,8 @@ bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust
- [x] 支持对“稍后再看”内视频的自动扫描与下载
- [x] 支持对 UP 主投稿视频的自动扫描与下载
- [x] 支持限制任务的并行度和接口请求频率
- [ ] 下载单个文件时支持断点续传与并发下载
- [x] 支持单个文件的分块并行下载
- [x] 支持使用 Web UI 配置,查看并管理视频、视频源
## 参考与借鉴

BIN
assets/webui.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -7,18 +7,23 @@ license = { workspace = true }
description = { workspace = true }
publish = { workspace = true }
readme = "../../README.md"
build = "build.rs"
[dependencies]
anyhow = { workspace = true }
arc-swap = { workspace = true }
async-stream = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
base64 = { workspace = true }
bili_sync_entity = { workspace = true }
bili_sync_migration = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
cookie = { workspace = true }
cow-utils = { workspace = true }
dashmap = { workspace = true }
dirs = { workspace = true }
enum_dispatch = { workspace = true }
float-ord = { workspace = true }
futures = { workspace = true }
handlebars = { workspace = true }
@@ -27,22 +32,37 @@ leaky-bucket = { workspace = true }
md5 = { workspace = true }
memchr = { workspace = true }
once_cell = { workspace = true }
parking_lot = { workspace = true }
prost = { workspace = true }
quick-xml = { workspace = true }
rand = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
rsa = { workspace = true }
rust-embed-for-web = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
toml = { workspace = true }
tower = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
validator = { workspace = true }
[dev-dependencies]
assert_matches = { workspace = true }
[build-dependencies]
built = { workspace = true }
git2 = { workspace = true }
[package.metadata.release]
release = true

View File

@@ -0,0 +1,3 @@
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
}

View File

@@ -1,203 +1,108 @@
use std::collections::HashSet;
use std::borrow::Cow;
use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use async_trait::async_trait;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::*;
use chrono::Utc;
use futures::Stream;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{IntoCondition, OnConflict};
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, TransactionTrait};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::SimpleExpr;
use sea_orm::{DatabaseConnection, Unchanged};
use crate::adapter::{helper, VideoListModel};
use crate::bilibili::{self, BiliClient, Collection, CollectionItem, CollectionType, VideoInfo};
use crate::utils::status::Status;
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, VideoInfo};
#[async_trait]
impl VideoListModel for collection::Model {
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
helper::count_videos(video::Column::CollectionId.eq(self.id).into_condition(), connection).await
impl VideoSource for collection::Model {
fn display_name(&self) -> Cow<'static, str> {
format!("{}{}", CollectionType::from(self.r#type), self.name).into()
}
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
helper::filter_videos(
video::Column::CollectionId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.eq(0))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_null())
.into_condition(),
connection,
)
.await
fn filter_expr(&self) -> SimpleExpr {
video::Column::CollectionId.eq(self.id)
}
async fn unhandled_video_pages(
&self,
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
helper::filter_videos_with_pages(
video::Column::CollectionId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.lt(Status::handled()))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.into_condition(),
connection,
)
.await
}
async fn exist_labels(
&self,
videos_info: &[VideoInfo],
connection: &DatabaseConnection,
) -> Result<HashSet<String>> {
helper::video_keys(
video::Column::CollectionId.eq(self.id),
videos_info,
[video::Column::Bvid, video::Column::Pubtime],
connection,
)
.await
}
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
let mut video_model = video_info.to_model(base_model);
fn set_relation_id(&self, video_model: &mut video::ActiveModel) {
video_model.collection_id = Set(Some(self.id));
helper::video_with_path(video_model, &self.path, video_info)
}
async fn fetch_videos_detail(
fn path(&self) -> &Path {
Path::new(self.path.as_str())
}
fn get_latest_row_at(&self) -> DateTime {
self.latest_row_at
}
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel {
_ActiveModel::Collection(collection::ActiveModel {
id: Unchanged(self.id),
latest_row_at: Set(datetime),
..Default::default()
})
}
fn should_take(&self, _release_datetime: &chrono::DateTime<Utc>, _latest_row_at: &chrono::DateTime<Utc>) -> bool {
// collection视频合集/视频列表)返回的内容似乎并非严格按照时间排序,并且不同 collection 的排序方式也不同
// 为了保证程序正确性collection 不根据时间提前 break而是每次都全量拉取
true
}
fn should_filter(
&self,
video: bilibili::Video<'_>,
video_model: video::Model,
connection: &DatabaseConnection,
) -> Result<()> {
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
match info {
Ok((tags, view_info)) => {
let VideoInfo::View { pages, .. } = &view_info else {
unreachable!("view_info must be VideoInfo::View")
};
let txn = connection.begin().await?;
// 将分页信息写入数据库
helper::create_video_pages(pages, &video_model, &txn).await?;
// 将页标记和 tag 写入数据库
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
video_active_model.single_page = Set(Some(pages.len() == 1));
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
video_active_model.save(&txn).await?;
txn.commit().await?;
video_info: Result<VideoInfo, anyhow::Error>,
latest_row_at: &chrono::DateTime<Utc>,
) -> Option<VideoInfo> {
// 由于 collection 的视频无固定时间顺序should_take 无法提前中断拉取,因此 should_filter 环节需要进行额外过滤
if let Ok(video_info) = video_info {
if video_info.release_datetime() > latest_row_at {
return Some(video_info);
}
Err(e) => {
helper::error_fetch_video_detail(e, video_model, connection).await?;
}
};
Ok(())
}
None
}
fn log_fetch_video_start(&self) {
info!(
"开始获取{} {} - {} 的视频与分页信息...",
CollectionType::from(self.r#type),
self.s_id,
self.name
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
connection: &'a DatabaseConnection,
) -> Result<(
VideoSourceEnum,
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
)> {
let collection = Collection::new(
bili_client,
CollectionItem {
sid: self.s_id.to_string(),
mid: self.m_id.to_string(),
collection_type: CollectionType::from(self.r#type),
},
);
}
fn log_fetch_video_end(&self) {
info!(
"获取{} {} - {} 的视频与分页信息完成",
CollectionType::from(self.r#type),
self.s_id,
self.name
let collection_info = collection.get_info().await?;
ensure!(
collection_info.sid == self.s_id
&& collection_info.mid == self.m_id
&& collection_info.collection_type == CollectionType::from(self.r#type),
"collection info mismatch: {:?} != {:?}",
collection_info,
collection.collection
);
}
fn log_download_video_start(&self) {
info!(
"开始下载{}: {} - {} 中所有未处理过的视频...",
CollectionType::from(self.r#type),
self.s_id,
self.name
);
}
fn log_download_video_end(&self) {
info!(
"下载{}: {} - {} 中未处理过的视频完成",
CollectionType::from(self.r#type),
self.s_id,
self.name
);
}
fn log_refresh_video_start(&self) {
info!(
"开始扫描{}: {} - {} 的新视频...",
CollectionType::from(self.r#type),
self.s_id,
self.name
);
}
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
info!(
"扫描{}: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
CollectionType::from(self.r#type),
self.s_id,
self.name,
got_count,
new_count,
);
}
}
pub(super) async fn collection_from<'a>(
collection_item: &'a CollectionItem,
path: &Path,
bili_client: &'a BiliClient,
connection: &DatabaseConnection,
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
let collection = Collection::new(bili_client, collection_item);
let collection_info = collection.get_info().await?;
collection::Entity::insert(collection::ActiveModel {
s_id: Set(collection_info.sid),
m_id: Set(collection_info.mid),
r#type: Set(collection_info.collection_type.into()),
name: Set(collection_info.name.clone()),
path: Set(path.to_string_lossy().to_string()),
..Default::default()
})
.on_conflict(
OnConflict::columns([
collection::Column::SId,
collection::Column::MId,
collection::Column::Type,
])
.update_columns([collection::Column::Name, collection::Column::Path])
.to_owned(),
)
.exec(connection)
.await?;
Ok((
Box::new(
collection::ActiveModel {
id: Unchanged(self.id),
name: Set(collection_info.name.clone()),
..Default::default()
}
.save(connection)
.await?;
Ok((
collection::Entity::find()
.filter(
collection::Column::SId
.eq(collection_item.sid.clone())
.and(collection::Column::MId.eq(collection_item.mid.clone()))
.and(collection::Column::Type.eq(Into::<i32>::into(collection_item.collection_type.clone()))),
)
.filter(collection::Column::Id.eq(self.id))
.one(connection)
.await?
.unwrap(),
),
Box::pin(collection.into_simple_video_stream()),
))
.context("collection not found")?
.into(),
Box::pin(collection.into_video_stream()),
))
}
}

View File

@@ -1,160 +1,78 @@
use std::collections::HashSet;
use std::borrow::Cow;
use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use async_trait::async_trait;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{IntoCondition, OnConflict};
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, TransactionTrait};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::SimpleExpr;
use sea_orm::{DatabaseConnection, Unchanged};
use crate::adapter::{helper, VideoListModel};
use crate::bilibili::{self, BiliClient, FavoriteList, VideoInfo};
use crate::utils::status::Status;
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
use crate::bilibili::{BiliClient, FavoriteList, VideoInfo};
#[async_trait]
impl VideoListModel for favorite::Model {
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
helper::count_videos(video::Column::FavoriteId.eq(self.id).into_condition(), connection).await
impl VideoSource for favorite::Model {
fn display_name(&self) -> Cow<'static, str> {
format!("收藏夹「{}", self.name).into()
}
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
helper::filter_videos(
video::Column::FavoriteId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.eq(0))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_null())
.into_condition(),
connection,
)
.await
fn filter_expr(&self) -> SimpleExpr {
video::Column::FavoriteId.eq(self.id)
}
async fn unhandled_video_pages(
&self,
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
helper::filter_videos_with_pages(
video::Column::FavoriteId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.lt(Status::handled()))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.into_condition(),
connection,
)
.await
}
async fn exist_labels(
&self,
videos_info: &[VideoInfo],
connection: &DatabaseConnection,
) -> Result<HashSet<String>> {
helper::video_keys(
video::Column::FavoriteId.eq(self.id),
videos_info,
[video::Column::Bvid, video::Column::Favtime],
connection,
)
.await
}
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
let mut video_model = video_info.to_model(base_model);
fn set_relation_id(&self, video_model: &mut video::ActiveModel) {
video_model.favorite_id = Set(Some(self.id));
helper::video_with_path(video_model, &self.path, video_info)
}
async fn fetch_videos_detail(
&self,
video: bilibili::Video<'_>,
video_model: video::Model,
connection: &DatabaseConnection,
) -> Result<()> {
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
match info {
Ok((tags, pages_info)) => {
let txn = connection.begin().await?;
// 将分页信息写入数据库
helper::create_video_pages(&pages_info, &video_model, &txn).await?;
// 将页标记和 tag 写入数据库
let mut video_active_model: video::ActiveModel = video_model.into();
video_active_model.single_page = Set(Some(pages_info.len() == 1));
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
video_active_model.save(&txn).await?;
txn.commit().await?;
}
Err(e) => {
helper::error_fetch_video_detail(e, video_model, connection).await?;
}
};
Ok(())
fn path(&self) -> &Path {
Path::new(self.path.as_str())
}
fn log_fetch_video_start(&self) {
info!("开始获取收藏夹 {} - {} 的视频与分页信息...", self.f_id, self.name);
fn get_latest_row_at(&self) -> DateTime {
self.latest_row_at
}
fn log_fetch_video_end(&self) {
info!("获取收藏夹 {} - {} 的视频与分页信息完成", self.f_id, self.name);
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel {
_ActiveModel::Favorite(favorite::ActiveModel {
id: Unchanged(self.id),
latest_row_at: Set(datetime),
..Default::default()
})
}
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
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
connection: &'a DatabaseConnection,
) -> Result<(
VideoSourceEnum,
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
)> {
let favorite = FavoriteList::new(bili_client, self.f_id.to_string());
let favorite_info = favorite.get_info().await?;
ensure!(
favorite_info.id == self.f_id,
"favorite id mismatch: {} != {}",
favorite_info.id,
self.f_id
);
}
}
pub(super) async fn favorite_from<'a>(
fid: &str,
path: &Path,
bili_client: &'a BiliClient,
connection: &DatabaseConnection,
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
let favorite = FavoriteList::new(bili_client, fid.to_owned());
let favorite_info = favorite.get_info().await?;
favorite::Entity::insert(favorite::ActiveModel {
f_id: Set(favorite_info.id),
name: Set(favorite_info.title.clone()),
path: Set(path.to_string_lossy().to_string()),
..Default::default()
})
.on_conflict(
OnConflict::column(favorite::Column::FId)
.update_columns([favorite::Column::Name, favorite::Column::Path])
.to_owned(),
)
.exec(connection)
.await?;
Ok((
Box::new(
favorite::ActiveModel {
id: Unchanged(self.id),
name: Set(favorite_info.title.clone()),
..Default::default()
}
.save(connection)
.await?;
Ok((
favorite::Entity::find()
.filter(favorite::Column::FId.eq(favorite_info.id))
.filter(favorite::Column::Id.eq(self.id))
.one(connection)
.await?
.unwrap(),
),
Box::pin(favorite.into_video_stream()),
))
.context("favorite not found")?
.into(),
Box::pin(favorite.into_video_stream()),
))
}
}

View File

@@ -1,138 +0,0 @@
use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use bili_sync_entity::*;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{OnConflict, SimpleExpr};
use sea_orm::ActiveValue::Set;
use sea_orm::{Condition, DatabaseTransaction, QuerySelect};
use crate::bilibili::{BiliError, PageInfo, VideoInfo};
use crate::config::{PathSafeTemplate, TEMPLATE};
use crate::utils::id_time_key;
/// 使用 condition 筛选视频,返回视频数量
pub(super) async fn count_videos(condition: Condition, conn: &DatabaseConnection) -> Result<u64> {
Ok(video::Entity::find().filter(condition).count(conn).await?)
}
/// 使用 condition 筛选视频,返回视频列表
pub(super) async fn filter_videos(condition: Condition, conn: &DatabaseConnection) -> Result<Vec<video::Model>> {
Ok(video::Entity::find().filter(condition).all(conn).await?)
}
/// 使用 condition 筛选视频,返回视频列表和相关的分 P 列表
pub(super) async fn filter_videos_with_pages(
condition: Condition,
conn: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
Ok(video::Entity::find()
.filter(condition)
.find_with_related(page::Entity)
.all(conn)
.await?)
}
/// 返回 videos_info 存在于视频表里那部分对应的 key
pub(super) async fn video_keys(
expr: SimpleExpr,
videos_info: &[VideoInfo],
columns: [video::Column; 2],
conn: &DatabaseConnection,
) -> Result<HashSet<String>> {
Ok(video::Entity::find()
.filter(
video::Column::Bvid
.is_in(videos_info.iter().map(|v| v.bvid().to_string()))
.and(expr),
)
.select_only()
.columns(columns)
.into_tuple()
.all(conn)
.await?
.into_iter()
.map(|(bvid, time)| id_time_key(&bvid, &time))
.collect())
}
/// 返回设置了 path 的视频
pub(super) fn video_with_path(
mut video_model: video::ActiveModel,
base_path: &str,
video_info: &VideoInfo,
) -> video::ActiveModel {
if let Some(fmt_args) = &video_info.to_fmt_args() {
video_model.path = Set(Path::new(base_path)
.join(TEMPLATE.path_safe_render("video", fmt_args).unwrap())
.to_string_lossy()
.to_string());
}
video_model
}
/// 处理获取视频详细信息失败的情况
pub(super) async fn error_fetch_video_detail(
e: anyhow::Error,
video_model: bili_sync_entity::video::Model,
connection: &DatabaseConnection,
) -> Result<()> {
error!(
"获取视频 {} - {} 的详细信息失败,错误为:{}",
&video_model.bvid, &video_model.name, e
);
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
video_active_model.valid = Set(false);
video_active_model.save(connection).await?;
}
Ok(())
}
/// 创建视频的所有分 P
pub(crate) async fn create_video_pages(
pages_info: &[PageInfo],
video_model: &video::Model,
connection: &DatabaseTransaction,
) -> Result<()> {
let page_models = pages_info
.iter()
.map(move |p| {
let (width, height) = match &p.dimension {
Some(d) => {
if d.rotate == 0 {
(Some(d.width), Some(d.height))
} else {
(Some(d.height), Some(d.width))
}
}
None => (None, None),
};
page::ActiveModel {
video_id: Set(video_model.id),
cid: Set(p.cid),
pid: Set(p.page),
name: Set(p.name.clone()),
width: Set(width),
height: Set(height),
duration: Set(p.duration),
image: Set(p.first_frame.clone()),
download_status: Set(0),
..Default::default()
}
})
.collect::<Vec<page::ActiveModel>>();
for page_chunk in page_models.chunks(50) {
page::Entity::insert_many(page_chunk.to_vec())
.on_conflict(
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
.do_nothing()
.to_owned(),
)
.do_nothing()
.exec(connection)
.await?;
}
Ok(())
}

View File

@@ -1,94 +1,135 @@
mod collection;
mod favorite;
mod helper;
mod submission;
mod watch_later;
use std::collections::HashSet;
use std::borrow::Cow;
use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use enum_dispatch::enum_dispatch;
use futures::Stream;
use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::DatabaseConnection;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::SimpleExpr;
use crate::adapter::collection::collection_from;
use crate::adapter::favorite::favorite_from;
use crate::adapter::submission::submission_from;
use crate::adapter::watch_later::watch_later_from;
use crate::bilibili::{self, BiliClient, CollectionItem, VideoInfo};
#[rustfmt::skip]
use bili_sync_entity::collection::Model as Collection;
use bili_sync_entity::favorite::Model as Favorite;
use bili_sync_entity::submission::Model as Submission;
use bili_sync_entity::watch_later::Model as WatchLater;
pub enum Args<'a> {
Favorite { fid: &'a str },
Collection { collection_item: &'a CollectionItem },
use crate::bilibili::{BiliClient, VideoInfo};
#[enum_dispatch]
pub enum VideoSourceEnum {
Favorite,
Collection,
Submission,
WatchLater,
Submission { upper_id: &'a str },
}
pub async fn video_list_from<'a>(
args: Args<'a>,
path: &Path,
bili_client: &'a BiliClient,
connection: &DatabaseConnection,
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
match args {
Args::Favorite { fid } => favorite_from(fid, path, bili_client, connection).await,
Args::Collection { collection_item } => collection_from(collection_item, path, bili_client, connection).await,
Args::WatchLater => watch_later_from(path, bili_client, connection).await,
Args::Submission { upper_id } => submission_from(upper_id, path, bili_client, connection).await,
#[enum_dispatch(VideoSourceEnum)]
pub trait VideoSource {
/// 获取视频源的名称
fn display_name(&self) -> Cow<'static, str>;
/// 获取特定视频列表的筛选条件
fn filter_expr(&self) -> SimpleExpr;
// 为 video_model 设置该视频列表的关联 id
fn set_relation_id(&self, video_model: &mut bili_sync_entity::video::ActiveModel);
// 获取视频列表的保存路径
fn path(&self) -> &Path;
/// 获取视频 model 中记录的最新时间
fn get_latest_row_at(&self) -> DateTime;
/// 更新视频 model 中记录的最新时间,此处返回需要更新的 ActiveModel接着调用 save 方法执行保存
/// 不同 VideoSource 返回的类型不同,为了 VideoSource 的 object safety 不能使用 impl Trait
/// Box<dyn ActiveModelTrait> 又提示 ActiveModelTrait 没有 object safety因此手写一个 Enum 静态分发
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel;
// 判断是否应该继续拉取视频
fn should_take(&self, release_datetime: &chrono::DateTime<Utc>, latest_row_at: &chrono::DateTime<Utc>) -> bool {
release_datetime > latest_row_at
}
fn should_filter(
&self,
video_info: Result<VideoInfo, anyhow::Error>,
_latest_row_at: &chrono::DateTime<Utc>,
) -> Option<VideoInfo> {
// 视频按照时间顺序拉取should_take 已经获取了所有需要处理的视频should_filter 无需额外处理
video_info.ok()
}
fn log_refresh_video_start(&self) {
info!("开始扫描{}..", self.display_name());
}
fn log_refresh_video_end(&self, count: usize) {
info!("扫描{}完成,获取到 {} 条新视频", self.display_name(), count);
}
fn log_fetch_video_start(&self) {
info!("开始填充{}视频详情..", self.display_name());
}
fn log_fetch_video_end(&self) {
info!("填充{}视频详情完成", self.display_name());
}
fn log_download_video_start(&self) {
info!("开始下载{}视频..", self.display_name());
}
fn log_download_video_end(&self) {
info!("下载{}视频完成", self.display_name());
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
connection: &'a DatabaseConnection,
) -> Result<(
VideoSourceEnum,
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
)>;
}
pub enum _ActiveModel {
Favorite(bili_sync_entity::favorite::ActiveModel),
Collection(bili_sync_entity::collection::ActiveModel),
Submission(bili_sync_entity::submission::ActiveModel),
WatchLater(bili_sync_entity::watch_later::ActiveModel),
}
impl _ActiveModel {
pub async fn save(self, connection: &DatabaseConnection) -> Result<()> {
match self {
_ActiveModel::Favorite(model) => {
model.save(connection).await?;
}
_ActiveModel::Collection(model) => {
model.save(connection).await?;
}
_ActiveModel::Submission(model) => {
model.save(connection).await?;
}
_ActiveModel::WatchLater(mut model) => {
if model.id.is_not_set() {
model.id = Set(1);
model.insert(connection).await?;
} else {
model.save(connection).await?;
}
}
}
Ok(())
}
}
#[async_trait]
pub trait VideoListModel {
/// 与视频列表关联的视频总数
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64>;
/// 未填充的视频
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<bili_sync_entity::video::Model>>;
/// 未处理的视频和分页
async fn unhandled_video_pages(
&self,
connection: &DatabaseConnection,
) -> Result<Vec<(bili_sync_entity::video::Model, Vec<bili_sync_entity::page::Model>)>>;
/// 该批次视频的存在标记
async fn exist_labels(&self, videos_info: &[VideoInfo], connection: &DatabaseConnection)
-> Result<HashSet<String>>;
/// 视频信息对应的视频 model
fn video_model_by_info(
&self,
video_info: &VideoInfo,
base_model: Option<bili_sync_entity::video::Model>,
) -> bili_sync_entity::video::ActiveModel;
/// 视频 model 中缺失的信息
async fn fetch_videos_detail(
&self,
video: bilibili::Video<'_>,
video_model: bili_sync_entity::video::Model,
connection: &DatabaseConnection,
) -> Result<()>;
/// 开始获取视频
fn log_fetch_video_start(&self);
/// 结束获取视频
fn log_fetch_video_end(&self);
/// 开始下载视频
fn log_download_video_start(&self);
/// 结束下载视频
fn log_download_video_end(&self);
/// 开始刷新视频
fn log_refresh_video_start(&self);
/// 结束刷新视频
fn log_refresh_video_end(&self, got_count: usize, new_count: u64);
}

View File

@@ -1,176 +1,77 @@
use std::collections::HashSet;
use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use async_trait::async_trait;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{IntoCondition, OnConflict};
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, TransactionTrait};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::SimpleExpr;
use sea_orm::{DatabaseConnection, Unchanged};
use crate::adapter::helper::video_with_path;
use crate::adapter::{helper, VideoListModel};
use crate::bilibili::{self, BiliClient, Submission, VideoInfo};
use crate::utils::status::Status;
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
use crate::bilibili::{BiliClient, Submission, VideoInfo};
#[async_trait]
impl VideoListModel for submission::Model {
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
helper::count_videos(video::Column::SubmissionId.eq(self.id).into_condition(), connection).await
impl VideoSource for submission::Model {
fn display_name(&self) -> std::borrow::Cow<'static, str> {
format!("{}」投稿", self.upper_name).into()
}
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
helper::filter_videos(
video::Column::SubmissionId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.eq(0))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_null())
.into_condition(),
connection,
)
.await
fn filter_expr(&self) -> SimpleExpr {
video::Column::SubmissionId.eq(self.id)
}
async fn unhandled_video_pages(
&self,
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
helper::filter_videos_with_pages(
video::Column::SubmissionId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.lt(Status::handled()))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.into_condition(),
connection,
)
.await
}
async fn exist_labels(
&self,
videos_info: &[VideoInfo],
connection: &DatabaseConnection,
) -> Result<HashSet<String>> {
helper::video_keys(
video::Column::SubmissionId.eq(self.id),
videos_info,
[video::Column::Bvid, video::Column::Ctime],
connection,
)
.await
}
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
let mut video_model = video_info.to_model(base_model);
fn set_relation_id(&self, video_model: &mut video::ActiveModel) {
video_model.submission_id = Set(Some(self.id));
video_with_path(video_model, &self.path, video_info)
}
async fn fetch_videos_detail(
&self,
video: bilibili::Video<'_>,
video_model: video::Model,
connection: &DatabaseConnection,
) -> Result<()> {
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
match info {
Ok((tags, view_info)) => {
let VideoInfo::View { pages, .. } = &view_info else {
unreachable!("view_info must be VideoInfo::View")
};
let txn = connection.begin().await?;
// 将分页信息写入数据库
helper::create_video_pages(pages, &video_model, &txn).await?;
// 将页标记和 tag 写入数据库
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
video_active_model.single_page = Set(Some(pages.len() == 1));
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
video_active_model.save(&txn).await?;
txn.commit().await?;
}
Err(e) => {
helper::error_fetch_video_detail(e, video_model, connection).await?;
}
};
Ok(())
fn path(&self) -> &Path {
Path::new(self.path.as_str())
}
fn log_fetch_video_start(&self) {
info!(
"开始获取 UP 主 {} - {} 投稿的视频与分页信息...",
self.upper_id, self.upper_name
fn get_latest_row_at(&self) -> DateTime {
self.latest_row_at
}
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel {
_ActiveModel::Submission(submission::ActiveModel {
id: Unchanged(self.id),
latest_row_at: Set(datetime),
..Default::default()
})
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
connection: &'a DatabaseConnection,
) -> Result<(
VideoSourceEnum,
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
)> {
let submission = Submission::new(bili_client, self.upper_id.to_string());
let upper = submission.get_info().await?;
ensure!(
upper.mid == submission.upper_id,
"submission upper id mismatch: {} != {}",
upper.mid,
submission.upper_id
);
}
fn log_fetch_video_end(&self) {
info!(
"获取 UP 主 {} - {} 投稿的视频与分页信息完成",
self.upper_id, self.upper_name
);
}
fn log_download_video_start(&self) {
info!(
"开始下载 UP 主 {} - {} 投稿的所有未处理过的视频...",
self.upper_id, self.upper_name
);
}
fn log_download_video_end(&self) {
info!(
"下载 UP 主 {} - {} 投稿的所有未处理过的视频完成",
self.upper_id, self.upper_name
);
}
fn log_refresh_video_start(&self) {
info!("开始扫描 UP 主 {} - {} 投稿的新视频...", self.upper_id, self.upper_name);
}
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
info!(
"扫描 UP 主 {} - {} 投稿的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
self.upper_id, self.upper_name, got_count, new_count,
);
}
}
pub(super) async fn submission_from<'a>(
upper_id: &str,
path: &Path,
bili_client: &'a BiliClient,
connection: &DatabaseConnection,
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
let submission = Submission::new(bili_client, upper_id.to_owned());
let upper = submission.get_info().await?;
submission::Entity::insert(submission::ActiveModel {
upper_id: Set(upper.mid.parse()?),
upper_name: Set(upper.name),
path: Set(path.to_string_lossy().to_string()),
..Default::default()
})
.on_conflict(
OnConflict::column(submission::Column::UpperId)
.update_columns([submission::Column::UpperName, submission::Column::Path])
.to_owned(),
)
.exec(connection)
.await?;
Ok((
Box::new(
submission::ActiveModel {
id: Unchanged(self.id),
upper_name: Set(upper.name),
..Default::default()
}
.save(connection)
.await?;
Ok((
submission::Entity::find()
.filter(submission::Column::UpperId.eq(upper.mid))
.filter(submission::Column::Id.eq(self.id))
.one(connection)
.await?
.unwrap(),
),
Box::pin(submission.into_video_stream()),
))
.context("submission not found")?
.into(),
Box::pin(submission.into_video_stream()),
))
}
}

View File

@@ -1,158 +1,55 @@
use std::collections::HashSet;
use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use async_trait::async_trait;
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{IntoCondition, OnConflict};
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, TransactionTrait};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::SimpleExpr;
use sea_orm::{DatabaseConnection, Unchanged};
use crate::adapter::helper::video_with_path;
use crate::adapter::{helper, VideoListModel};
use crate::bilibili::{self, BiliClient, VideoInfo, WatchLater};
use crate::utils::status::Status;
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
use crate::bilibili::{BiliClient, VideoInfo, WatchLater};
#[async_trait]
impl VideoListModel for watch_later::Model {
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
helper::count_videos(video::Column::WatchLaterId.eq(self.id).into_condition(), connection).await
impl VideoSource for watch_later::Model {
fn display_name(&self) -> std::borrow::Cow<'static, str> {
"稍后再看".into()
}
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
helper::filter_videos(
video::Column::WatchLaterId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.eq(0))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_null())
.into_condition(),
connection,
)
.await
fn filter_expr(&self) -> SimpleExpr {
video::Column::WatchLaterId.eq(self.id)
}
async fn unhandled_video_pages(
&self,
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
helper::filter_videos_with_pages(
video::Column::WatchLaterId
.eq(self.id)
.and(video::Column::Valid.eq(true))
.and(video::Column::DownloadStatus.lt(Status::handled()))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.into_condition(),
connection,
)
.await
}
async fn exist_labels(
&self,
videos_info: &[VideoInfo],
connection: &DatabaseConnection,
) -> Result<HashSet<String>> {
helper::video_keys(
video::Column::WatchLaterId.eq(self.id),
videos_info,
[video::Column::Bvid, video::Column::Favtime],
connection,
)
.await
}
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
let mut video_model = video_info.to_model(base_model);
fn set_relation_id(&self, video_model: &mut video::ActiveModel) {
video_model.watch_later_id = Set(Some(self.id));
video_with_path(video_model, &self.path, video_info)
}
async fn fetch_videos_detail(
&self,
video: bilibili::Video<'_>,
video_model: video::Model,
connection: &DatabaseConnection,
) -> Result<()> {
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
match info {
Ok((tags, pages_info)) => {
let txn = connection.begin().await?;
// 将分页信息写入数据库
helper::create_video_pages(&pages_info, &video_model, &txn).await?;
// 将页标记和 tag 写入数据库
let mut video_active_model: video::ActiveModel = video_model.into();
video_active_model.single_page = Set(Some(pages_info.len() == 1));
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
video_active_model.save(&txn).await?;
txn.commit().await?;
}
Err(e) => {
helper::error_fetch_video_detail(e, video_model, connection).await?;
}
};
Ok(())
fn path(&self) -> &Path {
Path::new(self.path.as_str())
}
fn log_fetch_video_start(&self) {
info!("开始获取稍后再看的视频与分页信息...");
fn get_latest_row_at(&self) -> DateTime {
self.latest_row_at
}
fn log_fetch_video_end(&self) {
info!("获取稍后再看的视频与分页信息完成");
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel {
_ActiveModel::WatchLater(watch_later::ActiveModel {
id: Unchanged(self.id),
latest_row_at: Set(datetime),
..Default::default()
})
}
fn log_download_video_start(&self) {
info!("开始下载稍后再看中所有未处理过的视频...");
}
fn log_download_video_end(&self) {
info!("下载稍后再看中未处理过的视频完成");
}
fn log_refresh_video_start(&self) {
info!("开始扫描稍后再看的新视频...");
}
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
info!(
"扫描稍后再看的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
got_count, new_count,
);
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
_connection: &'a DatabaseConnection,
) -> Result<(
VideoSourceEnum,
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
)> {
let watch_later = WatchLater::new(bili_client);
Ok((self.into(), Box::pin(watch_later.into_video_stream())))
}
}
pub(super) async fn watch_later_from<'a>(
path: &Path,
bili_client: &'a BiliClient,
connection: &DatabaseConnection,
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
let watch_later = WatchLater::new(bili_client);
watch_later::Entity::insert(watch_later::ActiveModel {
id: Set(1),
path: Set(path.to_string_lossy().to_string()),
..Default::default()
})
.on_conflict(
OnConflict::column(watch_later::Column::Id)
.update_column(watch_later::Column::Path)
.to_owned(),
)
.exec(connection)
.await?;
Ok((
Box::new(
watch_later::Entity::find()
.filter(watch_later::Column::Id.eq(1))
.one(connection)
.await?
.unwrap(),
),
Box::pin(watch_later.into_video_stream()),
))
}

View File

@@ -0,0 +1,9 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum InnerApiError {
#[error("Primary key not found: {0}")]
NotFound(i32),
#[error("Bad request: {0}")]
BadRequest(String),
}

View File

@@ -0,0 +1,83 @@
use std::borrow::Borrow;
use sea_orm::{ConnectionTrait, DatabaseTransaction};
use crate::api::response::{PageInfo, VideoInfo};
pub async fn update_video_download_status(
txn: &DatabaseTransaction,
videos: &[impl Borrow<VideoInfo>],
batch_size: Option<usize>,
) -> Result<(), sea_orm::DbErr> {
if videos.is_empty() {
return Ok(());
}
let videos = videos.iter().map(|v| v.borrow()).collect::<Vec<_>>();
if let Some(size) = batch_size {
for chunk in videos.chunks(size) {
execute_video_update_batch(txn, chunk).await?;
}
} else {
execute_video_update_batch(txn, &videos).await?;
}
Ok(())
}
pub async fn update_page_download_status(
txn: &DatabaseTransaction,
pages: &[impl Borrow<PageInfo>],
batch_size: Option<usize>,
) -> Result<(), sea_orm::DbErr> {
if pages.is_empty() {
return Ok(());
}
let pages = pages.iter().map(|v| v.borrow()).collect::<Vec<_>>();
if let Some(size) = batch_size {
for chunk in pages.chunks(size) {
execute_page_update_batch(txn, chunk).await?;
}
} else {
execute_page_update_batch(txn, &pages).await?;
}
Ok(())
}
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> {
if videos.is_empty() {
return Ok(());
}
let sql = format!(
"WITH tempdata(id, download_status) AS (VALUES {}) \
UPDATE video \
SET download_status = tempdata.download_status \
FROM tempdata \
WHERE video.id = tempdata.id",
videos
.iter()
.map(|v| format!("({}, {})", v.id, v.download_status))
.collect::<Vec<_>>()
.join(", ")
);
txn.execute_unprepared(&sql).await?;
Ok(())
}
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> {
if pages.is_empty() {
return Ok(());
}
let sql = format!(
"WITH tempdata(id, download_status) AS (VALUES {}) \
UPDATE page \
SET download_status = tempdata.download_status \
FROM tempdata \
WHERE page.id = tempdata.id",
pages
.iter()
.map(|p| format!("({}, {})", p.id, p.download_status))
.collect::<Vec<_>>()
.join(", ")
);
txn.execute_unprepared(&sql).await?;
Ok(())
}

View File

@@ -0,0 +1,8 @@
mod error;
mod helper;
mod request;
mod response;
mod routes;
mod wrapper;
pub use routes::{LogHelper, MAX_HISTORY_LOGS, router};

View File

@@ -0,0 +1,94 @@
use serde::Deserialize;
use validator::Validate;
use crate::bilibili::CollectionType;
#[derive(Deserialize)]
pub struct VideosRequest {
pub collection: Option<i32>,
pub favorite: Option<i32>,
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Deserialize)]
pub struct ResetRequest {
#[serde(default)]
pub force: bool,
}
#[derive(Deserialize, Validate)]
pub struct StatusUpdate {
#[validate(range(min = 0, max = 4))]
pub status_index: usize,
#[validate(custom(function = "crate::utils::validation::validate_status_value"))]
pub status_value: u32,
}
#[derive(Deserialize, Validate)]
pub struct PageStatusUpdate {
pub page_id: i32,
#[validate(nested)]
pub updates: Vec<StatusUpdate>,
}
#[derive(Deserialize, Validate)]
pub struct UpdateVideoStatusRequest {
#[serde(default)]
#[validate(nested)]
pub video_updates: Vec<StatusUpdate>,
#[serde(default)]
#[validate(nested)]
pub page_updates: Vec<PageStatusUpdate>,
}
#[derive(Deserialize)]
pub struct FollowedCollectionsRequest {
pub page_num: Option<i32>,
pub page_size: Option<i32>,
}
#[derive(Deserialize)]
pub struct FollowedUppersRequest {
pub page_num: Option<i32>,
pub page_size: Option<i32>,
}
#[derive(Deserialize, Validate)]
pub struct InsertFavoriteRequest {
pub fid: i64,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize, Validate)]
pub struct InsertCollectionRequest {
pub sid: i64,
pub mid: i64,
#[serde(default)]
pub collection_type: CollectionType,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize, Validate)]
pub struct InsertSubmissionRequest {
pub upper_id: i64,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize)]
pub struct ImageProxyParams {
pub url: String,
}
#[derive(Deserialize, Validate)]
pub struct UpdateVideoSourceRequest {
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
pub enabled: bool,
}

View File

@@ -0,0 +1,177 @@
use bili_sync_entity::*;
use sea_orm::{DerivePartialModel, FromQueryResult};
use serde::Serialize;
use crate::utils::status::{PageStatus, VideoStatus};
#[derive(Serialize)]
pub struct VideoSourcesResponse {
pub collection: Vec<VideoSource>,
pub favorite: Vec<VideoSource>,
pub submission: Vec<VideoSource>,
pub watch_later: Vec<VideoSource>,
}
#[derive(Serialize)]
pub struct VideosResponse {
pub videos: Vec<VideoInfo>,
pub total_count: u64,
}
#[derive(Serialize)]
pub struct VideoResponse {
pub video: VideoInfo,
pub pages: Vec<PageInfo>,
}
#[derive(Serialize)]
pub struct ResetVideoResponse {
pub resetted: bool,
pub video: VideoInfo,
pub pages: Vec<PageInfo>,
}
#[derive(Serialize)]
pub struct ResetAllVideosResponse {
pub resetted: bool,
pub resetted_videos_count: usize,
pub resetted_pages_count: usize,
}
#[derive(Serialize)]
pub struct UpdateVideoStatusResponse {
pub success: bool,
pub video: VideoInfo,
pub pages: Vec<PageInfo>,
}
#[derive(FromQueryResult, Serialize)]
pub struct VideoSource {
pub id: i32,
pub name: String,
}
#[derive(Serialize, DerivePartialModel, FromQueryResult)]
#[sea_orm(entity = "video::Entity")]
pub struct VideoInfo {
pub id: i32,
pub bvid: String,
pub name: String,
pub upper_name: String,
#[serde(serialize_with = "serde_video_download_status")]
pub download_status: u32,
}
#[derive(Serialize, DerivePartialModel, FromQueryResult)]
#[sea_orm(entity = "page::Entity")]
pub struct PageInfo {
pub id: i32,
pub video_id: i32,
pub pid: i32,
pub name: String,
#[serde(serialize_with = "serde_page_download_status")]
pub download_status: u32,
}
fn serde_video_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let status: [u32; 5] = VideoStatus::from(*status).into();
status.serialize(serializer)
}
fn serde_page_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let status: [u32; 5] = PageStatus::from(*status).into();
status.serialize(serializer)
}
#[derive(Serialize)]
pub struct FavoriteWithSubscriptionStatus {
pub title: String,
pub media_count: i64,
pub fid: i64,
pub mid: i64,
pub subscribed: bool,
}
#[derive(Serialize)]
pub struct CollectionWithSubscriptionStatus {
pub title: String,
pub sid: i64,
pub mid: i64,
pub invalid: bool,
pub subscribed: bool,
}
#[derive(Serialize)]
pub struct UpperWithSubscriptionStatus {
pub mid: i64,
pub uname: String,
pub face: String,
pub sign: String,
pub invalid: bool,
pub subscribed: bool,
}
#[derive(Serialize)]
pub struct FavoritesResponse {
pub favorites: Vec<FavoriteWithSubscriptionStatus>,
}
#[derive(Serialize)]
pub struct CollectionsResponse {
pub collections: Vec<CollectionWithSubscriptionStatus>,
pub total: i64,
}
#[derive(Serialize)]
pub struct UppersResponse {
pub uppers: Vec<UpperWithSubscriptionStatus>,
pub total: i64,
}
#[derive(Serialize)]
pub struct VideoSourcesDetailsResponse {
pub collections: Vec<VideoSourceDetail>,
pub favorites: Vec<VideoSourceDetail>,
pub submissions: Vec<VideoSourceDetail>,
pub watch_later: Vec<VideoSourceDetail>,
}
#[derive(Serialize, FromQueryResult)]
pub struct DayCountPair {
pub day: String,
pub cnt: i64,
}
#[derive(Serialize)]
pub struct DashBoardResponse {
pub enabled_favorites: u64,
pub enabled_collections: u64,
pub enabled_submissions: u64,
pub enable_watch_later: bool,
pub videos_by_day: Vec<DayCountPair>,
}
#[derive(Serialize)]
pub struct SysInfo {
pub total_memory: u64,
pub used_memory: u64,
pub process_memory: u64,
pub used_cpu: f32,
pub process_cpu: f32,
pub total_disk: u64,
pub available_disk: u64,
}
#[derive(Serialize, FromQueryResult)]
pub struct VideoSourceDetail {
pub id: i32,
pub name: String,
pub path: String,
pub enabled: bool,
}

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::extract::Extension;
use axum::routing::get;
use sea_orm::DatabaseConnection;
use crate::api::error::InnerApiError;
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::config::{Config, VersionedConfig};
use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
pub(super) fn router() -> Router {
Router::new().route("/config", get(get_config).put(update_config))
}
/// 获取全局配置
pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
Ok(ApiResponse::ok(VersionedConfig::get().load_full()))
}
/// 更新全局配置
pub async fn update_config(
Extension(db): Extension<Arc<DatabaseConnection>>,
ValidatedJson(config): ValidatedJson<Config>,
) -> Result<ApiResponse<Arc<Config>>, ApiError> {
let Some(_lock) = TASK_STATUS_NOTIFIER.detect_running() else {
// 简单避免一下可能的不一致现象
return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into());
};
config.check()?;
let new_config = VersionedConfig::get().update(config, db.as_ref()).await?;
drop(_lock);
Ok(ApiResponse::ok(new_config))
}

View File

@@ -0,0 +1,67 @@
use std::sync::Arc;
use axum::routing::get;
use axum::{Extension, Router};
use bili_sync_entity::*;
use sea_orm::entity::prelude::*;
use sea_orm::{FromQueryResult, Statement};
use crate::api::response::{DashBoardResponse, DayCountPair};
use crate::api::wrapper::{ApiError, ApiResponse};
pub(super) fn router() -> Router {
Router::new().route("/dashboard", get(get_dashboard))
}
async fn get_dashboard(
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<DashBoardResponse>, ApiError> {
let (enabled_favorites, enabled_collections, enabled_submissions, enabled_watch_later, videos_by_day) = tokio::try_join!(
favorite::Entity::find()
.filter(favorite::Column::Enabled.eq(true))
.count(db.as_ref()),
collection::Entity::find()
.filter(collection::Column::Enabled.eq(true))
.count(db.as_ref()),
submission::Entity::find()
.filter(submission::Column::Enabled.eq(true))
.count(db.as_ref()),
watch_later::Entity::find()
.filter(watch_later::Column::Enabled.eq(true))
.count(db.as_ref()),
DayCountPair::find_by_statement(Statement::from_string(
db.get_database_backend(),
// 用 SeaORM 太复杂了,直接写个裸 SQL
"
SELECT
dates.day AS day,
COUNT(video.id) AS cnt
FROM
(
SELECT
STRFTIME('%Y-%m-%d', DATE('now', '-' || n || ' days', 'localtime')) AS day,
DATETIME(DATE('now', '-' || n || ' days', 'localtime'), 'utc') AS start_utc_datetime,
DATETIME(DATE('now', '-' || n || ' days', '+1 day', 'localtime'), 'utc') AS end_utc_datetime
FROM
(
SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
)
) AS dates
LEFT JOIN
video ON video.created_at >= dates.start_utc_datetime AND video.created_at < dates.end_utc_datetime
GROUP BY
dates.day
ORDER BY
dates.day;
"
))
.all(db.as_ref()),
)?;
return Ok(ApiResponse::ok(DashBoardResponse {
enabled_favorites,
enabled_collections,
enabled_submissions,
enable_watch_later: enabled_watch_later > 0,
videos_by_day,
}));
}

View File

@@ -0,0 +1,146 @@
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::extract::{Extension, Query};
use axum::routing::get;
use bili_sync_entity::*;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect};
use crate::api::request::{FollowedCollectionsRequest, FollowedUppersRequest};
use crate::api::response::{
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse,
UpperWithSubscriptionStatus, UppersResponse,
};
use crate::api::wrapper::{ApiError, ApiResponse};
use crate::bilibili::{BiliClient, Me};
pub(super) fn router() -> Router {
Router::new()
.route("/me/favorites", get(get_created_favorites))
.route("/me/collections", get(get_followed_collections))
.route("/me/uppers", get(get_followed_uppers))
}
/// 获取当前用户创建的收藏夹
pub async fn get_created_favorites(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
) -> Result<ApiResponse<FavoritesResponse>, ApiError> {
let me = Me::new(bili_client.as_ref());
let bili_favorites = me.get_created_favorites().await?;
let favorites = if let Some(bili_favorites) = bili_favorites {
// b 站收藏夹相关接口使用的所谓 “fid” 其实是该处的 id即 fid + mid 后两位
let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect();
let subscribed_fids: Vec<i64> = favorite::Entity::find()
.select_only()
.column(favorite::Column::FId)
.filter(favorite::Column::FId.is_in(bili_fids))
.into_tuple()
.all(db.as_ref())
.await?;
let subscribed_set: HashSet<i64> = subscribed_fids.into_iter().collect();
bili_favorites
.into_iter()
.map(|fav| FavoriteWithSubscriptionStatus {
title: fav.title,
media_count: fav.media_count,
// api 返回的 id 才是真实的 fid
fid: fav.id,
mid: fav.mid,
subscribed: subscribed_set.contains(&fav.id),
})
.collect()
} else {
vec![]
};
Ok(ApiResponse::ok(FavoritesResponse { favorites }))
}
/// 获取当前用户收藏的合集
pub async fn get_followed_collections(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<FollowedCollectionsRequest>,
) -> Result<ApiResponse<CollectionsResponse>, ApiError> {
let me = Me::new(bili_client.as_ref());
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(50));
let bili_collections = me.get_followed_collections(page_num, page_size).await?;
let collections = if let Some(collection_list) = bili_collections.list {
let bili_sids: Vec<_> = collection_list.iter().map(|col| col.id).collect();
let subscribed_ids: Vec<i64> = collection::Entity::find()
.select_only()
.column(collection::Column::SId)
.filter(collection::Column::SId.is_in(bili_sids))
.into_tuple()
.all(db.as_ref())
.await?;
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
collection_list
.into_iter()
.map(|col| CollectionWithSubscriptionStatus {
title: col.title,
sid: col.id,
mid: col.mid,
invalid: col.state == 1,
subscribed: subscribed_set.contains(&col.id),
})
.collect()
} else {
vec![]
};
Ok(ApiResponse::ok(CollectionsResponse {
collections,
total: bili_collections.count,
}))
}
/// 获取当前用户关注的 UP 主
pub async fn get_followed_uppers(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<FollowedUppersRequest>,
) -> Result<ApiResponse<UppersResponse>, ApiError> {
let me = Me::new(bili_client.as_ref());
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(20));
let bili_uppers = me.get_followed_uppers(page_num, page_size).await?;
let bili_uid: Vec<_> = bili_uppers.list.iter().map(|upper| upper.mid).collect();
let subscribed_ids: Vec<i64> = submission::Entity::find()
.select_only()
.column(submission::Column::UpperId)
.filter(submission::Column::UpperId.is_in(bili_uid))
.into_tuple()
.all(db.as_ref())
.await?;
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
let uppers = bili_uppers
.list
.into_iter()
.map(|upper| UpperWithSubscriptionStatus {
mid: upper.mid,
// 官方没有提供字段,但是可以使用这种方式简单判断下
invalid: upper.uname == "账号已注销" && upper.face == "https://i0.hdslb.com/bfs/face/member/noface.jpg",
uname: upper.uname,
face: upper.face,
sign: upper.sign,
subscribed: subscribed_set.contains(&upper.mid),
})
.collect();
Ok(ApiResponse::ok(UppersResponse {
uppers,
total: bili_uppers.total,
}))
}

View File

@@ -0,0 +1,99 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Extension, Query, Request};
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Router, middleware};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use reqwest::{Method, StatusCode, header};
use super::request::ImageProxyParams;
use crate::api::wrapper::ApiResponse;
use crate::bilibili::BiliClient;
use crate::config::VersionedConfig;
mod config;
mod dashboard;
mod me;
mod video_sources;
mod videos;
mod ws;
pub use ws::{LogHelper, MAX_HISTORY_LOGS};
pub fn router() -> Router {
Router::new().route("/image-proxy", get(image_proxy)).nest(
"/api",
config::router()
.merge(me::router())
.merge(video_sources::router())
.merge(videos::router())
.merge(dashboard::router())
.merge(ws::router())
.layer(middleware::from_fn(auth)),
)
}
/// 中间件:验证请求头中的 Authorization 是否与配置中的 auth_token 匹配
pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
let config = VersionedConfig::get().load();
let token = config.auth_token.as_str();
if headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.is_some_and(|s| s == token)
|| headers
.get("Sec-WebSocket-Protocol")
.and_then(|v| v.to_str().ok())
.and_then(|s| BASE64_URL_SAFE_NO_PAD.decode(s).ok())
.is_some_and(|s| s == token.as_bytes())
{
return Ok(next.run(request).await);
}
Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response())
}
/// B 站的图片会检查 referer需要做个转发伪造一下否则直接返回 403
pub async fn image_proxy(
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<ImageProxyParams>,
) -> Response {
let resp = bili_client.client.request(Method::GET, &params.url, None).send().await;
let whitelist = [
header::CONTENT_TYPE,
header::CONTENT_LENGTH,
header::CACHE_CONTROL,
header::EXPIRES,
header::LAST_MODIFIED,
header::ETAG,
header::CONTENT_DISPOSITION,
header::CONTENT_ENCODING,
header::ACCEPT_RANGES,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
]
.into_iter()
.collect::<HashSet<_>>();
let builder = Response::builder();
let response = match resp {
Err(e) => builder.status(StatusCode::BAD_GATEWAY).body(Body::new(e.to_string())),
Ok(res) => {
let mut response = builder.status(res.status());
for (k, v) in res.headers() {
if whitelist.contains(k) {
response = response.header(k, v);
}
}
let streams = res.bytes_stream();
response.body(Body::from_stream(streams))
}
};
//safety: all previously configured headers are taken from a valid response, ensuring the response is safe to use
response.unwrap()
}

View File

@@ -0,0 +1,255 @@
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::extract::{Extension, Path};
use axum::routing::{get, post, put};
use bili_sync_entity::*;
use bili_sync_migration::Expr;
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect};
use crate::adapter::_ActiveModel;
use crate::api::error::InnerApiError;
use crate::api::request::{
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
};
use crate::api::response::{VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse};
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
pub(super) fn router() -> Router {
Router::new()
.route("/video-sources", get(get_video_sources))
.route("/video-sources/details", get(get_video_sources_details))
.route("/video-sources/{type}/{id}", put(update_video_source))
.route("/video-sources/favorites", post(insert_favorite))
.route("/video-sources/collections", post(insert_collection))
.route("/video-sources/submissions", post(insert_submission))
}
/// 列出所有视频来源
pub async fn get_video_sources(
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
let (collection, favorite, submission, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([collection::Column::Id, collection::Column::Name])
.into_model::<VideoSource>()
.all(db.as_ref()),
favorite::Entity::find()
.select_only()
.columns([favorite::Column::Id, favorite::Column::Name])
.into_model::<VideoSource>()
.all(db.as_ref()),
submission::Entity::find()
.select_only()
.column(submission::Column::Id)
.column_as(submission::Column::UpperName, "name")
.into_model::<VideoSource>()
.all(db.as_ref()),
watch_later::Entity::find()
.select_only()
.column(watch_later::Column::Id)
.column_as(Expr::value("稍后再看"), "name")
.into_model::<VideoSource>()
.all(db.as_ref())
)?;
// watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项
if watch_later.is_empty() {
watch_later.push(VideoSource {
id: 1,
name: "稍后再看".to_string(),
});
}
Ok(ApiResponse::ok(VideoSourcesResponse {
collection,
favorite,
submission,
watch_later,
}))
}
/// 获取视频来源详情
pub async fn get_video_sources_details(
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
let (collections, favorites, submissions, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([
collection::Column::Id,
collection::Column::Name,
collection::Column::Path,
collection::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
favorite::Entity::find()
.select_only()
.columns([
favorite::Column::Id,
favorite::Column::Name,
favorite::Column::Path,
favorite::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
submission::Entity::find()
.select_only()
.column(submission::Column::Id)
.column_as(submission::Column::UpperName, "name")
.columns([submission::Column::Path, submission::Column::Enabled])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
watch_later::Entity::find()
.select_only()
.column(watch_later::Column::Id)
.column_as(Expr::value("稍后再看"), "name")
.columns([watch_later::Column::Path, watch_later::Column::Enabled])
.into_model::<VideoSourceDetail>()
.all(db.as_ref())
)?;
if watch_later.is_empty() {
watch_later.push(VideoSourceDetail {
id: 1,
name: "稍后再看".to_string(),
path: String::new(),
enabled: false,
})
}
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
collections,
favorites,
submissions,
watch_later,
}))
}
/// 更新视频来源
pub async fn update_video_source(
Path((source_type, id)): Path<(String, i32)>,
Extension(db): Extension<Arc<DatabaseConnection>>,
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let active_model = match source_type.as_str() {
"collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: collection::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Collection(active_model)
}),
"favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: favorite::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Favorite(active_model)
}),
"submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: submission::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Submission(active_model)
}),
"watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? {
// 稍后再看需要做特殊处理get 时如果稍后再看不存在返回的是 id 为 1 的假记录
// 因此此处可能是更新也可能是插入,做个额外的处理
Some(model) => {
// 如果有记录,使用 id 对应的记录更新
let mut active_model: watch_later::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
Some(_ActiveModel::WatchLater(active_model))
}
None => {
if id != 1 {
None
} else {
// 如果没有记录且 id 为 1插入一个新的稍后再看记录
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
path: Set(request.path),
enabled: Set(request.enabled),
..Default::default()
}))
}
}
},
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
};
let Some(active_model) = active_model else {
return Err(InnerApiError::NotFound(id).into());
};
active_model.save(db.as_ref()).await?;
Ok(ApiResponse::ok(true))
}
/// 新增收藏夹订阅
pub async fn insert_favorite(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string());
let favorite_info = favorite.get_info().await?;
favorite::Entity::insert(favorite::ActiveModel {
f_id: Set(favorite_info.id),
name: Set(favorite_info.title.clone()),
path: Set(request.path),
enabled: Set(true),
..Default::default()
})
.exec(db.as_ref())
.await?;
Ok(ApiResponse::ok(true))
}
/// 新增合集/列表订阅
pub async fn insert_collection(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let collection = Collection::new(
bili_client.as_ref(),
CollectionItem {
sid: request.sid.to_string(),
mid: request.mid.to_string(),
collection_type: request.collection_type,
},
);
let collection_info = collection.get_info().await?;
collection::Entity::insert(collection::ActiveModel {
s_id: Set(collection_info.sid),
m_id: Set(collection_info.mid),
r#type: Set(collection_info.collection_type.into()),
name: Set(collection_info.name.clone()),
path: Set(request.path),
enabled: Set(true),
..Default::default()
})
.exec(db.as_ref())
.await?;
Ok(ApiResponse::ok(true))
}
/// 新增投稿订阅
pub async fn insert_submission(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string());
let upper = submission.get_info().await?;
submission::Entity::insert(submission::ActiveModel {
upper_id: Set(upper.mid.parse()?),
upper_name: Set(upper.name),
path: Set(request.path),
enabled: Set(true),
..Default::default()
})
.exec(db.as_ref())
.await?;
Ok(ApiResponse::ok(true))
}

View File

@@ -0,0 +1,266 @@
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use axum::extract::{Extension, Path, Query};
use axum::routing::{get, post};
use axum::{Json, Router};
use bili_sync_entity::*;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait,
};
use crate::api::error::InnerApiError;
use crate::api::helper::{update_page_download_status, update_video_download_status};
use crate::api::request::{ResetRequest, UpdateVideoStatusRequest, VideosRequest};
use crate::api::response::{
PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
VideosResponse,
};
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::utils::status::{PageStatus, VideoStatus};
pub(super) fn router() -> Router {
Router::new()
.route("/videos", get(get_videos))
.route("/videos/{id}", get(get_video))
.route("/videos/{id}/reset", post(reset_video))
.route("/videos/reset-all", post(reset_all_videos))
.route("/videos/{id}/update-status", post(update_video_status))
}
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
pub async fn get_videos(
Extension(db): Extension<Arc<DatabaseConnection>>,
Query(params): Query<VideosRequest>,
) -> Result<ApiResponse<VideosResponse>, ApiError> {
let mut query = video::Entity::find();
for (field, column) in [
(params.collection, video::Column::CollectionId),
(params.favorite, video::Column::FavoriteId),
(params.submission, video::Column::SubmissionId),
(params.watch_later, video::Column::WatchLaterId),
] {
if let Some(id) = field {
query = query.filter(column.eq(id));
}
}
if let Some(query_word) = params.query {
query = query.filter(video::Column::Name.contains(query_word));
}
let total_count = query.clone().count(db.as_ref()).await?;
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) {
(page, page_size)
} else {
(0, 10)
};
Ok(ApiResponse::ok(VideosResponse {
videos: query
.order_by_desc(video::Column::Id)
.into_partial_model::<VideoInfo>()
.paginate(db.as_ref(), page_size)
.fetch_page(page)
.await?,
total_count,
}))
}
pub async fn get_video(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<VideoResponse>, ApiError> {
let (video_info, pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
)?;
let Some(video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());
};
Ok(ApiResponse::ok(VideoResponse {
video: video_info,
pages: pages_info,
}))
}
pub async fn reset_video(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
Json(request): Json<ResetRequest>,
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
let (video_info, pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
)?;
let Some(mut video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());
};
let resetted_pages_info = pages_info
.into_iter()
.filter_map(|mut page_info| {
let mut page_status = PageStatus::from(page_info.download_status);
if (request.force && page_status.force_reset_failed()) || page_status.reset_failed() {
page_info.download_status = page_status.into();
Some(page_info)
} else {
None
}
})
.collect::<Vec<_>>();
let mut video_status = VideoStatus::from(video_info.download_status);
let mut video_resetted = (request.force && video_status.force_reset_failed()) || video_status.reset_failed();
if !resetted_pages_info.is_empty() {
video_status.set(4, 0); // 将“分页下载”重置为 0
video_resetted = true;
}
let resetted_videos_info = if video_resetted {
video_info.download_status = video_status.into();
vec![&video_info]
} else {
vec![]
};
let resetted = !resetted_videos_info.is_empty() || !resetted_pages_info.is_empty();
if resetted {
let txn = db.begin().await?;
if !resetted_videos_info.is_empty() {
// 只可能有 1 个元素,所以不用 batch
update_video_download_status(&txn, &resetted_videos_info, None).await?;
}
if !resetted_pages_info.is_empty() {
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
}
txn.commit().await?;
}
Ok(ApiResponse::ok(ResetVideoResponse {
resetted,
video: video_info,
pages: resetted_pages_info,
}))
}
pub async fn reset_all_videos(
Extension(db): Extension<Arc<DatabaseConnection>>,
Json(request): Json<ResetRequest>,
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
// 先查询所有视频和页面数据
let (all_videos, all_pages) = tokio::try_join!(
video::Entity::find().into_partial_model::<VideoInfo>().all(db.as_ref()),
page::Entity::find().into_partial_model::<PageInfo>().all(db.as_ref())
)?;
let resetted_pages_info = all_pages
.into_iter()
.filter_map(|mut page_info| {
let mut page_status = PageStatus::from(page_info.download_status);
if (request.force && page_status.force_reset_failed()) || page_status.reset_failed() {
page_info.download_status = page_status.into();
Some(page_info)
} else {
None
}
})
.collect::<Vec<_>>();
let video_ids_with_resetted_pages: HashSet<i32> = resetted_pages_info.iter().map(|page| page.video_id).collect();
let resetted_videos_info = all_videos
.into_iter()
.filter_map(|mut video_info| {
let mut video_status = VideoStatus::from(video_info.download_status);
let mut video_resetted =
(request.force && video_status.force_reset_failed()) || video_status.reset_failed();
if video_ids_with_resetted_pages.contains(&video_info.id) {
video_status.set(4, 0); // 将"分页下载"重置为 0
video_resetted = true;
}
if video_resetted {
video_info.download_status = video_status.into();
Some(video_info)
} else {
None
}
})
.collect::<Vec<_>>();
let has_video_updates = !resetted_videos_info.is_empty();
let has_page_updates = !resetted_pages_info.is_empty();
if has_video_updates || has_page_updates {
let txn = db.begin().await?;
if has_video_updates {
update_video_download_status(&txn, &resetted_videos_info, Some(500)).await?;
}
if has_page_updates {
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
}
txn.commit().await?;
}
Ok(ApiResponse::ok(ResetAllVideosResponse {
resetted: has_video_updates || has_page_updates,
resetted_videos_count: resetted_videos_info.len(),
resetted_pages_count: resetted_pages_info.len(),
}))
}
pub async fn update_video_status(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
ValidatedJson(request): ValidatedJson<UpdateVideoStatusRequest>,
) -> Result<ApiResponse<UpdateVideoStatusResponse>, ApiError> {
let (video_info, mut pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
)?;
let Some(mut video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());
};
let mut video_status = VideoStatus::from(video_info.download_status);
for update in &request.video_updates {
video_status.set(update.status_index, update.status_value);
}
video_info.download_status = video_status.into();
let mut updated_pages_info = Vec::new();
let mut page_id_map = pages_info
.iter_mut()
.map(|page| (page.id, page))
.collect::<std::collections::HashMap<_, _>>();
for page_update in &request.page_updates {
if let Some(page_info) = page_id_map.remove(&page_update.page_id) {
let mut page_status = PageStatus::from(page_info.download_status);
for update in &page_update.updates {
page_status.set(update.status_index, update.status_value);
}
page_info.download_status = page_status.into();
updated_pages_info.push(page_info);
}
}
let has_video_updates = !request.video_updates.is_empty();
let has_page_updates = !updated_pages_info.is_empty();
if has_video_updates || has_page_updates {
let txn = db.begin().await?;
if has_video_updates {
update_video_download_status(&txn, &[&video_info], None).await?;
}
if has_page_updates {
update_page_download_status(&txn, &updated_pages_info, None).await?;
}
txn.commit().await?;
}
Ok(ApiResponse::ok(UpdateVideoStatusResponse {
success: has_video_updates || has_page_updates,
video: video_info,
pages: pages_info,
}))
}

View File

@@ -0,0 +1,54 @@
use std::collections::VecDeque;
use std::sync::Arc;
use parking_lot::Mutex;
use tokio::sync::broadcast;
use tracing_subscriber::fmt::MakeWriter;
pub const MAX_HISTORY_LOGS: usize = 30;
/// LogHelper 维护了日志发送器和一个日志历史记录的缓冲区
pub struct LogHelper {
pub sender: broadcast::Sender<String>,
pub log_history: Arc<Mutex<VecDeque<String>>>,
}
impl LogHelper {
pub fn new(sender: broadcast::Sender<String>, log_history: Arc<Mutex<VecDeque<String>>>) -> Self {
LogHelper { sender, log_history }
}
}
impl<'a> MakeWriter<'a> for LogHelper {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
impl std::io::Write for LogHelper {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let log_message = String::from_utf8_lossy(buf).to_string();
let _ = self.sender.send(log_message.clone());
let mut history = self.log_history.lock();
history.push_back(log_message);
if history.len() > MAX_HISTORY_LOGS {
history.pop_front();
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Clone for LogHelper {
fn clone(&self) -> Self {
LogHelper {
sender: self.sender.clone(),
log_history: self.log_history.clone(),
}
}
}

View File

@@ -0,0 +1,263 @@
mod log_helper;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use axum::extract::WebSocketUpgrade;
use axum::extract::ws::{Message, WebSocket};
use axum::response::IntoResponse;
use axum::routing::any;
use axum::{Extension, Router};
use dashmap::DashMap;
use futures::stream::{SplitSink, SplitStream};
use futures::{SinkExt, StreamExt, future};
pub use log_helper::{LogHelper, MAX_HISTORY_LOGS};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use sysinfo::{
CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System, get_current_pid,
};
use tokio::pin;
use tokio::task::JoinHandle;
use tokio_stream::wrappers::{BroadcastStream, IntervalStream, WatchStream};
use uuid::Uuid;
use crate::api::response::SysInfo;
use crate::utils::task_notifier::{TASK_STATUS_NOTIFIER, TaskStatus};
static WEBSOCKET_HANDLER: LazyLock<WebSocketHandler> = LazyLock::new(WebSocketHandler::new);
pub(super) fn router() -> Router {
Router::new().route("/ws", any(websocket_handler))
}
async fn websocket_handler(ws: WebSocketUpgrade, Extension(log_writer): Extension<LogHelper>) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, log_writer))
}
// 事件类型枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum EventType {
Logs,
Tasks,
SysInfo,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
enum ClientEvent {
Subscribe(EventType),
Unsubscribe(EventType),
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
enum ServerEvent {
Logs(String),
Tasks(Arc<TaskStatus>),
SysInfo(Arc<SysInfo>),
}
struct WebSocketHandler {
sysinfo_subscribers: Arc<DashMap<Uuid, tokio::sync::mpsc::Sender<ServerEvent>>>,
sysinfo_handles: RwLock<Option<JoinHandle<()>>>,
}
impl WebSocketHandler {
fn new() -> Self {
Self {
sysinfo_subscribers: Arc::new(DashMap::new()),
sysinfo_handles: RwLock::new(None),
}
}
async fn handle_sender(
&self,
mut sender: SplitSink<WebSocket, Message>,
mut rx: tokio::sync::mpsc::Receiver<ServerEvent>,
) {
while let Some(event) = rx.recv().await {
match serde_json::to_string(&event) {
Ok(text) => {
if let Err(e) = sender.send(Message::Text(text.into())).await {
error!("Failed to send message: {:?}", e);
break;
}
}
Err(e) => {
error!("Failed to serialize event: {:?}", e);
}
}
}
}
async fn handle_receiver(
&self,
mut receiver: SplitStream<WebSocket>,
tx: tokio::sync::mpsc::Sender<ServerEvent>,
uuid: Uuid,
log_writer: LogHelper,
) {
// 日志和任务状态的处理本身就是由 stream 驱动的,可以直接为每个 ws 连接维护独立的任务处理器
// 系统信息是服务端轮询然后推送的,如果单独维护会导致每个连接都独立轮询系统信息,造成不必要的浪费
// 因此采用了全局的订阅者管理,所有连接共享同一个系统信息轮询任务
let (mut log_handle, mut task_handle) = (None, None);
while let Some(Ok(msg)) = receiver.next().await {
if let Message::Text(text) = msg {
match serde_json::from_str::<ClientEvent>(&text) {
Ok(ClientEvent::Subscribe(event_type)) => match event_type {
EventType::Logs => {
if log_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
let log_writer_clone = log_writer.clone();
let tx_clone = tx.clone();
let history = log_writer_clone.log_history.lock();
let history_logs: Vec<String> = history.iter().cloned().collect();
drop(history);
log_handle = Some(tokio::spawn(async move {
let rx = log_writer_clone.sender.subscribe();
let log_stream = futures::stream::iter(history_logs.into_iter())
.chain(BroadcastStream::new(rx).filter_map(async |msg| msg.ok()))
.map(|msg| ServerEvent::Logs(msg));
pin!(log_stream);
while let Some(event) = log_stream.next().await {
if let Err(e) = tx_clone.send(event).await {
error!("Failed to send log event: {:?}", e);
break;
}
}
}));
}
}
EventType::Tasks => {
if task_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
let tx_clone = tx.clone();
task_handle = Some(tokio::spawn(async move {
let mut stream = WatchStream::new(TASK_STATUS_NOTIFIER.subscribe())
.map(|status| ServerEvent::Tasks(status));
while let Some(event) = stream.next().await {
if let Err(e) = tx_clone.send(event).await {
error!("Failed to send task status: {:?}", e);
break;
}
}
}));
}
}
EventType::SysInfo => self.add_sysinfo_subscriber(uuid, tx.clone()).await,
},
Ok(ClientEvent::Unsubscribe(event_type)) => match event_type {
EventType::Logs => {
if let Some(handle) = log_handle.take() {
handle.abort();
}
}
EventType::Tasks => {
if let Some(handle) = task_handle.take() {
handle.abort();
}
}
EventType::SysInfo => {
self.remove_sysinfo_subscriber(uuid).await;
}
},
Err(e) => {
error!("Failed to parse client message: {:?}", e);
}
}
}
}
if let Some(handle) = log_handle {
handle.abort();
}
if let Some(handle) = task_handle {
handle.abort();
}
self.remove_sysinfo_subscriber(uuid).await;
}
// 添加订阅者
async fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: tokio::sync::mpsc::Sender<ServerEvent>) {
self.sysinfo_subscribers.insert(uuid, sender);
if self.sysinfo_subscribers.len() > 0
&& self
.sysinfo_handles
.read()
.as_ref()
.is_none_or(|h: &JoinHandle<()>| h.is_finished())
{
let sysinfo_subscribers = self.sysinfo_subscribers.clone();
let mut write_guard = self.sysinfo_handles.write();
if write_guard.as_ref().is_some_and(|h: &JoinHandle<()>| !h.is_finished()) {
return;
}
*write_guard = Some(tokio::spawn(async move {
let mut system = System::new();
let mut disks = Disks::new();
let sys_refresh_kind = sys_refresh_kind();
let disk_refresh_kind = disk_refresh_kind();
// 对于 linux/mac/windows 平台,该方法永远返回 Some(pid)expect 基本是安全的
let self_pid = get_current_pid().expect("Unsupported platform");
let mut stream =
IntervalStream::new(tokio::time::interval(Duration::from_secs(2))).filter_map(move |_| {
system.refresh_specifics(sys_refresh_kind);
disks.refresh_specifics(true, disk_refresh_kind);
let process = match system.process(self_pid) {
Some(p) => p,
None => return futures::future::ready(None),
};
futures::future::ready(Some(SysInfo {
total_memory: system.total_memory(),
used_memory: system.used_memory(),
process_memory: process.memory(),
used_cpu: system.global_cpu_usage(),
process_cpu: process.cpu_usage() / system.cpus().len() as f32,
total_disk: disks.iter().map(|d| d.total_space()).sum(),
available_disk: disks.iter().map(|d| d.available_space()).sum(),
}))
});
while let Some(sys_info) = stream.next().await {
let sys_info = Arc::new(sys_info);
future::join_all(sysinfo_subscribers.iter().map(async |subscriber| {
if let Err(e) = subscriber.send(ServerEvent::SysInfo(sys_info.clone())).await {
error!(
"Failed to send sysinfo event to subscriber {}: {:?}",
subscriber.key(),
e
);
}
}))
.await;
}
}));
}
}
async fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
self.sysinfo_subscribers.remove(&uuid);
if self.sysinfo_subscribers.is_empty() {
if let Some(handle) = self.sysinfo_handles.write().take() {
handle.abort();
}
}
}
}
async fn handle_socket(socket: WebSocket, log_writer: LogHelper) {
let (ws_sender, ws_receiver) = socket.split();
let uuid = Uuid::new_v4();
let (tx, rx) = tokio::sync::mpsc::channel(100);
tokio::spawn(WEBSOCKET_HANDLER.handle_sender(ws_sender, rx));
tokio::spawn(WEBSOCKET_HANDLER.handle_receiver(ws_receiver, tx, uuid, log_writer));
}
fn sys_refresh_kind() -> RefreshKind {
RefreshKind::nothing()
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
.with_memory(MemoryRefreshKind::nothing().with_ram())
.with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory())
}
fn disk_refresh_kind() -> DiskRefreshKind {
DiskRefreshKind::nothing().with_storage()
}

View File

@@ -0,0 +1,119 @@
use std::borrow::Cow;
use anyhow::Error;
use axum::Json;
use axum::extract::rejection::JsonRejection;
use axum::extract::{FromRequest, Request};
use axum::response::IntoResponse;
use reqwest::StatusCode;
use serde::Serialize;
use serde::de::DeserializeOwned;
use validator::Validate;
use crate::api::error::InnerApiError;
#[derive(Serialize)]
pub struct ApiResponse<T: Serialize> {
status_code: u16,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<Cow<'static, str>>,
}
impl<T: Serialize> ApiResponse<T> {
pub fn ok(data: T) -> Self {
Self {
status_code: 200,
data: Some(data),
message: None,
}
}
pub fn bad_request(message: impl Into<Cow<'static, str>>) -> Self {
Self {
status_code: 400,
data: None,
message: Some(message.into()),
}
}
pub fn unauthorized(message: impl Into<Cow<'static, str>>) -> Self {
Self {
status_code: 401,
data: None,
message: Some(message.into()),
}
}
pub fn not_found(message: impl Into<Cow<'static, str>>) -> Self {
Self {
status_code: 404,
data: None,
message: Some(message.into()),
}
}
pub fn internal_server_error(message: impl Into<Cow<'static, str>>) -> Self {
Self {
status_code: 500,
data: None,
message: Some(message.into()),
}
}
}
impl<T: Serialize> IntoResponse for ApiResponse<T> {
fn into_response(self) -> axum::response::Response {
(
StatusCode::from_u16(self.status_code).expect("invalid Http Status Code"),
Json(self),
)
.into_response()
}
}
pub struct ApiError(Error);
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(value: E) -> Self {
Self(value.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
if let Some(inner_error) = self.0.downcast_ref::<InnerApiError>() {
match inner_error {
InnerApiError::NotFound(_) => return ApiResponse::<()>::not_found(self.0.to_string()).into_response(),
InnerApiError::BadRequest(_) => {
return ApiResponse::<()>::bad_request(self.0.to_string()).into_response();
}
}
}
ApiResponse::<()>::internal_server_error(self.0.to_string()).into_response()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ValidatedJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = ApiError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state).await?;
value
.validate()
.map_err(|e| ApiError::from(InnerApiError::BadRequest(e.to_string())))?;
Ok(ValidatedJson(value))
}
}

View File

@@ -1,13 +1,14 @@
use anyhow::{anyhow, bail, Result};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::bilibili::error::BiliError;
use crate::config::VersionedConfig;
pub struct PageAnalyzer {
info: serde_json::Value,
}
#[derive(Debug, strum::FromRepr, PartialEq, PartialOrd, Serialize, Deserialize)]
#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
pub enum VideoQuality {
Quality360p = 16,
Quality480p = 32,
@@ -21,7 +22,7 @@ pub enum VideoQuality {
Quality8k = 127,
}
#[derive(Debug, Clone, Copy, strum::FromRepr, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, strum::FromRepr, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioQuality {
Quality64k = 30216,
Quality132k = 30232,
@@ -30,8 +31,19 @@ pub enum AudioQuality {
Quality192k = 30280,
}
impl Ord for AudioQuality {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_sort_key().cmp(&other.as_sort_key())
}
}
impl PartialOrd for AudioQuality {
fn partial_cmp(&self, other: &AudioQuality) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl AudioQuality {
#[inline]
pub fn as_sort_key(&self) -> isize {
match self {
// 这可以让 Dolby 和 Hi-RES 排在 192k 之后,且 Dolby 和 Hi-RES 之间的顺序不变
@@ -41,14 +53,10 @@ impl AudioQuality {
}
}
impl PartialOrd<AudioQuality> for AudioQuality {
fn partial_cmp(&self, other: &AudioQuality) -> Option<std::cmp::Ordering> {
self.as_sort_key().partial_cmp(&other.as_sort_key())
}
}
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize)]
#[derive(
Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize, Clone,
)]
pub enum VideoCodecs {
#[strum(serialize = "hev")]
HEV,
@@ -58,8 +66,22 @@ pub enum VideoCodecs {
AV1,
}
impl TryFrom<u64> for VideoCodecs {
type Error = anyhow::Error;
fn try_from(value: u64) -> std::result::Result<Self, Self::Error> {
// https://socialsisteryi.github.io/bilibili-API-collect/docs/video/videostream_url.html#%E8%A7%86%E9%A2%91%E7%BC%96%E7%A0%81%E4%BB%A3%E7%A0%81
match value {
7 => Ok(Self::AVC),
12 => Ok(Self::HEV),
13 => Ok(Self::AV1),
_ => bail!("invalid video codecs id: {}", value),
}
}
}
// 视频流的筛选偏好
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct FilterOption {
pub video_max_quality: VideoQuality,
pub video_min_quality: VideoQuality,
@@ -93,27 +115,44 @@ impl Default for FilterOption {
pub enum Stream {
Flv(String),
Html5Mp4(String),
EpositeTryMp4(String),
EpisodeTryMp4(String),
DashVideo {
url: String,
backup_url: Vec<String>,
quality: VideoQuality,
codecs: VideoCodecs,
},
DashAudio {
url: String,
backup_url: Vec<String>,
quality: AudioQuality,
},
}
// 通用的获取流链接的方法,交由 Downloader 使用
impl Stream {
pub fn url(&self) -> &str {
pub fn urls(&self) -> Vec<&str> {
match self {
Self::Flv(url) => url,
Self::Html5Mp4(url) => url,
Self::EpositeTryMp4(url) => url,
Self::DashVideo { url, .. } => url,
Self::DashAudio { url, .. } => url,
Self::Flv(url) | Self::Html5Mp4(url) | Self::EpisodeTryMp4(url) => vec![url],
Self::DashVideo { url, backup_url, .. } | Self::DashAudio { url, backup_url, .. } => {
let mut urls = std::iter::once(url.as_str())
.chain(backup_url.iter().map(|s| s.as_str()))
.collect::<Vec<_>>();
if VersionedConfig::get().load().cdn_sorting {
urls.sort_by_key(|u| {
if u.contains("upos-") {
0 // 服务商 cdn
} else if u.contains("cn-") {
1 // 自建 cdn
} else if u.contains("mcdn") {
2 // mcdn
} else {
3 // pcdn 或者其它
}
});
}
urls
}
}
}
}
@@ -154,7 +193,7 @@ impl PageAnalyzer {
return Ok(vec![Stream::Flv(
self.info["durl"][0]["url"]
.as_str()
.ok_or(anyhow!("invalid flv stream"))?
.context("invalid flv stream")?
.to_string(),
)]);
}
@@ -162,38 +201,35 @@ impl PageAnalyzer {
return Ok(vec![Stream::Html5Mp4(
self.info["durl"][0]["url"]
.as_str()
.ok_or(anyhow!("invalid html5 mp4 stream"))?
.context("invalid html5 mp4 stream")?
.to_string(),
)]);
}
if self.is_episode_try_mp4_stream() {
return Ok(vec![Stream::EpositeTryMp4(
return Ok(vec![Stream::EpisodeTryMp4(
self.info["durl"][0]["url"]
.as_str()
.ok_or(anyhow!("invalid episode try mp4 stream"))?
.context("invalid episode try mp4 stream")?
.to_string(),
)]);
}
let mut streams: Vec<Stream> = Vec::new();
for video in self.info["dash"]["video"]
.as_array()
for video in self
.info
.pointer_mut("/dash/video")
.and_then(|v| v.as_array_mut())
.ok_or(BiliError::RiskControlOccurred)?
.iter()
.iter_mut()
{
let (Some(url), Some(quality), Some(codecs)) = (
let (Some(url), Some(quality), Some(codecs_id)) = (
video["baseUrl"].as_str(),
video["id"].as_u64(),
video["codecs"].as_str(),
video["codecid"].as_u64(),
) else {
continue;
};
let quality = VideoQuality::from_repr(quality as usize).ok_or(anyhow!("invalid video stream quality"))?;
// 从视频流的 codecs 字段中获取编码格式,此处并非精确匹配而是判断包含,比如 codecs 是 av1.42c01e,需要匹配为 av1
let Some(codecs) = [VideoCodecs::HEV, VideoCodecs::AVC, VideoCodecs::AV1]
.into_iter()
.find(|c| codecs.contains(c.as_ref()))
else {
// 少数情况会走到此处,如 codecs 为 dvh1.08.09、hvc1.2.4.L123.90 等,直接跳过,不影响流程
let quality = VideoQuality::from_repr(quality as usize).context("invalid video stream quality")?;
let Ok(codecs) = codecs_id.try_into() else {
continue;
};
if !filter_option.codecs.contains(&codecs)
@@ -206,51 +242,60 @@ impl PageAnalyzer {
}
streams.push(Stream::DashVideo {
url: url.to_string(),
backup_url: serde_json::from_value(video["backupUrl"].take()).unwrap_or_default(),
quality,
codecs,
});
}
if let Some(audios) = self.info["dash"]["audio"].as_array() {
for audio in audios.iter() {
if let Some(audios) = self.info.pointer_mut("/dash/audio").and_then(|a| a.as_array_mut()) {
for audio in audios.iter_mut() {
let (Some(url), Some(quality)) = (audio["baseUrl"].as_str(), audio["id"].as_u64()) else {
continue;
};
let quality =
AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid audio stream quality"))?;
let quality = AudioQuality::from_repr(quality as usize).context("invalid audio stream quality")?;
if quality < filter_option.audio_min_quality || quality > filter_option.audio_max_quality {
continue;
}
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(audio["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
}
let flac = &self.info["dash"]["flac"]["audio"];
if !(filter_option.no_hires || flac.is_null()) {
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
bail!("invalid flac stream");
};
let quality = AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid flac stream quality"))?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
quality,
});
if !filter_option.no_hires {
if let Some(flac) = self.info.pointer_mut("/dash/flac/audio") {
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
bail!("invalid flac stream");
};
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
}
}
let dolby_audio = &self.info["dash"]["dolby"]["audio"][0];
if !(filter_option.no_dolby_audio || dolby_audio.is_null()) {
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
bail!("invalid dolby audio stream");
};
let quality =
AudioQuality::from_repr(quality as usize).ok_or(anyhow!("invalid dolby audio stream quality"))?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
quality,
});
if !filter_option.no_dolby_audio {
if let Some(dolby_audio) = self
.info
.pointer_mut("/dash/dolby/audio/0")
.and_then(|a| a.as_object_mut())
{
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
bail!("invalid dolby audio stream");
};
let quality =
AudioQuality::from_repr(quality as usize).context("invalid dolby audio stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
}
}
Ok(streams)
@@ -261,40 +306,42 @@ impl PageAnalyzer {
if self.is_flv_stream() || self.is_html5_mp4_stream() || self.is_episode_try_mp4_stream() {
// 按照 streams 中的假设,符合这三种情况的流只有一个,直接取
return Ok(BestStream::Mixed(
streams.into_iter().next().ok_or(anyhow!("no stream found"))?,
streams.into_iter().next().context("no stream found")?,
));
}
let (videos, audios): (Vec<Stream>, Vec<Stream>) =
streams.into_iter().partition(|s| matches!(s, Stream::DashVideo { .. }));
Ok(BestStream::VideoAudio {
video: Iterator::max_by(videos.into_iter(), |a, b| match (a, b) {
(
Stream::DashVideo {
quality: a_quality,
codecs: a_codecs,
..
},
Stream::DashVideo {
quality: b_quality,
codecs: b_codecs,
..
},
) => {
if a_quality != b_quality {
return a_quality.partial_cmp(b_quality).unwrap();
};
filter_option
.codecs
.iter()
.position(|c| c == b_codecs)
.cmp(&filter_option.codecs.iter().position(|c| c == a_codecs))
}
_ => unreachable!(),
})
.ok_or(anyhow!("no video stream found"))?,
audio: Iterator::max_by(audios.into_iter(), |a, b| match (a, b) {
video: videos
.into_iter()
.max_by(|a, b| match (a, b) {
(
Stream::DashVideo {
quality: a_quality,
codecs: a_codecs,
..
},
Stream::DashVideo {
quality: b_quality,
codecs: b_codecs,
..
},
) => {
if a_quality != b_quality {
return a_quality.cmp(b_quality);
};
filter_option
.codecs
.iter()
.position(|c| c == b_codecs)
.cmp(&filter_option.codecs.iter().position(|c| c == a_codecs))
}
_ => unreachable!(),
})
.context("no video stream found")?,
audio: audios.into_iter().max_by(|a, b| match (a, b) {
(Stream::DashAudio { quality: a_quality, .. }, Stream::DashAudio { quality: b_quality, .. }) => {
a_quality.partial_cmp(b_quality).unwrap()
a_quality.cmp(b_quality)
}
_ => unreachable!(),
}),
@@ -306,31 +353,35 @@ impl PageAnalyzer {
mod tests {
use super::*;
use crate::bilibili::{BiliClient, Video};
use crate::config::CONFIG;
use crate::config::VersionedConfig;
#[test]
fn test_quality_order() {
assert!([
VideoQuality::Quality360p,
VideoQuality::Quality480p,
VideoQuality::Quality720p,
VideoQuality::Quality1080p,
VideoQuality::Quality1080pPLUS,
VideoQuality::Quality1080p60,
VideoQuality::Quality4k,
VideoQuality::QualityHdr,
VideoQuality::QualityDolby,
VideoQuality::Quality8k
]
.is_sorted());
assert!([
AudioQuality::Quality64k,
AudioQuality::Quality132k,
AudioQuality::Quality192k,
AudioQuality::QualityDolby,
AudioQuality::QualityHiRES,
]
.is_sorted());
assert!(
[
VideoQuality::Quality360p,
VideoQuality::Quality480p,
VideoQuality::Quality720p,
VideoQuality::Quality1080p,
VideoQuality::Quality1080pPLUS,
VideoQuality::Quality1080p60,
VideoQuality::Quality4k,
VideoQuality::QualityHdr,
VideoQuality::QualityDolby,
VideoQuality::Quality8k
]
.is_sorted()
);
assert!(
[
AudioQuality::Quality64k,
AudioQuality::Quality132k,
AudioQuality::Quality192k,
AudioQuality::QualityDolby,
AudioQuality::QualityHiRES,
]
.is_sorted()
);
}
#[ignore = "only for manual test"]
@@ -341,18 +392,41 @@ mod tests {
(
"BV1xRChYUE2R",
VideoQuality::Quality8k,
VideoCodecs::HEV,
Some(AudioQuality::QualityHiRES),
),
// 一个没有声音的纯视频
("BV1J7411H7KQ", VideoQuality::Quality720p, None),
("BV1J7411H7KQ", VideoQuality::Quality720p, VideoCodecs::HEV, None),
// 一个杜比全景声的演示片
(
"BV1Mm4y1P7JV",
VideoQuality::Quality4k,
VideoQuality::QualityDolby,
VideoCodecs::HEV,
Some(AudioQuality::QualityDolby),
),
// 影视飓风的杜比视界视频
(
"BV1HEf2YWEvs",
VideoQuality::QualityDolby,
VideoCodecs::HEV,
Some(AudioQuality::QualityDolby),
),
// 孤独摇滚的杜比视界 + hires + 杜比全景声视频
(
"BV1YDVYzeE39",
VideoQuality::QualityDolby,
VideoCodecs::HEV,
Some(AudioQuality::QualityHiRES),
),
// 一个京紫的 HDR 视频
(
"BV1cZ4y1b7iB",
VideoQuality::QualityHdr,
VideoCodecs::HEV,
Some(AudioQuality::Quality192k),
),
];
for (bvid, video_quality, audio_quality) in testcases.into_iter() {
for (bvid, video_quality, video_codec, audio_quality) in testcases.into_iter() {
let client = BiliClient::new();
let video = Video::new(&client, bvid.to_owned());
let pages = video.get_pages().await.expect("failed to get pages");
@@ -361,15 +435,16 @@ mod tests {
.get_page_analyzer(&first_page)
.await
.expect("failed to get page analyzer")
.best_stream(&CONFIG.filter_option)
.best_stream(&VersionedConfig::get().load().filter_option)
.expect("failed to get best stream");
dbg!(bvid, &best_stream);
match best_stream {
BestStream::VideoAudio {
video: Stream::DashVideo { quality, .. },
video: Stream::DashVideo { quality, codecs, .. },
audio,
} => {
assert_eq!(quality, video_quality);
assert_eq!(codecs, video_codec);
assert_eq!(
audio.map(|audio_stream| match audio_stream {
Stream::DashAudio { quality, .. } => quality,
@@ -382,4 +457,27 @@ mod tests {
}
}
}
#[test]
fn test_url_sort() {
let stream = Stream::DashVideo {
url: "https://xy116x207x155x163xy240ey95dy1010y700yy8dxy.mcdn.bilivideo.cn:4483".to_owned(),
backup_url: vec![
"https://upos-sz-mirrorcos.bilivideo.com".to_owned(),
"https://cn-tj-cu-01-11.bilivideo.com".to_owned(),
"https://xxx.v1d.szbdys.com".to_owned(),
],
quality: VideoQuality::Quality1080p,
codecs: VideoCodecs::AVC,
};
assert_eq!(
stream.urls(),
vec![
"https://upos-sz-mirrorcos.bilivideo.com",
"https://cn-tj-cu-01-11.bilivideo.com",
"https://xy116x207x155x163xy240ey95dy1010y700yy8dxy.mcdn.bilivideo.cn:4483",
"https://xxx.v1d.szbdys.com"
]
);
}
}

View File

@@ -1,13 +1,13 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Result};
use anyhow::Result;
use leaky_bucket::RateLimiter;
use reqwest::{header, Method};
use reqwest::{Method, header};
use sea_orm::DatabaseConnection;
use crate::bilibili::credential::WbiImg;
use crate::bilibili::Credential;
use crate::config::{RateLimit, CONFIG};
use crate::bilibili::credential::WbiImg;
use crate::config::{RateLimit, VersionedCache, VersionedConfig};
// 一个对 reqwest::Client 的简单封装,用于 Bilibili 请求
#[derive(Clone)]
@@ -34,7 +34,7 @@ impl Client {
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap(),
.expect("failed to build reqwest client"),
)
}
@@ -63,55 +63,54 @@ impl Default for Client {
pub struct BiliClient {
pub client: Client,
limiter: Option<RateLimiter>,
limiter: VersionedCache<Option<RateLimiter>>,
}
impl BiliClient {
pub fn new() -> Self {
let client = Client::new();
let limiter = CONFIG
.concurrent_limit
.rate_limit
.as_ref()
.map(|RateLimit { limit, duration }| {
RateLimiter::builder()
.initial(*limit)
.refill(*limit)
.max(*limit)
.interval(Duration::from_millis(*duration))
.build()
});
let limiter = VersionedCache::new(|config| {
Ok(config
.concurrent_limit
.rate_limit
.as_ref()
.map(|RateLimit { limit, duration }| {
RateLimiter::builder()
.initial(*limit)
.refill(*limit)
.max(*limit)
.interval(Duration::from_millis(*duration))
.build()
}))
})
.expect("failed to create rate limiter");
Self { client, limiter }
}
/// 获取一个预构建的请求,通过该方法获取请求时会检查并等待速率限制
pub async fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
if let Some(limiter) = &self.limiter {
if let Some(limiter) = self.limiter.load().as_ref() {
limiter.acquire_one().await;
}
let credential = CONFIG.credential.load();
self.client.request(method, url, credential.as_deref())
let credential = &VersionedConfig::get().load().credential;
self.client.request(method, url, Some(credential))
}
pub async fn check_refresh(&self) -> Result<()> {
let credential = CONFIG.credential.load();
let Some(credential) = credential.as_deref() else {
return Ok(());
};
pub async fn check_refresh(&self, connection: &DatabaseConnection) -> Result<()> {
let credential = &VersionedConfig::get().load().credential;
if !credential.need_refresh(&self.client).await? {
return Ok(());
}
let new_credential = credential.refresh(&self.client).await?;
CONFIG.credential.store(Some(Arc::new(new_credential)));
CONFIG.save()
VersionedConfig::get()
.update_credential(new_credential, connection)
.await?;
Ok(())
}
/// 获取 wbi img用于生成请求签名
pub async fn wbi_img(&self) -> Result<WbiImg> {
let credential = CONFIG.credential.load();
let Some(credential) = credential.as_deref() else {
bail!("no credential found");
};
let credential = &VersionedConfig::get().load().credential;
credential.wbi_img(&self.client).await
}
}

View File

@@ -1,18 +1,19 @@
use std::fmt::{Display, Formatter};
use anyhow::Result;
use async_stream::stream;
use anyhow::{Context, Result, anyhow};
use async_stream::try_stream;
use futures::Stream;
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use crate::bilibili::credential::encoded_query;
use crate::bilibili::{BiliClient, Validate, VideoInfo, MIXIN_KEY};
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
#[derive(PartialEq, Eq, Hash, Clone, Debug, Deserialize, Default, Copy)]
pub enum CollectionType {
Series,
#[default]
Season,
}
@@ -38,8 +39,8 @@ impl From<i32> for CollectionType {
impl Display for CollectionType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
CollectionType::Series => "视频列表",
CollectionType::Season => "视频合集",
CollectionType::Series => "列表",
CollectionType::Season => "合集",
};
write!(f, "{}", s)
}
@@ -54,7 +55,7 @@ pub struct CollectionItem {
pub struct Collection<'a> {
client: &'a BiliClient,
collection: &'a CollectionItem,
pub collection: CollectionItem,
}
#[derive(Debug, PartialEq)]
@@ -93,7 +94,7 @@ impl<'de> Deserialize<'de> for CollectionInfo {
}
impl<'a> Collection<'a> {
pub fn new(client: &'a BiliClient, collection: &'a CollectionItem) -> Self {
pub fn new(client: &'a BiliClient, collection: CollectionItem) -> Self {
Self { client, collection }
}
@@ -133,7 +134,7 @@ impl<'a> Collection<'a> {
("pn", page.as_str()),
("ps", "30"),
],
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
MIXIN_KEY.load().as_deref(),
),
),
CollectionType::Season => (
@@ -146,7 +147,7 @@ impl<'a> Collection<'a> {
("page_num", page.as_str()),
("page_size", "30"),
],
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
MIXIN_KEY.load().as_deref(),
),
),
};
@@ -162,44 +163,57 @@ impl<'a> Collection<'a> {
.validate()
}
pub fn into_simple_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
stream! {
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
try_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 mut videos = self.get_videos(page).await.with_context(|| {
format!(
"failed to get videos of collection {:?} page {}",
self.collection, page
)
})?;
let archives = &mut videos["data"]["archives"];
if archives.as_array().is_none_or(|v| v.is_empty()) {
Err(anyhow!(
"no videos found in collection {:?} page {}",
self.collection,
page
))?;
}
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{
let videos_info: Vec<VideoInfo> = serde_json::from_value(archives.take()).with_context(|| {
format!(
"failed to parse videos of collection {:?} page {}",
self.collection, page
)
})?;
for video_info in videos_info {
yield video_info;
}
let fields = match self.collection.collection_type{
let page_info = &videos["data"]["page"];
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;
let values = fields
.iter()
.map(|f| page_info[f].as_i64())
.collect::<Vec<Option<i64>>>();
if let [Some(num), Some(size), Some(total)] = values[..] {
if num * size < total {
page += 1;
continue;
}
} else {
Err(anyhow!(
"invalid page info of collection {:?} page {}: read {:?} from {}",
self.collection,
page,
fields,
page_info
))?;
}
page += 1;
break;
}
}
}

View File

@@ -1,10 +1,11 @@
use std::borrow::Cow;
use std::collections::HashSet;
use anyhow::{anyhow, bail, Result};
use anyhow::{Context, Result, bail, ensure};
use cookie::Cookie;
use cow_utils::CowUtils;
use regex::Regex;
use reqwest::{header, Method};
use reqwest::{Method, header};
use rsa::pkcs8::DecodePublicKey;
use rsa::sha2::Sha256;
use rsa::{Oaep, RsaPublicKey};
@@ -54,6 +55,7 @@ impl Credential {
.request(Method::GET, "https://api.bilibili.com/x/web-interface/nav", Some(self))
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -74,7 +76,7 @@ impl Credential {
.json::<serde_json::Value>()
.await?
.validate()?;
res["data"]["refresh"].as_bool().ok_or(anyhow!("check refresh failed"))
res["data"]["refresh"].as_bool().context("check refresh failed")
}
pub async fn refresh(&self, client: &Client) -> Result<Self> {
@@ -95,11 +97,13 @@ nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40
JNrRuoEUXpabUzGB8QIDAQAB
-----END PUBLIC KEY-----",
)
.unwrap();
.expect("fail to decode public key");
let ts = chrono::Local::now().timestamp_millis();
let data = format!("refresh_{}", ts).into_bytes();
let mut rng = rand::rngs::OsRng;
let encrypted = key.encrypt(&mut rng, Oaep::new::<Sha256>(), &data).unwrap();
let mut rng = rand::rng();
let encrypted = key
.encrypt(&mut rng, Oaep::new::<Sha256>(), &data)
.expect("fail to encrypt");
hex::encode(encrypted)
}
@@ -150,9 +154,10 @@ JNrRuoEUXpabUzGB8QIDAQAB
.filter_map(|x| Cookie::parse(x).ok())
.filter(|x| required_cookies.contains(x.name()))
.collect();
if cookies.len() != required_cookies.len() {
bail!("not all required cookies found");
}
ensure!(
cookies.len() == required_cookies.len(),
"not all required cookies found"
);
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
@@ -161,10 +166,10 @@ JNrRuoEUXpabUzGB8QIDAQAB
_ => unreachable!(),
}
}
if !res["data"]["refresh_token"].is_string() {
bail!("refresh_token not found");
match res["data"]["refresh_token"].as_str() {
Some(token) => credential.ac_time_value = token.to_string(),
None => bail!("refresh_token not found"),
}
credential.ac_time_value = res["data"]["refresh_token"].as_str().unwrap().to_string();
Ok(credential)
}
@@ -195,9 +200,9 @@ fn regex_find(pattern: &str, doc: &str) -> Result<String> {
let re = Regex::new(pattern)?;
Ok(re
.captures(doc)
.ok_or(anyhow!("pattern not match"))?
.context("no match found")?
.get(1)
.unwrap()
.context("no capture found")?
.as_str()
.to_string())
}
@@ -210,43 +215,45 @@ fn get_filename(url: &str) -> Option<&str> {
pub fn encoded_query<'a>(
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
mixin_key: Option<&str>,
mixin_key: Option<impl AsRef<str>>,
) -> Vec<(&'a str, Cow<'a, str>)> {
match mixin_key {
Some(key) => _encoded_query(params, key, chrono::Local::now().timestamp().to_string()),
Some(key) => _encoded_query(params, key.as_ref(), chrono::Local::now().timestamp().to_string()),
None => params.into_iter().map(|(k, v)| (k, v.into())).collect(),
}
}
#[inline]
fn _encoded_query<'a>(
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
mixin_key: &str,
timestamp: String,
) -> Vec<(&'a str, Cow<'a, str>)> {
let disallowed = ['!', '\'', '(', ')', '*'];
let mut params: Vec<(&'a str, Cow<'a, str>)> = params
.into_iter()
.map(|(k, v)| {
(
k,
// FIXME: 总感觉这里不太好,即使 v 是 &str 也会被转换成 String
v.into()
.chars()
.filter(|&x| !"!'()*".contains(x))
.collect::<String>()
.into(),
match Into::<Cow<'a, str>>::into(v) {
Cow::Borrowed(v) => v.cow_replace(&disallowed[..], ""),
Cow::Owned(v) => v.replace(&disallowed[..], "").into(),
},
)
})
.collect();
params.push(("wts", timestamp.into()));
params.sort_by(|a, b| a.0.cmp(b.0));
let query = serde_urlencoded::to_string(&params).unwrap().replace('+', "%20");
let query = serde_urlencoded::to_string(&params)
.expect("fail to encode query")
.replace('+', "%20");
params.push(("w_rid", format!("{:x}", md5::compute(query.clone() + mixin_key)).into()));
params
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use super::*;
#[test]
@@ -285,20 +292,47 @@ mod tests {
};
let key = Option::<String>::from(key).expect("fail to convert key");
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
assert_eq!(
dbg!(_encoded_query(
// 没有特殊字符
assert_matches!(
&_encoded_query(
vec![("foo", "114"), ("bar", "514"), ("zab", "1919810")],
key.as_str(),
"1702204169".to_string(),
)),
// 上面产生的结果全是 Cow::Owned但 eq 只会比较值,这样写比较方便
vec![
("bar", Cow::Borrowed("514")),
("foo", Cow::Borrowed("114")),
("wts", Cow::Borrowed("1702204169")),
("zab", Cow::Borrowed("1919810")),
("w_rid", Cow::Borrowed("8f6f2b5b3d485fe1886cec6a0be8c5d4")),
]
)[..],
[
("bar", Cow::Borrowed(a)),
("foo", Cow::Borrowed(b)),
("wts", Cow::Owned(c)),
("zab", Cow::Borrowed(d)),
("w_rid", Cow::Owned(e)),
] => {
assert_eq!(*a, "514");
assert_eq!(*b, "114");
assert_eq!(c, "1702204169");
assert_eq!(*d, "1919810");
assert_eq!(e, "8f6f2b5b3d485fe1886cec6a0be8c5d4");
}
);
// 有特殊字符
assert_matches!(
&_encoded_query(
vec![("foo", "'1(1)4'"), ("bar", "!5*1!14"), ("zab", "1919810")],
key.as_str(),
"1702204169".to_string(),
)[..],
[
("bar", Cow::Owned(a)),
("foo", Cow::Owned(b)),
("wts", Cow::Owned(c)),
("zab", Cow::Borrowed(d)),
("w_rid", Cow::Owned(e)),
] => {
assert_eq!(a, "5114");
assert_eq!(b, "114");
assert_eq!(c, "1702204169");
assert_eq!(*d, "1919810");
assert_eq!(e, "6a2c86c4b0648ce062ba0dac2de91a85");
}
);
}
}

View File

@@ -88,14 +88,14 @@ impl fmt::Display for CanvasStyles {
}
}
pub struct AssWriter<W: AsyncWrite> {
pub struct AssWriter<'a, W: AsyncWrite> {
f: Pin<Box<BufWriter<W>>>,
title: String,
canvas_config: CanvasConfig,
canvas_config: CanvasConfig<'a>,
}
impl<W: AsyncWrite> AssWriter<W> {
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Self {
impl<'a, W: AsyncWrite> AssWriter<'a, W> {
pub fn new(f: W, title: String, canvas_config: CanvasConfig<'a>) -> Self {
AssWriter {
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
f: Box::pin(BufWriter::with_capacity(10 << 20, f)),
@@ -104,7 +104,7 @@ impl<W: AsyncWrite> AssWriter<W> {
}
}
pub async fn construct(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
pub async fn construct(f: W, title: String, canvas_config: CanvasConfig<'a>) -> Result<Self> {
let mut res = Self::new(f, title, canvas_config);
res.init().await?;
Ok(res)

View File

@@ -1,5 +1,5 @@
use crate::bilibili::danmaku::canvas::CanvasConfig;
use crate::bilibili::danmaku::Danmu;
use crate::bilibili::danmaku::canvas::CanvasConfig;
pub enum Collision {
// 会越来越远
@@ -18,7 +18,7 @@ pub struct Lane {
}
impl Lane {
pub fn draw(danmu: &Danmu, config: &CanvasConfig) -> Self {
pub fn draw(danmu: &Danmu, config: &CanvasConfig<'_>) -> Self {
Lane {
last_shoot_time: danmu.timeline_s,
last_length: danmu.length(config),
@@ -26,7 +26,7 @@ impl Lane {
}
/// 这个槽位是否可以发射另外一条弹幕,返回可能的情形
pub fn available_for(&self, other: &Danmu, config: &CanvasConfig) -> Collision {
pub fn available_for(&self, other: &Danmu, config: &CanvasConfig<'_>) -> Collision {
#[allow(non_snake_case)]
let T = config.danmaku_option.duration;
#[allow(non_snake_case)]

View File

@@ -5,12 +5,12 @@ use anyhow::Result;
use float_ord::FloatOrd;
use lane::Lane;
use crate::bilibili::PageInfo;
use crate::bilibili::danmaku::canvas::lane::Collision;
use crate::bilibili::danmaku::danmu::DanmuType;
use crate::bilibili::danmaku::{Danmu, DrawEffect, Drawable};
use crate::bilibili::PageInfo;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct DanmakuOption {
pub duration: f64,
pub font: String,
@@ -54,13 +54,13 @@ impl Default for DanmakuOption {
}
#[derive(Clone)]
pub struct CanvasConfig {
pub struct CanvasConfig<'a> {
pub width: u64,
pub height: u64,
pub danmaku_option: &'static DanmakuOption,
pub danmaku_option: &'a DanmakuOption,
}
impl CanvasConfig {
pub fn new(danmaku_option: &'static DanmakuOption, page: &PageInfo) -> Self {
impl<'a> CanvasConfig<'a> {
pub fn new(danmaku_option: &'a DanmakuOption, page: &PageInfo) -> Self {
let (width, height) = Self::dimension(page);
Self {
width,
@@ -86,7 +86,7 @@ impl CanvasConfig {
((720.0 / height as f64 * width as f64) as u64, 720)
}
pub fn canvas(self) -> Canvas {
pub fn canvas(self) -> Canvas<'a> {
let float_lanes_cnt =
(self.danmaku_option.float_percentage * self.height as f64 / self.danmaku_option.lane_size as f64) as usize;
@@ -97,12 +97,12 @@ impl CanvasConfig {
}
}
pub struct Canvas {
pub config: CanvasConfig,
pub struct Canvas<'a> {
pub config: CanvasConfig<'a>,
pub float_lanes: Vec<Option<Lane>>,
}
impl Canvas {
impl<'a> Canvas<'a> {
pub fn draw(&mut self, mut danmu: Danmu) -> Result<Option<Drawable>> {
danmu.timeline_s += self.config.danmaku_option.time_offset;
if danmu.timeline_s < 0.0 {

View File

@@ -1,5 +1,5 @@
//! 一个弹幕实例,但是没有位置信息
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use crate::bilibili::danmaku::canvas::CanvasConfig;
@@ -40,7 +40,7 @@ impl Danmu {
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
///
/// 汉字算一个全宽英文算2/3宽
pub fn length(&self, config: &CanvasConfig) -> f64 {
pub fn length(&self, config: &CanvasConfig<'_>) -> f64 {
let pts = config.danmaku_option.font_size
* self
.content

View File

@@ -3,10 +3,10 @@ use std::path::PathBuf;
use anyhow::Result;
use tokio::fs::{self, File};
use crate::bilibili::PageInfo;
use crate::bilibili::danmaku::canvas::CanvasConfig;
use crate::bilibili::danmaku::{AssWriter, Danmu};
use crate::bilibili::PageInfo;
use crate::config::CONFIG;
use crate::config::VersionedConfig;
pub struct DanmakuWriter<'a> {
page: &'a PageInfo,
@@ -22,7 +22,8 @@ impl<'a> DanmakuWriter<'a> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let canvas_config = CanvasConfig::new(&CONFIG.danmaku_option, self.page);
let config = VersionedConfig::get().load_full();
let canvas_config = CanvasConfig::new(&config.danmaku_option, self.page);
let mut writer =
AssWriter::construct(File::create(path).await?, self.page.name.clone(), canvas_config.clone()).await?;
let mut canvas = canvas_config.canvas();

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use async_stream::stream;
use anyhow::{Context, Result, anyhow};
use async_stream::try_stream;
use futures::Stream;
use serde_json::Value;
@@ -47,7 +47,7 @@ impl<'a> FavoriteList<'a> {
.await
.query(&[
("media_id", self.fid.as_str()),
("pn", &page.to_string()),
("pn", page.to_string().as_str()),
("ps", "20"),
("order", "mtime"),
("type", "0"),
@@ -62,34 +62,31 @@ impl<'a> FavoriteList<'a> {
}
// 拿到收藏夹的所有权,返回一个收藏夹下的视频流
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
stream! {
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
try_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 favorite {} page {}: {}", self.fid, page, e);
break;
},
};
if !videos["data"]["medias"].is_array() {
warn!("no medias found in favorite {} page {}", self.fid, page);
break;
let mut videos = self
.get_videos(page)
.await
.with_context(|| format!("failed to get videos of favorite {} page {}", self.fid, page))?;
let medias = &mut videos["data"]["medias"];
if medias.as_array().is_none_or(|v| v.is_empty()) {
Err(anyhow!("no medias found in favorite {} page {}", self.fid, page))?;
}
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["medias"].take()) {
Ok(v) => v,
Err(e) => {
error!("failed to parse videos of favorite {} page {}: {}", self.fid, page, e);
break;
},
};
for video_info in videos_info{
let videos_info: Vec<VideoInfo> = serde_json::from_value(medias.take())
.with_context(|| format!("failed to parse videos of favorite {} page {}", self.fid, page))?;
for video_info in videos_info {
yield video_info;
}
if videos["data"]["has_more"].is_boolean() && videos["data"]["has_more"].as_bool().unwrap(){
page += 1;
continue;
let has_more = &videos["data"]["has_more"];
if let Some(v) = has_more.as_bool() {
if v {
page += 1;
continue;
}
} else {
Err(anyhow!("has_more is not a bool"))?;
}
break;
}

View File

@@ -0,0 +1,115 @@
use anyhow::{Result, ensure};
use reqwest::Method;
use crate::bilibili::{BiliClient, Validate};
use crate::config::VersionedConfig;
pub struct Me<'a> {
client: &'a BiliClient,
mid: String,
}
impl<'a> Me<'a> {
pub fn new(client: &'a BiliClient) -> Self {
Self {
client,
mid: Self::my_id(),
}
}
pub async fn get_created_favorites(&self) -> Result<Option<Vec<FavoriteItem>>> {
ensure!(!self.mid.is_empty(), "未获取到用户 ID请确保填写设置中的 B 站认证信息");
let mut resp = self
.client
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/created/list-all")
.await
.query(&[("up_mid", &self.mid)])
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(resp["data"]["list"].take())?)
}
pub async fn get_followed_collections(&self, page_num: i32, page_size: i32) -> Result<Collections> {
ensure!(!self.mid.is_empty(), "未获取到用户 ID请确保填写设置中的 B 站认证信息");
let mut resp = self
.client
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/collected/list")
.await
.query(&[
("up_mid", self.mid.as_str()),
("pn", page_num.to_string().as_str()),
("ps", page_size.to_string().as_str()),
("platform", "web"),
])
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(resp["data"].take())?)
}
pub async fn get_followed_uppers(&self, page_num: i32, page_size: i32) -> Result<FollowedUppers> {
ensure!(!self.mid.is_empty(), "未获取到用户 ID请确保填写设置中的 B 站认证信息");
let mut resp = self
.client
.request(Method::GET, "https://api.bilibili.com/x/relation/followings")
.await
.query(&[
("vmid", self.mid.as_str()),
("pn", page_num.to_string().as_str()),
("ps", page_size.to_string().as_str()),
])
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(resp["data"].take())?)
}
fn my_id() -> String {
VersionedConfig::get().load().credential.dedeuserid.clone()
}
}
#[derive(Debug, serde::Deserialize)]
pub struct FavoriteItem {
pub title: String,
pub media_count: i64,
pub id: i64,
pub mid: i64,
}
#[derive(Debug, serde::Deserialize)]
pub struct CollectionItem {
pub id: i64,
pub mid: i64,
pub state: i32,
pub title: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Collections {
pub count: i64,
pub list: Option<Vec<CollectionItem>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct FollowedUppers {
pub total: i64,
pub list: Vec<FollowedUpper>,
}
#[derive(Debug, serde::Deserialize)]
pub struct FollowedUpper {
pub mid: i64,
pub uname: String,
pub face: String,
pub sign: String,
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
pub use analyzer::{BestStream, FilterOption};
use anyhow::{bail, Result};
use anyhow::{Result, bail, ensure};
use arc_swap::ArcSwapOption;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
@@ -12,6 +12,7 @@ pub use danmaku::DanmakuOption;
pub use error::BiliError;
pub use favorite_list::FavoriteList;
use favorite_list::Upper;
pub use me::Me;
use once_cell::sync::Lazy;
pub use submission::Submission;
pub use video::{Dimension, PageInfo, Video};
@@ -24,13 +25,14 @@ mod credential;
mod danmaku;
mod error;
mod favorite_list;
mod me;
mod submission;
mod subtitle;
mod video;
mod watch_later;
static MIXIN_KEY: Lazy<ArcSwapOption<String>> = Lazy::new(Default::default);
#[inline]
pub(crate) fn set_global_mixin_key(key: String) {
MIXIN_KEY.store(Some(Arc::new(key)));
}
@@ -49,9 +51,7 @@ impl Validate for serde_json::Value {
(Some(code), Some(msg)) => (code, msg),
_ => bail!("no code or message found"),
};
if code != 0 {
bail!(BiliError::RequestFailed(code, msg.to_owned()));
}
ensure!(code == 0, BiliError::RequestFailed(code, msg.to_owned()));
Ok(self)
}
}
@@ -63,7 +63,7 @@ impl Validate for serde_json::Value {
/// > 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 {
Detail {
title: String,
bvid: String,
#[serde(rename = "desc")]
@@ -79,8 +79,8 @@ pub enum VideoInfo {
pages: Vec<PageInfo>,
state: i32,
},
/// 从收藏夹获取的视频信息
Detail {
/// 从收藏夹接口获取的视频信息
Favorite {
title: String,
#[serde(rename = "type")]
vtype: i32,
@@ -96,7 +96,7 @@ pub enum VideoInfo {
pubtime: DateTime<Utc>,
attr: i32,
},
/// 从稍后再看获取的视频信息
/// 从稍后再看接口获取的视频信息
WatchLater {
title: String,
bvid: String,
@@ -114,8 +114,8 @@ pub enum VideoInfo {
pubtime: DateTime<Utc>,
state: i32,
},
/// 从视频列表中获取的视频信息
Simple {
/// 从视频合集/视频列表接口获取的视频信息
Collection {
bvid: String,
#[serde(rename = "pic")]
cover: String,
@@ -124,6 +124,7 @@ pub enum VideoInfo {
#[serde(rename = "pubdate", with = "ts_seconds")]
pubtime: DateTime<Utc>,
},
// 从用户投稿接口获取的视频信息
Submission {
title: String,
bvid: String,
@@ -138,7 +139,7 @@ pub enum VideoInfo {
#[cfg(test)]
mod tests {
use futures::{pin_mut, StreamExt};
use futures::StreamExt;
use super::*;
use crate::utils::init_logger;
@@ -146,35 +147,80 @@ mod tests {
#[ignore = "only for manual test"]
#[tokio::test]
async fn test_video_info_type() {
init_logger("None,bili_sync=debug");
init_logger("None,bili_sync=debug", None);
let bili_client = BiliClient::new();
// 请求 UP 主视频必须要获取 mixin key使用 key 计算请求参数的签名,否则直接提示权限不足返回空
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
panic!("获取 mixin key 失败");
};
set_global_mixin_key(mixin_key);
let video = Video::new(&bili_client, "BV1Z54y1C7ZB".to_string());
assert!(matches!(video.get_view_info().await, Ok(VideoInfo::View { .. })));
let collection_item = CollectionItem {
mid: "521722088".to_string(),
sid: "387214".to_string(),
collection_type: CollectionType::Series,
};
let collection = Collection::new(&bili_client, &collection_item);
let stream = collection.into_simple_video_stream();
pin_mut!(stream);
assert!(matches!(stream.next().await, Some(VideoInfo::Simple { .. })));
let favorite = FavoriteList::new(&bili_client, "3084505258".to_string());
let stream = favorite.into_video_stream();
pin_mut!(stream);
assert!(matches!(stream.next().await, Some(VideoInfo::Detail { .. })));
let collection = Collection::new(
&bili_client,
CollectionItem {
mid: "521722088".to_string(),
sid: "4523".to_string(),
collection_type: CollectionType::Season,
},
);
let videos = collection
.into_video_stream()
.take(20)
.filter_map(|v| futures::future::ready(v.ok()))
.collect::<Vec<_>>()
.await;
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Collection { .. })));
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
// 测试收藏夹
let favorite = FavoriteList::new(&bili_client, "3144336058".to_string());
let videos = favorite
.into_video_stream()
.take(20)
.filter_map(|v| futures::future::ready(v.ok()))
.collect::<Vec<_>>()
.await;
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Favorite { .. })));
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
// 测试稍后再看
let watch_later = WatchLater::new(&bili_client);
let stream = watch_later.into_video_stream();
pin_mut!(stream);
assert!(matches!(stream.next().await, Some(VideoInfo::WatchLater { .. })));
let videos = watch_later
.into_video_stream()
.take(20)
.filter_map(|v| futures::future::ready(v.ok()))
.collect::<Vec<_>>()
.await;
assert!(videos.iter().all(|v| matches!(v, VideoInfo::WatchLater { .. })));
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
// 测试投稿
let submission = Submission::new(&bili_client, "956761".to_string());
let stream = submission.into_video_stream();
pin_mut!(stream);
assert!(matches!(stream.next().await, Some(VideoInfo::Submission { .. })));
let videos = submission
.into_video_stream()
.take(20)
.filter_map(|v| futures::future::ready(v.ok()))
.collect::<Vec<_>>()
.await;
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Submission { .. })));
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
}
#[ignore = "only for manual test"]
#[tokio::test]
async fn test_subtitle_parse() -> Result<()> {
let bili_client = BiliClient::new();
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
panic!("获取 mixin key 失败");
};
set_global_mixin_key(mixin_key);
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string());
let pages = video.get_pages().await?;
println!("pages: {:?}", pages);
let subtitles = video.get_subtitles(&pages[0]).await?;
for subtitle in subtitles {
println!(
"{}: {}",
subtitle.lan,
subtitle.body.to_string().chars().take(200).collect::<String>()
);
}
Ok(())
}
}

View File

@@ -1,16 +1,15 @@
use anyhow::Result;
use arc_swap::access::Access;
use async_stream::stream;
use anyhow::{Context, Result, anyhow};
use async_stream::try_stream;
use futures::Stream;
use reqwest::Method;
use serde_json::Value;
use crate::bilibili::credential::encoded_query;
use crate::bilibili::favorite_list::Upper;
use crate::bilibili::{BiliClient, Validate, VideoInfo, MIXIN_KEY};
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
pub struct Submission<'a> {
client: &'a BiliClient,
upper_id: String,
pub upper_id: String,
}
impl<'a> Submission<'a> {
@@ -39,15 +38,15 @@ impl<'a> Submission<'a> {
.await
.query(&encoded_query(
vec![
("mid", self.upper_id.clone()),
("order", "pubdate".to_string()),
("order_avoided", "true".to_string()),
("platform", "web".to_string()),
("web_location", "1550101".to_string()),
("pn", page.to_string()),
("ps", "30".to_string()),
("mid", self.upper_id.as_str()),
("order", "pubdate"),
("order_avoided", "true"),
("platform", "web"),
("web_location", "1550101"),
("pn", page.to_string().as_str()),
("ps", "30"),
],
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
MIXIN_KEY.load().as_deref(),
))
.send()
.await?
@@ -57,34 +56,31 @@ impl<'a> Submission<'a> {
.validate()
}
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
stream! {
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
try_stream! {
let mut page = 1;
loop {
let mut videos = match self.get_videos(page).await {
Ok(v) => v,
Err(e) => {
error!("failed to get videos of upper {} page {}: {}", self.upper_id, page, e);
break;
},
};
if !videos["data"]["list"]["vlist"].is_array() {
warn!("no medias found in upper {} page {}", self.upper_id, page);
break;
let mut videos = self
.get_videos(page)
.await
.with_context(|| format!("failed to get videos of upper {} page {}", self.upper_id, page))?;
let vlist = &mut videos["data"]["list"]["vlist"];
if vlist.as_array().is_none_or(|v| v.is_empty()) {
Err(anyhow!("no medias found in upper {} page {}", self.upper_id, page))?;
}
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["list"]["vlist"].take()) {
Ok(v) => v,
Err(e) => {
error!("failed to parse videos of upper {} page {}: {}", self.upper_id, page, e);
break;
},
};
for video_info in videos_info{
let videos_info: Vec<VideoInfo> = serde_json::from_value(vlist.take())
.with_context(|| format!("failed to parse videos of upper {} page {}", self.upper_id, page))?;
for video_info in videos_info {
yield video_info;
}
if videos["data"]["page"]["count"].is_i64() && videos["data"]["page"]["count"].as_i64().unwrap() > (page * 30) as i64 {
page += 1;
continue;
let count = &videos["data"]["page"]["count"];
if let Some(v) = count.as_i64() {
if v > (page * 30) as i64 {
page += 1;
continue;
}
} else {
Err(anyhow!("count is not an i64"))?;
}
break;
}

View File

@@ -0,0 +1,75 @@
use std::fmt::Display;
#[derive(Debug, serde::Deserialize)]
pub struct SubTitlesInfo {
pub subtitles: Vec<SubTitleInfo>,
}
#[derive(Debug, serde::Deserialize)]
pub struct SubTitleInfo {
pub lan: String,
pub subtitle_url: String,
}
pub struct SubTitle {
pub lan: String,
pub body: SubTitleBody,
}
#[derive(Debug, serde::Deserialize)]
pub struct SubTitleBody(pub Vec<SubTitleItem>);
#[derive(Debug, serde::Deserialize)]
pub struct SubTitleItem {
from: f64,
to: f64,
content: String,
}
impl SubTitleInfo {
pub fn is_ai_sub(&self) -> bool {
// ai aisubtitle.hdslb.com/bfs/ai_subtitle/xxxx
// 非 ai aisubtitle.hdslb.com/bfs/subtitle/xxxx
self.subtitle_url.contains("ai_subtitle")
}
}
impl Display for SubTitleBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (idx, item) in self.0.iter().enumerate() {
writeln!(f, "{}", idx)?;
writeln!(f, "{} --> {}", format_time(item.from), format_time(item.to))?;
writeln!(f, "{}", item.content)?;
writeln!(f)?;
}
Ok(())
}
}
fn format_time(time: f64) -> String {
let (second, millisecond) = (time.trunc(), (time.fract() * 1e3) as u32);
let (hour, minute, second) = (
(second / 3600.0) as u32,
((second % 3600.0) / 60.0) as u32,
(second % 60.0) as u32,
);
format!("{:02}:{:02}:{:02},{:03}", hour, minute, second, millisecond)
}
#[cfg(test)]
mod tests {
#[test]
fn test_format_time() {
// float 解析会有精度问题,但误差几毫秒应该不太关键
// 想再健壮一点就得手写 serde_json 解析拆分秒和毫秒,然后分别处理了
let testcases = [
(0.0, "00:00:00,000"),
(1.5, "00:00:01,500"),
(206.45, "00:03:26,449"),
(360001.23, "100:00:01,229"),
];
for (time, expect) in testcases.iter() {
assert_eq!(super::format_time(*time), *expect);
}
}
}

View File

@@ -1,6 +1,6 @@
use anyhow::{bail, Result};
use futures::stream::FuturesUnordered;
use anyhow::{Result, ensure};
use futures::TryStreamExt;
use futures::stream::FuturesUnordered;
use prost::Message;
use reqwest::Method;
@@ -8,20 +8,11 @@ use crate::bilibili::analyzer::PageAnalyzer;
use crate::bilibili::client::BiliClient;
use crate::bilibili::credential::encoded_query;
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
use crate::bilibili::{Validate, VideoInfo, MIXIN_KEY};
static MASK_CODE: u64 = 2251799813685247;
static XOR_CODE: u64 = 23442827791579;
static BASE: u64 = 58;
static DATA: &[char] = &[
'F', 'c', 'w', 'A', 'P', 'N', 'K', 'T', 'M', 'u', 'g', '3', 'G', 'V', '5', 'L', 'j', '7', 'E', 'J', 'n', 'H', 'p',
'W', 's', 'x', '4', 't', 'b', '8', 'h', 'a', 'Y', 'e', 'v', 'i', 'q', 'B', 'z', '6', 'r', 'k', 'C', 'y', '1', '2',
'm', 'U', 'S', 'D', 'Q', 'X', '9', 'R', 'd', 'o', 'Z', 'f',
];
use crate::bilibili::subtitle::{SubTitle, SubTitleBody, SubTitleInfo, SubTitlesInfo};
use crate::bilibili::{MIXIN_KEY, Validate, VideoInfo};
pub struct Video<'a> {
client: &'a BiliClient,
pub aid: String,
pub bvid: String,
}
@@ -58,17 +49,16 @@ pub struct Dimension {
impl<'a> Video<'a> {
pub fn new(client: &'a BiliClient, bvid: String) -> Self {
let aid = bvid_to_aid(&bvid).to_string();
Self { client, aid, bvid }
Self { client, bvid }
}
/// 直接调用视频信息接口获取详细的视频信息
/// 直接调用视频信息接口获取详细的视频信息,视频信息中包含了视频的分页信息
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")
.await
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
@@ -78,12 +68,13 @@ impl<'a> Video<'a> {
Ok(serde_json::from_value(res["data"].take())?)
}
#[allow(dead_code)]
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
let mut res = self
.client
.request(Method::GET, "https://api.bilibili.com/x/player/pagelist")
.await
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
@@ -98,7 +89,7 @@ impl<'a> Video<'a> {
.client
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
.await
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
@@ -110,7 +101,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 {
for i in 1..=page.duration.div_ceil(360) {
tasks.push(self.get_danmaku_segment(page, i as i64));
}
let result: Vec<Vec<DanmakuElem>> = tasks.try_collect().await?;
@@ -130,13 +121,12 @@ impl<'a> Video<'a> {
.error_for_status()?;
let headers = std::mem::take(res.headers_mut());
let content_type = headers.get("content-type");
if content_type.is_none_or(|v| v != "application/octet-stream") {
bail!(
"unexpected content type: {:?}, body: {:?}",
content_type,
res.text().await
);
}
ensure!(
content_type.is_some_and(|v| v == "application/octet-stream"),
"unexpected content type: {:?}, body: {:?}",
content_type,
res.text().await
);
Ok(DmSegMobileReply::decode(res.bytes().await?)?.elems)
}
@@ -147,14 +137,14 @@ impl<'a> Video<'a> {
.await
.query(&encoded_query(
vec![
("avid", self.aid.as_str()),
("bvid", self.bvid.as_str()),
("cid", page.cid.to_string().as_str()),
("qn", "127"),
("otype", "json"),
("fnval", "4048"),
("fourk", "1"),
],
MIXIN_KEY.load().as_deref().map(|x| x.as_str()),
MIXIN_KEY.load().as_deref(),
))
.send()
.await?
@@ -164,27 +154,44 @@ impl<'a> Video<'a> {
.validate()?;
Ok(PageAnalyzer::new(res["data"].take()))
}
}
fn bvid_to_aid(bvid: &str) -> u64 {
let mut bvid = bvid.chars().collect::<Vec<_>>();
(bvid[3], bvid[9]) = (bvid[9], bvid[3]);
(bvid[4], bvid[7]) = (bvid[7], bvid[4]);
let mut tmp = 0u64;
for char in bvid.into_iter().skip(3) {
let idx = DATA.iter().position(|&x| x == char).unwrap();
tmp = tmp * BASE + idx as u64;
pub async fn get_subtitles(&self, page: &PageInfo) -> Result<Vec<SubTitle>> {
let mut res = self
.client
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2")
.await
.query(&encoded_query(
vec![("cid", &page.cid.to_string()), ("bvid", &self.bvid)],
MIXIN_KEY.load().as_deref(),
))
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
// 接口返回的信息,包含了一系列的字幕,每个字幕包含了字幕的语言和 json 下载地址
let subtitles_info: SubTitlesInfo = serde_json::from_value(res["data"]["subtitle"].take())?;
let tasks = subtitles_info
.subtitles
.into_iter()
.filter(|v| !v.is_ai_sub())
.map(|v| self.get_subtitle(v))
.collect::<FuturesUnordered<_>>();
tasks.try_collect().await
}
(tmp & MASK_CODE) ^ XOR_CODE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bvid_to_aid() {
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
async fn get_subtitle(&self, info: SubTitleInfo) -> Result<SubTitle> {
let mut res = self
.client
.client // 这里可以直接使用 inner_client因为该请求不需要鉴权
.request(Method::GET, format!("https:{}", &info.subtitle_url).as_str(), None)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?;
let body: SubTitleBody = serde_json::from_value(res["body"].take())?;
Ok(SubTitle { lan: info.lan, body })
}
}

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use async_stream::stream;
use anyhow::{Context, Result, anyhow};
use async_stream::try_stream;
use futures::Stream;
use serde_json::Value;
@@ -25,24 +25,20 @@ impl<'a> WatchLater<'a> {
.validate()
}
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
stream! {
let Ok(mut videos) = self.get_videos().await else {
error!("Failed to get watch later list");
return;
};
if !videos["data"]["list"].is_array() {
error!("Watch later list is not an array");
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
try_stream! {
let mut videos = self
.get_videos()
.await
.with_context(|| "Failed to get watch later list")?;
let list = &mut videos["data"]["list"];
if list.as_array().is_none_or(|v| v.is_empty()) {
Err(anyhow!("No videos found in watch later list"))?;
}
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["list"].take()) {
Ok(v) => v,
Err(e) => {
error!("Failed to parse watch later list: {}", e);
return;
}
};
for video in videos_info {
yield video;
let videos_info: Vec<VideoInfo> =
serde_json::from_value(list.take()).with_context(|| "Failed to parse watch later list")?;
for video_info in videos_info {
yield video_info;
}
}
}

View File

@@ -0,0 +1,44 @@
use std::borrow::Cow;
use std::sync::LazyLock;
use clap::Parser;
pub static ARGS: LazyLock<Args> = LazyLock::new(Args::parse);
#[derive(Parser)]
#[command(name = "Bili-Sync", version = detail_version(), about, long_about = None)]
pub struct Args {
#[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,
}
mod built_info {
include!(concat!(env!("OUT_DIR"), "/built.rs"));
}
pub fn version() -> Cow<'static, str> {
if let (Some(git_version), Some(git_dirty)) = (built_info::GIT_VERSION, built_info::GIT_DIRTY) {
Cow::Owned(format!("{}{}", git_version, if git_dirty { "-dirty" } else { "" }))
} else {
Cow::Borrowed(built_info::PKG_VERSION)
}
}
fn detail_version() -> String {
format!(
"{}
Architecture: {}-{}
Author: {}
Built Time: {}
Rustc Version: {}",
version(),
built_info::CFG_OS,
built_info::CFG_TARGET_ARCH,
built_info::PKG_AUTHORS,
built_info::BUILT_TIME_UTC,
built_info::RUSTC_VERSION,
)
}

View File

@@ -1,11 +0,0 @@
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,129 @@
use std::path::PathBuf;
use std::sync::LazyLock;
use anyhow::{Result, bail};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
use crate::config::LegacyConfig;
use crate::config::default::{default_auth_token, default_bind_address, default_time_format};
use crate::config::item::{ConcurrentLimit, NFOTimeType};
use crate::utils::model::{load_db_config, save_db_config};
pub static CONFIG_DIR: LazyLock<PathBuf> =
LazyLock::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct Config {
pub auth_token: String,
pub bind_address: String,
pub credential: Credential,
pub filter_option: FilterOption,
pub danmaku_option: DanmakuOption,
pub video_name: String,
pub page_name: String,
pub interval: u64,
pub upper_path: PathBuf,
pub nfo_time_type: NFOTimeType,
pub concurrent_limit: ConcurrentLimit,
pub time_format: String,
pub cdn_sorting: bool,
pub version: u64,
}
impl Config {
pub async fn load_from_database(connection: &DatabaseConnection) -> Result<Option<Result<Self>>> {
load_db_config(connection).await
}
pub async fn save_to_database(&self, connection: &DatabaseConnection) -> Result<()> {
save_db_config(self, connection).await
}
pub fn check(&self) -> Result<()> {
let mut errors = Vec::new();
if !self.upper_path.is_absolute() {
errors.push("up 主头像保存的路径应为绝对路径");
}
if self.video_name.is_empty() {
errors.push("未设置 video_name 模板");
}
if self.page_name.is_empty() {
errors.push("未设置 page_name 模板");
}
let credential = &self.credential;
if credential.sessdata.is_empty()
|| credential.bili_jct.is_empty()
|| credential.buvid3.is_empty()
|| credential.dedeuserid.is_empty()
|| credential.ac_time_value.is_empty()
{
errors.push("Credential 信息不完整,请确保填写完整");
}
if !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
errors.push("video 和 page 允许的并发数必须大于 0");
}
if !errors.is_empty() {
bail!(
errors
.into_iter()
.map(|e| format!("- {}", e))
.collect::<Vec<_>>()
.join("\n")
);
}
Ok(())
}
#[cfg(test)]
pub(super) fn test_default() -> Self {
Self {
cdn_sorting: true,
..Default::default()
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
auth_token: default_auth_token(),
bind_address: default_bind_address(),
credential: Credential::default(),
filter_option: FilterOption::default(),
danmaku_option: DanmakuOption::default(),
video_name: "{{title}}".to_owned(),
page_name: "{{bvid}}".to_owned(),
interval: 1200,
upper_path: CONFIG_DIR.join("upper_face"),
nfo_time_type: NFOTimeType::FavTime,
concurrent_limit: ConcurrentLimit::default(),
time_format: default_time_format(),
cdn_sorting: false,
version: 0,
}
}
}
impl From<LegacyConfig> for Config {
fn from(legacy: LegacyConfig) -> Self {
Self {
auth_token: legacy.auth_token,
bind_address: legacy.bind_address,
credential: legacy.credential,
filter_option: legacy.filter_option,
danmaku_option: legacy.danmaku_option,
video_name: legacy.video_name,
page_name: legacy.page_name,
interval: legacy.interval,
upper_path: legacy.upper_path,
nfo_time_type: legacy.nfo_time_type,
concurrent_limit: legacy.concurrent_limit,
time_format: legacy.time_format,
cdn_sorting: legacy.cdn_sorting,
version: 0,
}
}
}

View File

@@ -0,0 +1,18 @@
use rand::seq::IndexedRandom;
pub(super) fn default_time_format() -> String {
"%Y-%m-%d".to_string()
}
/// 默认的 auth_token 实现,生成随机 16 位字符串
pub(super) fn default_auth_token() -> String {
let byte_choices = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=";
let mut rng = rand::rng();
(0..16)
.map(|_| *(byte_choices.choose(&mut rng).expect("choose byte failed")) as char)
.collect()
}
pub(super) fn default_bind_address() -> String {
"0.0.0.0:12345".to_string()
}

View File

@@ -1,84 +0,0 @@
use std::path::PathBuf;
use clap::Parser;
use handlebars::handlebars_helper;
use once_cell::sync::Lazy;
use crate::config::clap::Args;
use crate::config::item::PathSafeTemplate;
use crate::config::Config;
/// 全局的 CONFIG可以从中读取配置信息
pub static CONFIG: Lazy<Config> = Lazy::new(load_config);
/// 全局的 TEMPLATE用来渲染 video_name 和 page_name 模板
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
let mut handlebars = handlebars::Handlebars::new();
handlebars_helper!(truncate: |s: String, len: usize| {
if s.chars().count() > len {
s.chars().take(len).collect::<String>()
} else {
s.to_string()
}
});
handlebars.register_helper("truncate", Box::new(truncate));
handlebars.path_safe_register("video", &CONFIG.video_name).unwrap();
handlebars.path_safe_register("page", &CONFIG.page_name).unwrap();
handlebars
});
/// 全局的 ARGS用来解析命令行参数
pub static ARGS: Lazy<Args> = Lazy::new(Args::parse);
/// 全局的 CONFIG_DIR表示配置文件夹的路径
pub static CONFIG_DIR: Lazy<PathBuf> =
Lazy::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
#[cfg(not(test))]
#[inline]
fn load_config() -> Config {
let config = Config::load().unwrap_or_else(|err| {
if err
.downcast_ref::<std::io::Error>()
.is_none_or(|e| e.kind() != std::io::ErrorKind::NotFound)
{
panic!("加载配置文件失败,错误为: {err}");
}
warn!("配置文件不存在,使用默认配置...");
Config::default()
});
// 放到外面,确保新的配置项被保存
info!("配置加载完毕,覆盖刷新原有配置");
config.save().unwrap();
// 检查配置文件内容
info!("校验配置文件内容...");
config.check();
config
}
#[cfg(test)]
#[inline]
fn load_config() -> Config {
let credential = match (
std::env::var("TEST_SESSDATA"),
std::env::var("TEST_BILI_JCT"),
std::env::var("TEST_BUVID3"),
std::env::var("TEST_DEDEUSERID"),
std::env::var("TEST_AC_TIME_VALUE"),
) {
(Ok(sessdata), Ok(bili_jct), Ok(buvid3), Ok(dedeuserid), Ok(ac_time_value)) => {
Some(std::sync::Arc::new(crate::bilibili::Credential {
sessdata,
bili_jct,
buvid3,
dedeuserid,
ac_time_value,
}))
}
_ => None,
};
Config {
credential: arc_swap::ArcSwapOption::from(credential),
..Default::default()
}
}

View File

@@ -0,0 +1,91 @@
use std::sync::LazyLock;
use anyhow::Result;
use handlebars::handlebars_helper;
use crate::config::versioned_cache::VersionedCache;
use crate::config::{Config, PathSafeTemplate};
pub static TEMPLATE: LazyLock<VersionedCache<handlebars::Handlebars<'static>>> =
LazyLock::new(|| VersionedCache::new(create_template).expect("Failed to create handlebars template"));
fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
let mut handlebars = handlebars::Handlebars::new();
handlebars.register_helper("truncate", Box::new(truncate));
handlebars.path_safe_register("video", config.video_name.to_owned())?;
handlebars.path_safe_register("page", config.page_name.to_owned())?;
Ok(handlebars)
}
handlebars_helper!(truncate: |s: String, len: usize| {
if s.chars().count() > len {
s.chars().take(len).collect::<String>()
} else {
s.to_string()
}
});
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_template_usage() {
let mut template = handlebars::Handlebars::new();
template.register_helper("truncate", Box::new(truncate));
let _ = template.path_safe_register("video", "test{{bvid}}test");
let _ = template.path_safe_register("test_truncate", "哈哈,{{ truncate title 30 }}");
let _ = template.path_safe_register("test_path_unix", "{{ truncate title 7 }}/test/a");
let _ = template.path_safe_register("test_path_windows", r"{{ truncate title 7 }}\\test\\a");
#[cfg(not(windows))]
{
assert_eq!(
template
.path_safe_render("test_path_unix", &json!({"title": "关注/永雏塔菲喵"}))
.unwrap(),
"关注_永雏塔菲/test/a"
);
assert_eq!(
template
.path_safe_render("test_path_windows", &json!({"title": "关注/永雏塔菲喵"}))
.unwrap(),
"关注_永雏塔菲_test_a"
);
}
#[cfg(windows)]
{
assert_eq!(
template
.path_safe_render("test_path_unix", &json!({"title": "关注/永雏塔菲喵"}))
.unwrap(),
"关注_永雏塔菲_test_a"
);
assert_eq!(
template
.path_safe_render("test_path_windows", &json!({"title": "关注/永雏塔菲喵"}))
.unwrap(),
r"关注_永雏塔菲\\test\\a"
);
}
assert_eq!(
template
.path_safe_render("video", &json!({"bvid": "BV1b5411h7g7"}))
.unwrap(),
"testBV1b5411h7g7test"
);
assert_eq!(
template
.path_safe_render(
"test_truncate",
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
编译将发生在一个被称作「Cargo」的构建系统中。在这里被引用的指针将被授予「生命周期」之力导引对象安全。\
你将扮演一位名为「Rustacean」的神秘角色, 在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
征服她们、通过编译同时逐步发掘「C++」程序崩溃的真相。"})
)
.unwrap(),
"哈哈,你说得对,但是 Rust 是由 Mozilla 自主研发的一"
);
}
}

View File

@@ -1,12 +1,8 @@
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use crate::bilibili::{CollectionItem, CollectionType};
use crate::utils::filenamify::filenamify;
/// 稍后再看的配置
@@ -17,7 +13,7 @@ pub struct WatchLaterConfig {
}
/// NFO 文件使用的时间类型
#[derive(Serialize, Deserialize, Default)]
#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
pub enum NFOTimeType {
#[default]
@@ -26,14 +22,33 @@ pub enum NFOTimeType {
}
/// 并发下载相关的配置
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct ConcurrentLimit {
pub video: usize,
pub page: usize,
pub rate_limit: Option<RateLimit>,
#[serde(default)]
pub download: ConcurrentDownloadLimit,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct ConcurrentDownloadLimit {
pub enable: bool,
pub concurrency: usize,
pub threshold: u64,
}
impl Default for ConcurrentDownloadLimit {
fn default() -> Self {
Self {
enable: true,
concurrency: 4,
threshold: 20 * (1 << 20), // 20 MB
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RateLimit {
pub limit: usize,
pub duration: u64,
@@ -49,18 +64,20 @@ impl Default for ConcurrentLimit {
limit: 4,
duration: 250,
}),
download: ConcurrentDownloadLimit::default(),
}
}
}
pub trait PathSafeTemplate {
fn path_safe_register(&mut self, name: &'static str, template: &'static str) -> Result<()>;
fn path_safe_register(&mut self, name: &'static str, template: impl Into<String>) -> Result<()>;
fn path_safe_render(&self, name: &'static str, data: &serde_json::Value) -> Result<String>;
}
/// 通过将模板字符串中的分隔符替换为自定义的字符串,使得模板字符串中的分隔符得以保留
impl PathSafeTemplate for handlebars::Handlebars<'_> {
fn path_safe_register(&mut self, name: &'static str, template: &'static str) -> Result<()> {
fn path_safe_register(&mut self, name: &'static str, template: impl Into<String>) -> Result<()> {
let template = template.into();
Ok(self.register_template_string(name, template.replace(std::path::MAIN_SEPARATOR_STR, "__SEP__"))?)
}
@@ -68,72 +85,3 @@ impl PathSafeTemplate for handlebars::Handlebars<'_> {
Ok(filenamify(&self.render(name, data)?).replace("__SEP__", std::path::MAIN_SEPARATOR_STR))
}
}
/* 后面是用于自定义 Collection 的序列化、反序列化的样板代码 */
pub(super) fn serialize_collection_list<S>(
collection_list: &HashMap<CollectionItem, PathBuf>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(collection_list.len()))?;
for (k, v) in collection_list {
let prefix = match k.collection_type {
CollectionType::Series => "series",
CollectionType::Season => "season",
};
map.serialize_entry(&[prefix, &k.mid, &k.sid].join(":"), v)?;
}
map.end()
}
pub(super) fn deserialize_collection_list<'de, D>(deserializer: D) -> Result<HashMap<CollectionItem, PathBuf>, D::Error>
where
D: Deserializer<'de>,
{
struct CollectionListVisitor;
impl<'de> Visitor<'de> for CollectionListVisitor {
type Value = HashMap<CollectionItem, PathBuf>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a map of collection list")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut collection_list = HashMap::new();
while let Some((key, value)) = map.next_entry::<String, PathBuf>()? {
let collection_item = match key.split(':').collect::<Vec<&str>>().as_slice() {
[prefix, mid, sid] => {
let collection_type = match *prefix {
"series" => CollectionType::Series,
"season" => CollectionType::Season,
_ => {
return Err(serde::de::Error::custom(
"invalid collection type, should be series or season",
))
}
};
CollectionItem {
mid: mid.to_string(),
sid: sid.to_string(),
collection_type,
}
}
_ => {
return Err(serde::de::Error::custom(
"invalid collection key, should be series:mid:sid or season:mid:sid",
))
}
};
collection_list.insert(collection_item, value);
}
Ok(collection_list)
}
}
deserializer.deserialize_map(CollectionListVisitor)
}

View File

@@ -0,0 +1,134 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use sea_orm::DatabaseConnection;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use crate::bilibili::{CollectionItem, CollectionType, Credential, DanmakuOption, FilterOption};
use crate::config::Config;
use crate::config::default::{default_auth_token, default_bind_address, default_time_format};
use crate::config::item::{ConcurrentLimit, NFOTimeType, WatchLaterConfig};
use crate::utils::model::migrate_legacy_config;
#[derive(Serialize, Deserialize)]
pub struct LegacyConfig {
#[serde(default = "default_auth_token")]
pub auth_token: String,
#[serde(default = "default_bind_address")]
pub bind_address: String,
pub credential: Credential,
pub filter_option: FilterOption,
#[serde(default)]
pub danmaku_option: DanmakuOption,
pub favorite_list: HashMap<String, PathBuf>,
#[serde(
default,
serialize_with = "serialize_collection_list",
deserialize_with = "deserialize_collection_list"
)]
pub collection_list: HashMap<CollectionItem, PathBuf>,
#[serde(default)]
pub submission_list: HashMap<String, PathBuf>,
#[serde(default)]
pub watch_later: WatchLaterConfig,
pub video_name: String,
pub page_name: String,
pub interval: u64,
pub upper_path: PathBuf,
#[serde(default)]
pub nfo_time_type: NFOTimeType,
#[serde(default)]
pub concurrent_limit: ConcurrentLimit,
#[serde(default = "default_time_format")]
pub time_format: String,
#[serde(default)]
pub cdn_sorting: bool,
}
impl LegacyConfig {
async fn load_from_file(path: &Path) -> Result<Self> {
let legacy_config_str = tokio::fs::read_to_string(path).await?;
Ok(toml::from_str(&legacy_config_str)?)
}
pub async fn migrate_from_file(path: &Path, connection: &DatabaseConnection) -> Result<Config> {
let legacy_config = Self::load_from_file(path).await?;
migrate_legacy_config(&legacy_config, connection).await?;
Ok(legacy_config.into())
}
}
/*
后面是用于自定义 Collection 的序列化、反序列化的样板代码
*/
pub(super) fn serialize_collection_list<S>(
collection_list: &HashMap<CollectionItem, PathBuf>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(collection_list.len()))?;
for (k, v) in collection_list {
let prefix = match k.collection_type {
CollectionType::Series => "series",
CollectionType::Season => "season",
};
map.serialize_entry(&[prefix, &k.mid, &k.sid].join(":"), v)?;
}
map.end()
}
pub(super) fn deserialize_collection_list<'de, D>(deserializer: D) -> Result<HashMap<CollectionItem, PathBuf>, D::Error>
where
D: Deserializer<'de>,
{
struct CollectionListVisitor;
impl<'de> Visitor<'de> for CollectionListVisitor {
type Value = HashMap<CollectionItem, PathBuf>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a map of collection list")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut collection_list = HashMap::new();
while let Some((key, value)) = map.next_entry::<String, PathBuf>()? {
let collection_item = match key.split(':').collect::<Vec<&str>>().as_slice() {
[prefix, mid, sid] => {
let collection_type = match *prefix {
"series" => CollectionType::Series,
"season" => CollectionType::Season,
_ => {
return Err(serde::de::Error::custom(
"invalid collection type, should be series or season",
));
}
};
CollectionItem {
mid: mid.to_string(),
sid: sid.to_string(),
collection_type,
}
}
_ => {
return Err(serde::de::Error::custom(
"invalid collection key, should be series:mid:sid or season:mid:sid",
));
}
};
collection_list.insert(collection_item, value);
}
Ok(collection_list)
}
}
deserializer.deserialize_map(CollectionListVisitor)
}

View File

@@ -1,148 +1,16 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use arc_swap::ArcSwapOption;
use serde::{Deserialize, Serialize};
mod clap;
mod global;
mod args;
mod current;
mod default;
mod handlebar;
mod item;
mod legacy;
mod versioned_cache;
mod versioned_config;
use crate::bilibili::{CollectionItem, Credential, DanmakuOption, FilterOption};
pub use crate::config::global::{ARGS, CONFIG, CONFIG_DIR, TEMPLATE};
use crate::config::item::{deserialize_collection_list, serialize_collection_list, ConcurrentLimit};
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit, WatchLaterConfig};
fn default_time_format() -> String {
"%Y-%m-%d".to_string()
}
#[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>,
#[serde(default)]
pub submission_list: HashMap<String, PathBuf>,
#[serde(default)]
pub watch_later: WatchLaterConfig,
pub video_name: Cow<'static, str>,
pub page_name: Cow<'static, str>,
pub interval: u64,
pub upper_path: PathBuf,
#[serde(default)]
pub nfo_time_type: NFOTimeType,
#[serde(default)]
pub concurrent_limit: ConcurrentLimit,
#[serde(default = "default_time_format")]
pub time_format: String,
}
impl Default for Config {
fn default() -> Self {
Self {
credential: ArcSwapOption::from(Some(Arc::new(Credential::default()))),
filter_option: FilterOption::default(),
danmaku_option: DanmakuOption::default(),
favorite_list: HashMap::new(),
collection_list: HashMap::new(),
submission_list: HashMap::new(),
watch_later: Default::default(),
video_name: Cow::Borrowed("{{title}}"),
page_name: Cow::Borrowed("{{bvid}}"),
interval: 1200,
upper_path: CONFIG_DIR.join("upper_face"),
nfo_time_type: NFOTimeType::FavTime,
concurrent_limit: ConcurrentLimit::default(),
time_format: default_time_format(),
}
}
}
impl Config {
pub fn save(&self) -> Result<()> {
let config_path = CONFIG_DIR.join("config.toml");
std::fs::create_dir_all(&*CONFIG_DIR)?;
std::fs::write(config_path, toml::to_string_pretty(self)?)?;
Ok(())
}
#[cfg(not(test))]
fn load() -> Result<Self> {
let config_path = CONFIG_DIR.join("config.toml");
let config_content = std::fs::read_to_string(config_path)?;
Ok(toml::from_str(&config_content)?)
}
#[cfg(not(test))]
pub fn check(&self) {
let mut ok = true;
if self.favorite_list.is_empty() && self.collection_list.is_empty() && !self.watch_later.enabled {
ok = false;
error!("没有配置任何需要扫描的内容,程序空转没有意义");
}
if self.watch_later.enabled && !self.watch_later.path.is_absolute() {
error!(
"稍后再看保存的路径应为绝对路径,检测到:{}",
self.watch_later.path.display()
);
}
for path in self.favorite_list.values() {
if !path.is_absolute() {
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 !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
ok = false;
error!("允许的并发数必须大于 0");
}
if !ok {
panic!(
"位于 {} 的配置文件不合法,请参考提示信息修复后继续运行",
CONFIG_DIR.join("config.toml").display()
);
}
}
}
pub use crate::config::args::{ARGS, version};
pub use crate::config::current::{CONFIG_DIR, Config};
pub use crate::config::handlebar::TEMPLATE;
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit};
pub use crate::config::legacy::LegacyConfig;
pub use crate::config::versioned_cache::VersionedCache;
pub use crate::config::versioned_config::VersionedConfig;

View File

@@ -0,0 +1,54 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result;
use arc_swap::{ArcSwap, Guard};
use crate::config::{Config, VersionedConfig};
pub struct VersionedCache<T> {
inner: ArcSwap<T>,
version: AtomicU64,
builder: fn(&Config) -> Result<T>,
mutex: parking_lot::Mutex<()>,
}
impl<T> VersionedCache<T> {
pub fn new(builder: fn(&Config) -> Result<T>) -> Result<Self> {
let current_config = VersionedConfig::get().load();
let current_version = current_config.version;
let initial_value = builder(&current_config)?;
Ok(Self {
inner: ArcSwap::from_pointee(initial_value),
version: AtomicU64::new(current_version),
builder,
mutex: parking_lot::Mutex::new(()),
})
}
pub fn load(&self) -> Guard<Arc<T>> {
self.reload_if_needed();
self.inner.load()
}
fn reload_if_needed(&self) {
let current_config = VersionedConfig::get().load();
let current_version = current_config.version;
let version = self.version.load(Ordering::Relaxed);
if version < current_version {
let _lock = self.mutex.lock();
if self.version.load(Ordering::Relaxed) >= current_version {
return;
}
match (self.builder)(&current_config) {
Err(e) => {
error!("Failed to rebuild versioned cache: {:?}", e);
}
Ok(new_value) => {
self.inner.store(Arc::new(new_value));
self.version.store(current_version, Ordering::Relaxed);
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
use arc_swap::{ArcSwap, Guard};
use sea_orm::DatabaseConnection;
use tokio::sync::OnceCell;
use crate::bilibili::Credential;
use crate::config::{CONFIG_DIR, Config, LegacyConfig};
pub static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
pub struct VersionedConfig {
inner: ArcSwap<Config>,
update_lock: tokio::sync::Mutex<()>,
}
impl VersionedConfig {
/// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误
pub async fn init(connection: &DatabaseConnection) -> Result<()> {
let mut config = match Config::load_from_database(connection).await? {
Some(Ok(config)) => config,
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
None => {
let config = match LegacyConfig::migrate_from_file(&CONFIG_DIR.join("config.toml"), connection).await {
Ok(config) => config,
Err(e) => {
if e.downcast_ref::<std::io::Error>()
.is_none_or(|e| e.kind() != std::io::ErrorKind::NotFound)
{
bail!("未成功读取并迁移旧版本配置:{:#}", e);
} else {
let config = Config::default();
warn!(
"生成 auth_token{},可使用该 token 登录 web UI该信息仅在首次运行时打印",
config.auth_token
);
config
}
}
};
config.save_to_database(connection).await?;
config
}
};
// version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空
config.version = 0;
let versioned_config = VersionedConfig::new(config);
VERSIONED_CONFIG
.set(versioned_config)
.map_err(|e| anyhow!("VERSIONED_CONFIG has already been initialized: {}", e))?;
Ok(())
}
#[cfg(test)]
/// 单元测试直接使用测试专用的配置即可
pub fn get() -> &'static VersionedConfig {
use std::sync::LazyLock;
static TEST_CONFIG: LazyLock<VersionedConfig> = LazyLock::new(|| VersionedConfig::new(Config::test_default()));
return &TEST_CONFIG;
}
#[cfg(not(test))]
/// 获取全局的 `VersionedConfig`,如果未初始化则会 panic
pub fn get() -> &'static VersionedConfig {
VERSIONED_CONFIG.get().expect("VERSIONED_CONFIG is not initialized")
}
pub fn new(config: Config) -> Self {
Self {
inner: ArcSwap::from_pointee(config),
update_lock: tokio::sync::Mutex::new(()),
}
}
pub fn load(&self) -> Guard<Arc<Config>> {
self.inner.load()
}
pub fn load_full(&self) -> Arc<Config> {
self.inner.load_full()
}
pub async fn update_credential(&self, new_credential: Credential, connection: &DatabaseConnection) -> Result<()> {
// 确保更新内容与写入数据库的操作是原子性的
let _lock = self.update_lock.lock().await;
loop {
let old_config = self.inner.load();
let mut new_config = old_config.as_ref().clone();
new_config.credential = new_credential.clone();
new_config.version += 1;
if Arc::ptr_eq(
&old_config,
&self.inner.compare_and_swap(&old_config, Arc::new(new_config)),
) {
break;
}
}
self.inner.load().save_to_database(connection).await
}
/// 外部 API 会调用这个方法,如果更新失败直接返回错误
pub async fn update(&self, mut new_config: Config, connection: &DatabaseConnection) -> Result<Arc<Config>> {
let _lock = self.update_lock.lock().await;
let old_config = self.inner.load();
if old_config.version != new_config.version {
bail!("配置版本不匹配,请刷新页面修改后重新提交");
}
new_config.version += 1;
let new_config = Arc::new(new_config);
if !Arc::ptr_eq(
&old_config,
&self.inner.compare_and_swap(&old_config, new_config.clone()),
) {
bail!("配置版本不匹配,请刷新页面修改后重新提交");
}
new_config.save_to_database(connection).await?;
Ok(new_config)
}
}

View File

@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context, Result};
use bili_sync_migration::{Migrator, MigratorTrait};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
@@ -8,7 +8,7 @@ fn database_url() -> String {
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
}
pub async fn database_connection() -> Result<DatabaseConnection> {
async fn database_connection() -> Result<DatabaseConnection> {
let mut option = ConnectOptions::new(database_url());
option
.max_connections(100)
@@ -17,9 +17,18 @@ pub async fn database_connection() -> Result<DatabaseConnection> {
Ok(Database::connect(option).await?)
}
pub async fn migrate_database() -> Result<()> {
async fn migrate_database() -> Result<()> {
// 注意此处使用内部构造的 DatabaseConnection而不是通过 database_connection() 获取
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
let connection = Database::connect(database_url()).await?;
Ok(Migrator::up(&connection, None).await?)
}
/// 进行数据库迁移并获取数据库连接,供外部使用
pub async fn setup_database() -> Result<DatabaseConnection> {
tokio::fs::create_dir_all(CONFIG_DIR.as_path())
.await
.context("Failed to create config directory")?;
migrate_database().await.context("Failed to migrate database")?;
database_connection().await.context("Failed to connect to database")
}

View File

@@ -1,12 +1,18 @@
use core::str;
use std::io::SeekFrom;
use std::path::Path;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use futures::StreamExt;
use reqwest::Method;
use tokio::fs::{self, File};
use tokio::io;
use anyhow::{Context, Result, bail, ensure};
use futures::TryStreamExt;
use reqwest::{Method, header};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use tokio::task::JoinSet;
use tokio_util::io::StreamReader;
use crate::bilibili::Client;
use crate::config::VersionedConfig;
pub struct Downloader {
client: Client,
}
@@ -20,37 +26,166 @@ impl Downloader {
}
pub async fn fetch(&self, url: &str, path: &Path) -> Result<()> {
if VersionedConfig::get().load().concurrent_limit.download.enable {
self.fetch_parallel(url, path).await
} else {
self.fetch_serial(url, path).await
}
}
async fn fetch_serial(&self, url: &str, path: &Path) -> Result<()> {
let resp = self
.client
.request(Method::GET, url, None)
.send()
.await?
.error_for_status()?;
let expected = resp.header_content_length();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let mut file = File::create(path).await?;
let mut res = self.client.request(Method::GET, url, None).send().await?.bytes_stream();
while let Some(item) = res.next().await {
io::copy(&mut item?.as_ref(), &mut file).await?;
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
let received = tokio::io::copy(&mut stream_reader, &mut file).await?;
file.flush().await?;
if let Some(expected) = expected {
ensure!(
received == expected,
"downloaded bytes mismatch: expected {}, got {}",
expected,
received
);
}
Ok(())
}
async fn fetch_parallel(&self, url: &str, path: &Path) -> Result<()> {
let (concurrency, threshold) = {
let config = VersionedConfig::get().load();
(
config.concurrent_limit.download.concurrency,
config.concurrent_limit.download.threshold,
)
};
let resp = self
.client
.request(Method::HEAD, url, None)
.send()
.await?
.error_for_status()?;
let file_size = resp.header_content_length().unwrap_or_default();
let chunk_size = file_size / concurrency as u64;
if resp
.headers()
.get(header::ACCEPT_RANGES)
.is_none_or(|v| v.to_str().unwrap_or_default() == "none") // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges#none
|| chunk_size < threshold
{
return self.fetch_serial(url, path).await;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let file = File::create(path).await?;
file.set_len(file_size).await?;
drop(file);
let mut tasks = JoinSet::new();
let url = Arc::new(url.to_string());
let path = Arc::new(path.to_path_buf());
for i in 0..concurrency {
let start = i as u64 * chunk_size;
let end = if i == concurrency - 1 {
file_size
} else {
start + chunk_size
} - 1;
let (url_clone, path_clone, client_clone) = (url.clone(), path.clone(), self.client.clone());
tasks.spawn(async move {
let mut file = OpenOptions::new().write(true).open(path_clone.as_ref()).await?;
file.seek(SeekFrom::Start(start)).await?;
let range_header = format!("bytes={}-{}", start, end);
let resp = client_clone
.request(Method::GET, &url_clone, None)
.header(header::RANGE, &range_header)
.send()
.await?
.error_for_status()?;
if let Some(content_length) = resp.header_content_length() {
ensure!(
content_length == end - start + 1,
"content length mismatch: expected {}, got {}",
end - start + 1,
content_length
);
}
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
let received = tokio::io::copy(&mut stream_reader, &mut file).await?;
file.flush().await?;
ensure!(
received == end - start + 1,
"downloaded bytes mismatch: expected {}, got {}",
end - start + 1,
received,
);
Ok(())
});
}
while let Some(res) = tasks.join_next().await {
res??;
}
Ok(())
}
pub async fn fetch_with_fallback(&self, urls: &[&str], path: &Path) -> Result<()> {
if urls.is_empty() {
bail!("no urls provided");
}
let mut res = Ok(());
for url in urls {
match self.fetch(url, path).await {
Ok(_) => return Ok(()),
Err(err) => {
res = Err(err);
}
}
}
res.with_context(|| format!("failed to download from {:?}", urls))
}
pub async fn merge(&self, video_path: &Path, audio_path: &Path, output_path: &Path) -> Result<()> {
let output = tokio::process::Command::new("ffmpeg")
.args([
"-i",
video_path.to_str().unwrap(),
video_path.to_string_lossy().as_ref(),
"-i",
audio_path.to_str().unwrap(),
audio_path.to_string_lossy().as_ref(),
"-c",
"copy",
"-strict",
"unofficial",
"-y",
output_path.to_str().unwrap(),
output_path.to_string_lossy().as_ref(),
])
.output()
.await?;
if !output.status.success() {
return match String::from_utf8(output.stderr) {
Ok(err) => Err(anyhow!(err)),
_ => Err(anyhow!("ffmpeg error")),
};
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
}
Ok(())
}
}
/// reqwest.content_length() 居然指的是 body_size 而非 content-length header没办法自己实现一下
/// https://github.com/seanmonstar/reqwest/issues/1814
trait ResponseExt {
fn header_content_length(&self) -> Option<u64>;
}
impl ResponseExt for reqwest::Response {
fn header_content_length(&self) -> Option<u64> {
self.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
}
}

View File

@@ -1,3 +1,6 @@
use std::io;
use anyhow::Result;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -7,3 +10,50 @@ pub struct DownloadAbortError();
#[derive(Error, Debug)]
#[error("Process page error")]
pub struct ProcessPageError();
pub enum ExecutionStatus {
Skipped,
Succeeded,
Ignored(anyhow::Error),
Failed(anyhow::Error),
// 任务可以返回该状态固定自己的 status
FixedFailed(u32, anyhow::Error),
}
// 目前 stable rust 似乎不支持自定义类型使用 ? 运算符,只能先在返回值使用 Result再这样套层娃
impl From<Result<ExecutionStatus>> for ExecutionStatus {
fn from(res: Result<ExecutionStatus>) -> Self {
match res {
Ok(status) => status,
Err(err) => {
for cause in err.chain() {
if let Some(io_err) = cause.downcast_ref::<io::Error>() {
// 权限错误
if io_err.kind() == io::ErrorKind::PermissionDenied {
return ExecutionStatus::Ignored(err);
}
// 使用 io::Error 包裹的 reqwest::Error
if io_err.kind() == io::ErrorKind::Other
&& io_err.get_ref().is_some_and(|e| {
e.downcast_ref::<reqwest::Error>().is_some_and(is_ignored_reqwest_error)
})
{
return ExecutionStatus::Ignored(err);
}
}
// 未包裹的 reqwest::Error
if let Some(error) = cause.downcast_ref::<reqwest::Error>() {
if is_ignored_reqwest_error(error) {
return ExecutionStatus::Ignored(err);
}
}
}
ExecutionStatus::Failed(err)
}
}
}
}
fn is_ignored_reqwest_error(err: &reqwest::Error) -> bool {
err.is_decode() || err.is_body() || err.is_timeout()
}

View File

@@ -2,84 +2,107 @@
extern crate tracing;
mod adapter;
mod api;
mod bilibili;
mod config;
mod database;
mod downloader;
mod error;
mod task;
mod utils;
mod workflow;
use once_cell::sync::Lazy;
use tokio::time;
use std::collections::VecDeque;
use std::fmt::Debug;
use std::future::Future;
use std::sync::Arc;
use crate::adapter::Args;
use crate::bilibili::BiliClient;
use crate::config::{ARGS, CONFIG};
use crate::database::{database_connection, migrate_database};
use bilibili::BiliClient;
use parking_lot::Mutex;
use sea_orm::DatabaseConnection;
use task::{http_server, video_downloader};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::api::{LogHelper, MAX_HISTORY_LOGS};
use crate::config::{ARGS, VersionedConfig};
use crate::database::setup_database;
use crate::utils::init_logger;
use crate::workflow::process_video_list;
use crate::utils::signal::terminate;
#[tokio::main]
async fn main() {
init_logger(&ARGS.log_level);
Lazy::force(&CONFIG);
migrate_database().await.expect("数据库迁移失败");
let connection = database_connection().await.expect("获取数据库连接失败");
let mut anchor = chrono::Local::now().date_naive();
let bili_client = BiliClient::new();
let watch_later_config = &CONFIG.watch_later;
loop {
'inner: {
match bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) {
Ok(Some(mixin_key)) => bilibili::set_global_mixin_key(mixin_key),
Ok(_) => {
error!("获取 mixin key 失败,无法进行 wbi 签名,等待下一轮执行");
break 'inner;
}
Err(e) => {
error!("获取 mixin key 时遇到错误:{e},等待下一轮执行");
break 'inner;
}
};
if anchor != chrono::Local::now().date_naive() {
if let Err(e) = bili_client.check_refresh().await {
error!("检查刷新 Credential 遇到错误:{e},等待下一轮执行");
break 'inner;
}
anchor = chrono::Local::now().date_naive();
let (connection, log_writer) = init().await;
let bili_client = Arc::new(BiliClient::new());
let token = CancellationToken::new();
let tracker = TaskTracker::new();
spawn_task(
"HTTP 服务",
http_server(connection.clone(), bili_client.clone(), log_writer),
&tracker,
token.clone(),
);
if !cfg!(debug_assertions) {
spawn_task(
"定时下载",
video_downloader(connection, bili_client),
&tracker,
token.clone(),
);
}
tracker.close();
handle_shutdown(tracker, token).await
}
fn spawn_task(
task_name: &'static str,
task: impl Future<Output = impl Debug> + Send + 'static,
tracker: &TaskTracker,
token: CancellationToken,
) {
tracker.spawn(async move {
tokio::select! {
res = task => {
error!("「{}」异常结束,返回结果为:「{:?}」,取消其它仍在执行的任务..", task_name, res);
token.cancel();
},
_ = token.cancelled() => {
info!("「{}」接收到取消信号,终止运行..", task_name);
}
for (fid, path) in &CONFIG.favorite_list {
if let Err(e) = process_video_list(Args::Favorite { fid }, &bili_client, path, &connection).await {
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
}
}
info!("所有收藏夹处理完毕");
for (collection_item, path) in &CONFIG.collection_list {
if let Err(e) =
process_video_list(Args::Collection { collection_item }, &bili_client, path, &connection).await
{
error!("处理合集 {collection_item:?} 时遇到非预期的错误:{e}");
}
}
info!("所有合集处理完毕");
if watch_later_config.enabled {
if let Err(e) =
process_video_list(Args::WatchLater, &bili_client, &watch_later_config.path, &connection).await
{
error!("处理稍后再看时遇到非预期的错误:{e}");
}
}
info!("稍后再看处理完毕");
for (upper_id, path) in &CONFIG.submission_list {
if let Err(e) = process_video_list(Args::Submission { upper_id }, &bili_client, path, &connection).await
{
error!("处理 UP 主 {upper_id} 投稿时遇到非预期的错误:{e}");
}
}
info!("所有 UP 主投稿处理完毕");
info!("本轮任务执行完毕,等待下一轮执行");
}
time::sleep(time::Duration::from_secs(CONFIG.interval)).await;
});
}
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
let (tx, _rx) = tokio::sync::broadcast::channel(30);
let log_history = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
let log_writer = LogHelper::new(tx, log_history.clone());
init_logger(&ARGS.log_level, Some(log_writer.clone()));
info!("欢迎使用 Bili-Sync当前程序版本{}", config::version());
info!("项目地址https://github.com/amtoaer/bili-sync");
let connection = Arc::new(setup_database().await.expect("数据库初始化失败"));
info!("数据库初始化完成");
VersionedConfig::init(&connection).await.expect("配置初始化失败");
info!("配置初始化完成");
(connection, log_writer)
}
async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) {
tokio::select! {
_ = tracker.wait() => {
error!("所有任务均已终止,程序退出")
}
_ = terminate() => {
info!("接收到终止信号,正在终止任务..");
token.cancel();
tracker.wait().await;
info!("所有任务均已终止,程序退出");
}
}
}

View File

@@ -0,0 +1,81 @@
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::extract::Request;
use axum::http::header;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Extension, ServiceExt};
use reqwest::StatusCode;
use rust_embed_for_web::{EmbedableFile, RustEmbed};
use sea_orm::DatabaseConnection;
use crate::api::{LogHelper, router};
use crate::bilibili::BiliClient;
use crate::config::VersionedConfig;
#[derive(RustEmbed)]
#[preserve_source = false]
#[folder = "../../web/build"]
struct Asset;
pub async fn http_server(
database_connection: Arc<DatabaseConnection>,
bili_client: Arc<BiliClient>,
log_writer: LogHelper,
) -> Result<()> {
let app = router()
.fallback_service(get(frontend_files))
.layer(Extension(database_connection))
.layer(Extension(bili_client))
.layer(Extension(log_writer));
let config = VersionedConfig::get().load_full();
let listener = tokio::net::TcpListener::bind(&config.bind_address)
.await
.context("bind address failed")?;
info!("开始运行管理页: http://{}", config.bind_address);
Ok(axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await?)
}
async fn frontend_files(request: Request) -> impl IntoResponse {
let mut path = request.uri().path().trim_start_matches('/');
if path.is_empty() || Asset::get(path).is_none() {
path = "index.html";
}
let Some(content) = Asset::get(path) else {
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
};
let mime_type = content.mime_type();
let content_type = mime_type.as_deref().unwrap_or("application/octet-stream");
if cfg!(debug_assertions) {
(
[(header::CONTENT_TYPE, content_type)],
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
content.data().unwrap(),
)
.into_response()
} else {
let accepted_encodings = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
.unwrap_or_default();
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
if accepted_encodings.contains(encoding) {
if let Some(data) = data {
return (
[
(header::CONTENT_TYPE, content_type),
(header::CONTENT_ENCODING, encoding),
],
data,
)
.into_response();
}
}
}
"Unsupported Encoding".into_response()
}
}

View File

@@ -0,0 +1,5 @@
mod http_server;
mod video_downloader;
pub use http_server::http_server;
pub use video_downloader::video_downloader;

View File

@@ -0,0 +1,62 @@
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use tokio::time;
use crate::adapter::VideoSource;
use crate::bilibili::{self, BiliClient};
use crate::config::VersionedConfig;
use crate::utils::model::get_enabled_video_sources;
use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
use crate::workflow::process_video_source;
/// 启动周期下载视频的任务
pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) {
let mut anchor = chrono::Local::now().date_naive();
loop {
info!("开始执行本轮视频下载任务..");
let _lock = TASK_STATUS_NOTIFIER.start_running().await;
let config = VersionedConfig::get().load_full();
'inner: {
if let Err(e) = config.check() {
error!("配置检查失败,跳过本轮执行:\n{:#}", e);
break 'inner;
}
match bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) {
Ok(Some(mixin_key)) => bilibili::set_global_mixin_key(mixin_key),
Ok(_) => {
error!("解析 mixin key 失败,等待下一轮执行");
break 'inner;
}
Err(e) => {
error!("获取 mixin key 遇到错误:{:#},等待下一轮执行", e);
break 'inner;
}
};
if anchor != chrono::Local::now().date_naive() {
if let Err(e) = bili_client.check_refresh(&connection).await {
error!("检查刷新 Credential 遇到错误:{:#},等待下一轮执行", e);
break 'inner;
}
anchor = chrono::Local::now().date_naive();
}
let Ok(video_sources) = get_enabled_video_sources(&connection).await else {
error!("获取视频源列表失败,等待下一轮执行");
break 'inner;
};
if video_sources.is_empty() {
info!("没有可用的视频源,等待下一轮执行");
break 'inner;
}
for video_source in video_sources {
let display_name = video_source.display_name();
if let Err(e) = process_video_source(video_source, &bili_client, &connection).await {
error!("处理 {} 时遇到错误:{:#},等待下一轮执行", display_name, e);
}
}
info!("本轮任务执行完毕,等待下一轮执行");
}
TASK_STATUS_NOTIFIER.finish_running(_lock);
time::sleep(time::Duration::from_secs(config.interval)).await;
}
}

View File

@@ -1,40 +1,34 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::IntoActiveModel;
use serde_json::json;
use crate::bilibili::VideoInfo;
use crate::config::CONFIG;
use crate::utils::id_time_key;
use crate::bilibili::{PageInfo, VideoInfo};
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
}
/// 在检测视频更新时,通过该方法将 VideoInfo 转换为简单的 ActiveModel,此处仅填充一些简单信息,后续会使用详情覆盖
pub fn into_simple_model(self) -> bili_sync_entity::video::ActiveModel {
let default = bili_sync_entity::video::ActiveModel {
id: NotSet,
created_at: NotSet,
// 此处不使用 ActiveModel::default() 是为了让其它字段有默认值
..bili_sync_entity::video::Model::default().into_active_model()
};
match self {
VideoInfo::Simple {
VideoInfo::Collection {
bvid,
cover,
ctime,
pubtime,
} => bili_sync_entity::video::ActiveModel {
bvid: Set(bvid.clone()),
cover: Set(cover.clone()),
bvid: Set(bvid),
cover: Set(cover),
ctime: Set(ctime.naive_utc()),
pubtime: Set(pubtime.naive_utc()),
category: Set(2), // 视频合集里的内容类型肯定是视频
valid: Set(true),
..base_model
..default
},
VideoInfo::Detail {
VideoInfo::Favorite {
title,
vtype,
bvid,
@@ -46,50 +40,20 @@ impl VideoInfo {
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()),
bvid: Set(bvid),
name: Set(title),
category: Set(vtype),
intro: Set(intro),
cover: Set(cover),
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),
valid: Set(attr == 0),
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
upper_name: Set(upper.name),
upper_face: Set(upper.face),
..default
},
VideoInfo::WatchLater {
title,
@@ -102,22 +66,20 @@ impl VideoInfo {
pubtime,
state,
} => bili_sync_entity::video::ActiveModel {
bvid: Set(bvid.clone()),
name: Set(title.clone()),
bvid: Set(bvid),
name: Set(title),
category: Set(2), // 稍后再看里的内容类型肯定是视频
intro: Set(intro.clone()),
cover: Set(cover.clone()),
intro: Set(intro),
cover: Set(cover),
ctime: Set(ctime.naive_utc()),
pubtime: Set(pubtime.naive_utc()),
favtime: Set(fav_time.naive_utc()),
download_status: Set(0),
valid: Set(*state == 0),
tags: Set(None),
single_page: Set(None),
valid: Set(state == 0),
upper_id: Set(upper.mid),
upper_name: Set(upper.name.clone()),
upper_face: Set(upper.face.clone()),
..base_model
upper_name: Set(upper.name),
upper_face: Set(upper.face),
..default
},
VideoInfo::Submission {
title,
@@ -126,90 +88,95 @@ impl VideoInfo {
cover,
ctime,
} => bili_sync_entity::video::ActiveModel {
bvid: Set(bvid.clone()),
name: Set(title.clone()),
intro: Set(intro.clone()),
cover: Set(cover.clone()),
bvid: Set(bvid),
name: Set(title),
intro: Set(intro),
cover: Set(cover),
ctime: Set(ctime.naive_utc()),
category: Set(2), // 投稿视频的内容类型肯定是视频
valid: Set(true),
..base_model
..default
},
}
}
pub fn to_fmt_args(&self) -> Option<serde_json::Value> {
match self {
VideoInfo::Simple { .. } | VideoInfo::Submission { .. } => None, // 不能从简单视频信息中构造格式化参数
VideoInfo::Detail {
title,
bvid,
upper,
pubtime,
fav_time,
..
}
| VideoInfo::WatchLater {
title,
bvid,
upper,
pubtime,
fav_time,
..
} => Some(json!({
"bvid": &bvid,
"title": &title,
"upper_name": &upper.name,
"upper_mid": &upper.mid,
"pubtime": pubtime.format(&CONFIG.time_format).to_string(),
"fav_time": fav_time.format(&CONFIG.time_format).to_string(),
})),
VideoInfo::View {
title,
bvid,
upper,
pubtime,
..
} => {
let pubtime = pubtime.format(&CONFIG.time_format).to_string();
Some(json!({
"bvid": &bvid,
"title": &title,
"upper_name": &upper.name,
"upper_mid": &upper.mid,
"pubtime": &pubtime,
"fav_time": &pubtime,
}))
}
}
}
pub fn video_key(&self) -> String {
match self {
// 对于合集没有 fav_time只能用 pubtime 代替
VideoInfo::Simple {
bvid, pubtime: time, ..
}
| VideoInfo::Detail {
bvid, fav_time: time, ..
}
| VideoInfo::WatchLater {
bvid, fav_time: time, ..
}
| VideoInfo::Submission { bvid, ctime: time, .. } => id_time_key(bvid, time),
// 详情接口返回的数据仅用于填充详情,不会被作为 video_key
_ => unreachable!(),
}
}
pub fn bvid(&self) -> &str {
/// 填充视频详情时调用,该方法会将视频详情附加到原有的 Model 上
/// 特殊地,如果在检测视频更新时记录了 favtime那么 favtime 会维持原样,否则会使用 pubtime 填充
pub fn into_detail_model(self, base_model: bili_sync_entity::video::Model) -> bili_sync_entity::video::ActiveModel {
match self {
VideoInfo::Simple { bvid, .. }
| VideoInfo::Detail { bvid, .. }
| VideoInfo::WatchLater { bvid, .. }
| VideoInfo::Submission { bvid, .. } => bvid,
// 同上
VideoInfo::Detail {
title,
bvid,
intro,
cover,
upper,
ctime,
pubtime,
state,
..
} => bili_sync_entity::video::ActiveModel {
bvid: Set(bvid),
name: Set(title),
category: Set(2),
intro: Set(intro),
cover: Set(cover),
ctime: Set(ctime.naive_utc()),
pubtime: Set(pubtime.naive_utc()),
favtime: if base_model.favtime != NaiveDateTime::default() {
NotSet // 之前设置了 favtime不覆盖
} else {
Set(pubtime.naive_utc()) // 未设置过 favtime使用 pubtime 填充
},
download_status: Set(0),
valid: Set(state == 0),
upper_id: Set(upper.mid),
upper_name: Set(upper.name),
upper_face: Set(upper.face),
..base_model.into_active_model()
},
_ => unreachable!(),
}
}
/// 获取视频的发布时间,用于对时间做筛选检查新视频
pub fn release_datetime(&self) -> &DateTime<Utc> {
match self {
VideoInfo::Collection { pubtime: time, .. }
| VideoInfo::Favorite { fav_time: time, .. }
| VideoInfo::WatchLater { fav_time: time, .. }
| VideoInfo::Submission { ctime: time, .. } => time,
_ => unreachable!(),
}
}
}
impl PageInfo {
pub fn into_active_model(
self,
video_model: &bili_sync_entity::video::Model,
) -> bili_sync_entity::page::ActiveModel {
let (width, height) = match &self.dimension {
Some(d) => {
if d.rotate == 0 {
(Some(d.width), Some(d.height))
} else {
(Some(d.height), Some(d.width))
}
}
None => (None, None),
};
bili_sync_entity::page::ActiveModel {
video_id: Set(video_model.id),
cid: Set(self.cid),
pid: Set(self.page),
name: Set(self.name),
width: Set(width),
height: Set(height),
duration: Set(self.duration),
image: Set(self.first_frame),
download_status: Set(0),
..Default::default()
}
}
}

View File

@@ -1,7 +1,7 @@
macro_rules! regex {
($re:literal $(,)?) => {{
static RE: once_cell::sync::OnceCell<regex::Regex> = once_cell::sync::OnceCell::new();
RE.get_or_init(|| regex::Regex::new($re).unwrap())
RE.get_or_init(|| regex::Regex::new($re).expect("invalid regex"))
}};
}

View File

@@ -0,0 +1,32 @@
use serde_json::json;
use crate::config::VersionedConfig;
pub fn video_format_args(video_model: &bili_sync_entity::video::Model) -> serde_json::Value {
let config = VersionedConfig::get().load();
json!({
"bvid": &video_model.bvid,
"title": &video_model.name,
"upper_name": &video_model.upper_name,
"upper_mid": &video_model.upper_id,
"pubtime": &video_model.pubtime.and_utc().format(&config.time_format).to_string(),
"fav_time": &video_model.favtime.and_utc().format(&config.time_format).to_string(),
})
}
pub fn page_format_args(
video_model: &bili_sync_entity::video::Model,
page_model: &bili_sync_entity::page::Model,
) -> serde_json::Value {
let config = VersionedConfig::get().load();
json!({
"bvid": &video_model.bvid,
"title": &video_model.name,
"upper_name": &video_model.upper_name,
"upper_mid": &video_model.upper_id,
"ptitle": &page_model.name,
"pid": page_model.pid,
"pubtime": video_model.pubtime.and_utc().format(&config.time_format).to_string(),
"fav_time": video_model.favtime.and_utc().format(&config.time_format).to_string(),
})
}

View File

@@ -1,24 +1,41 @@
pub mod convert;
pub mod filenamify;
pub mod format_arg;
pub mod model;
pub mod nfo;
pub mod signal;
pub mod status;
use chrono::{DateTime, Utc};
pub mod task_notifier;
pub mod validation;
use tracing_subscriber::fmt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
pub fn init_logger(log_level: &str) {
tracing_subscriber::fmt::Subscriber::builder()
use crate::api::LogHelper;
pub fn init_logger(log_level: &str, log_writer: Option<LogHelper>) {
let log = tracing_subscriber::fmt::Subscriber::builder()
.compact()
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
.with_target(false)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
"%Y-%m-%d %H:%M:%S%.3f".to_owned(),
"%b %d %H:%M:%S".to_owned(),
))
.finish()
.finish();
if let Some(writer) = log_writer {
log.with(
fmt::layer()
.with_ansi(false)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
"%b %d %H:%M:%S".to_owned(),
))
.json()
.flatten_event(true)
.with_writer(writer),
)
.try_init()
.expect("初始化日志失败");
}
/// 生成视频的唯一标记,均由 bvid 和时间戳构成
pub fn id_time_key(bvid: &String, time: &DateTime<Utc>) -> String {
format!("{}-{}", bvid, time.timestamp())
} else {
log.try_init().expect("初始化日志失败");
}
}

View File

@@ -1,20 +1,67 @@
use anyhow::Result;
use anyhow::{Context, Result, anyhow};
use bili_sync_entity::*;
use sea_orm::ActiveValue::Set;
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::OnConflict;
use sea_orm::sea_query::{OnConflict, SimpleExpr};
use sea_orm::{DatabaseTransaction, TransactionTrait};
use crate::adapter::VideoListModel;
use crate::bilibili::VideoInfo;
use crate::adapter::{VideoSource, VideoSourceEnum};
use crate::bilibili::{PageInfo, VideoInfo};
use crate::config::{Config, LegacyConfig};
use crate::utils::status::STATUS_COMPLETED;
/// 筛选未填充的视频
pub async fn filter_unfilled_videos(
additional_expr: SimpleExpr,
conn: &DatabaseConnection,
) -> Result<Vec<video::Model>> {
video::Entity::find()
.filter(
video::Column::Valid
.eq(true)
.and(video::Column::DownloadStatus.eq(0))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_null())
.and(additional_expr),
)
.all(conn)
.await
.context("filter unfilled videos failed")
}
/// 筛选未处理完成的视频和视频页
pub async fn filter_unhandled_video_pages(
additional_expr: SimpleExpr,
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
video::Entity::find()
.filter(
video::Column::Valid
.eq(true)
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.and(additional_expr),
)
.find_with_related(page::Entity)
.all(connection)
.await
.context("filter unhandled video pages failed")
}
/// 尝试创建 Video Model如果发生冲突则忽略
pub async fn create_videos(
videos_info: &[VideoInfo],
video_list_model: &dyn VideoListModel,
videos_info: Vec<VideoInfo>,
video_source: &VideoSourceEnum,
connection: &DatabaseConnection,
) -> Result<()> {
let video_models = videos_info
.iter()
.map(|v| video_list_model.video_model_by_info(v, None))
.into_iter()
.map(|v| {
let mut model = v.into_simple_model();
video_source.set_relation_id(&mut model);
model
})
.collect::<Vec<_>>();
video::Entity::insert_many(video_models)
// 这里想表达的是 on 索引名,但 sea-orm 的 api 似乎只支持列名而不支持索引名,好在留空可以达到相同的目的
@@ -25,12 +72,36 @@ pub async fn create_videos(
Ok(())
}
/// 尝试创建 Page Model如果发生冲突则忽略
pub async fn create_pages(
pages_info: Vec<PageInfo>,
video_model: &bili_sync_entity::video::Model,
connection: &DatabaseTransaction,
) -> Result<()> {
let page_models = pages_info
.into_iter()
.map(|p| p.into_active_model(video_model))
.collect::<Vec<page::ActiveModel>>();
for page_chunk in page_models.chunks(50) {
page::Entity::insert_many(page_chunk.to_vec())
.on_conflict(
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
.do_nothing()
.to_owned(),
)
.do_nothing()
.exec(connection)
.await?;
}
Ok(())
}
/// 更新视频 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)
.update_columns([video::Column::DownloadStatus, video::Column::Path])
.to_owned(),
)
.exec(connection)
@@ -48,3 +119,123 @@ pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &Data
query.exec(connection).await?;
Ok(())
}
/// 获取所有已经启用的视频源
pub async fn get_enabled_video_sources(connection: &DatabaseConnection) -> Result<Vec<VideoSourceEnum>> {
let (favorite, watch_later, submission, collection) = tokio::try_join!(
favorite::Entity::find()
.filter(favorite::Column::Enabled.eq(true))
.all(connection),
watch_later::Entity::find()
.filter(watch_later::Column::Enabled.eq(true))
.all(connection),
submission::Entity::find()
.filter(submission::Column::Enabled.eq(true))
.all(connection),
collection::Entity::find()
.filter(collection::Column::Enabled.eq(true))
.all(connection),
)?;
let mut sources = Vec::with_capacity(favorite.len() + watch_later.len() + submission.len() + collection.len());
sources.extend(favorite.into_iter().map(VideoSourceEnum::from));
sources.extend(watch_later.into_iter().map(VideoSourceEnum::from));
sources.extend(submission.into_iter().map(VideoSourceEnum::from));
sources.extend(collection.into_iter().map(VideoSourceEnum::from));
Ok(sources)
}
/// 从数据库中加载配置
pub async fn load_db_config(connection: &DatabaseConnection) -> Result<Option<Result<Config>>> {
Ok(bili_sync_entity::config::Entity::find_by_id(1)
.one(connection)
.await?
.map(|model| {
serde_json::from_str(&model.data).map_err(|e| anyhow!("Failed to deserialize config data: {}", e))
}))
}
/// 保存配置到数据库
pub async fn save_db_config(config: &Config, connection: &DatabaseConnection) -> Result<()> {
let data = serde_json::to_string(config).context("Failed to serialize config data")?;
let model = bili_sync_entity::config::ActiveModel {
id: Set(1),
data: Set(data),
..Default::default()
};
bili_sync_entity::config::Entity::insert(model)
.on_conflict(
OnConflict::column(bili_sync_entity::config::Column::Id)
.update_column(bili_sync_entity::config::Column::Data)
.to_owned(),
)
.exec(connection)
.await
.context("Failed to save config to database")?;
Ok(())
}
/// 迁移旧版本配置(即将所有相关联的内容设置为 enabled
pub async fn migrate_legacy_config(config: &LegacyConfig, connection: &DatabaseConnection) -> Result<()> {
let transaction = connection.begin().await.context("Failed to begin transaction")?;
tokio::try_join!(
migrate_favorite(config, &transaction),
migrate_watch_later(config, &transaction),
migrate_submission(config, &transaction),
migrate_collection(config, &transaction)
)?;
transaction.commit().await.context("Failed to commit transaction")?;
Ok(())
}
async fn migrate_favorite(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
favorite::Entity::update_many()
.filter(favorite::Column::FId.is_in(config.favorite_list.keys().collect::<Vec<_>>()))
.col_expr(favorite::Column::Enabled, Expr::value(true))
.exec(connection)
.await
.context("Failed to migrate favorite config")?;
Ok(())
}
async fn migrate_watch_later(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
if config.watch_later.enabled {
watch_later::Entity::update_many()
.col_expr(watch_later::Column::Enabled, Expr::value(true))
.exec(connection)
.await
.context("Failed to migrate watch later config")?;
}
Ok(())
}
async fn migrate_submission(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
submission::Entity::update_many()
.filter(submission::Column::UpperId.is_in(config.submission_list.keys().collect::<Vec<_>>()))
.col_expr(submission::Column::Enabled, Expr::value(true))
.exec(connection)
.await
.context("Failed to migrate submission config")?;
Ok(())
}
async fn migrate_collection(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
let tuples: Vec<(i64, i64, i32)> = config
.collection_list
.keys()
.filter_map(|key| Some((key.sid.parse().ok()?, key.mid.parse().ok()?, key.collection_type.into())))
.collect();
collection::Entity::update_many()
.filter(
Expr::tuple([
Expr::column(collection::Column::SId),
Expr::column(collection::Column::MId),
Expr::column(collection::Column::Type),
])
.in_tuples(tuples),
)
.col_expr(collection::Column::Enabled, Expr::value(true))
.exec(connection)
.await
.context("Failed to migrate collection config")?;
Ok(())
}

View File

@@ -1,235 +1,243 @@
use anyhow::Result;
use bili_sync_entity::*;
use chrono::NaiveDateTime;
use quick_xml::Error;
use quick_xml::events::{BytesCData, BytesText};
use quick_xml::writer::Writer;
use quick_xml::Error;
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncWriteExt, BufWriter};
use crate::config::NFOTimeType;
use crate::config::{NFOTimeType, VersionedConfig};
#[allow(clippy::upper_case_acronyms)]
pub enum NFOMode {
MOVIE,
TVSHOW,
EPOSODE,
UPPER,
pub enum NFO<'a> {
Movie(Movie<'a>),
TVShow(TVShow<'a>),
Upper(Upper),
Episode(Episode<'a>),
}
pub enum ModelWrapper<'a> {
Video(&'a video::Model),
Page(&'a page::Model),
pub struct Movie<'a> {
pub name: &'a str,
pub intro: &'a str,
pub bvid: &'a str,
pub upper_id: i64,
pub upper_name: &'a str,
pub aired: NaiveDateTime,
pub tags: Option<Vec<String>>,
}
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
pub struct TVShow<'a> {
pub name: &'a str,
pub intro: &'a str,
pub bvid: &'a str,
pub upper_id: i64,
pub upper_name: &'a str,
pub aired: NaiveDateTime,
pub tags: Option<Vec<String>>,
}
/// serde xml 似乎不太好用,先这么裸着写
/// (真是又臭又长啊
impl NFOSerializer<'_> {
pub async fn generate_nfo(self, nfo_time_type: &NFOTimeType) -> Result<String> {
pub struct Upper {
pub upper_id: String,
pub pubtime: NaiveDateTime,
}
pub struct Episode<'a> {
pub name: &'a str,
pub pid: String,
}
impl NFO<'_> {
pub async fn generate_nfo(self) -> Result<String> {
let mut buffer = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
"#
.as_bytes()
.to_vec();
let mut tokio_buffer = tokio::io::BufWriter::new(&mut buffer);
let mut writer = Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
let mut tokio_buffer = BufWriter::new(&mut buffer);
let writer = Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
match self {
NFOSerializer(ModelWrapper::Video(v), NFOMode::MOVIE) => {
let nfo_time = match nfo_time_type {
NFOTimeType::FavTime => v.favtime,
NFOTimeType::PubTime => v.pubtime,
};
writer
.create_element("movie")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("plot")
.write_cdata_content_async(BytesCData::new(&v.intro))
.await
.unwrap();
writer.create_element("outline").write_empty_async().await.unwrap();
writer
.create_element("title")
.write_text_content_async(BytesText::new(&v.name))
.await
.unwrap();
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
.await
.unwrap();
writer
.create_element("role")
.write_text_content_async(BytesText::new(&v.upper_name))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
writer
.create_element("year")
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
.await
.unwrap();
if let Some(tags) = &v.tags {
let tags: Vec<String> = serde_json::from_value(tags.clone()).unwrap();
for tag in tags {
writer
.create_element("genre")
.write_text_content_async(BytesText::new(&tag))
.await
.unwrap();
}
}
writer
.create_element("uniqueid")
.with_attribute(("type", "bilibili"))
.write_text_content_async(BytesText::new(&v.bvid))
.await
.unwrap();
writer
.create_element("aired")
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
NFO::Movie(movie) => {
Self::write_movie_nfo(writer, movie).await?;
}
NFOSerializer(ModelWrapper::Video(v), NFOMode::TVSHOW) => {
let nfo_time = match nfo_time_type {
NFOTimeType::FavTime => v.favtime,
NFOTimeType::PubTime => v.pubtime,
};
writer
.create_element("tvshow")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("plot")
.write_cdata_content_async(BytesCData::new(&v.intro))
.await
.unwrap();
writer.create_element("outline").write_empty_async().await.unwrap();
writer
.create_element("title")
.write_text_content_async(BytesText::new(&v.name))
.await
.unwrap();
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
.await
.unwrap();
writer
.create_element("role")
.write_text_content_async(BytesText::new(&v.upper_name))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
writer
.create_element("year")
.write_text_content_async(BytesText::new(&nfo_time.format("%Y").to_string()))
.await
.unwrap();
if let Some(tags) = &v.tags {
let tags: Vec<String> = serde_json::from_value(tags.clone()).unwrap();
for tag in tags {
writer
.create_element("genre")
.write_text_content_async(BytesText::new(&tag))
.await
.unwrap();
}
}
writer
.create_element("uniqueid")
.with_attribute(("type", "bilibili"))
.write_text_content_async(BytesText::new(&v.bvid))
.await
.unwrap();
writer
.create_element("aired")
.write_text_content_async(BytesText::new(&nfo_time.format("%Y-%m-%d").to_string()))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
NFO::TVShow(tvshow) => {
Self::write_tvshow_nfo(writer, tvshow).await?;
}
NFOSerializer(ModelWrapper::Video(v), NFOMode::UPPER) => {
writer
.create_element("person")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer.create_element("plot").write_empty_async().await.unwrap();
writer.create_element("outline").write_empty_async().await.unwrap();
writer
.create_element("lockdata")
.write_text_content_async(BytesText::new("false"))
.await
.unwrap();
writer
.create_element("dateadded")
.write_text_content_async(BytesText::new(
&v.pubtime.format("%Y-%m-%d %H:%M:%S").to_string(),
))
.await
.unwrap();
writer
.create_element("title")
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
.await
.unwrap();
writer
.create_element("sorttitle")
.write_text_content_async(BytesText::new(&v.upper_id.to_string()))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
NFO::Upper(upper) => {
Self::write_upper_nfo(writer, upper).await?;
}
NFOSerializer(ModelWrapper::Page(p), NFOMode::EPOSODE) => {
writer
.create_element("episodedetails")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer.create_element("plot").write_empty_async().await.unwrap();
writer.create_element("outline").write_empty_async().await.unwrap();
writer
.create_element("title")
.write_text_content_async(BytesText::new(&p.name))
.await
.unwrap();
writer
.create_element("season")
.write_text_content_async(BytesText::new("1"))
.await
.unwrap();
writer
.create_element("episode")
.write_text_content_async(BytesText::new(&p.pid.to_string()))
.await
.unwrap();
Ok(writer)
})
.await
.unwrap();
NFO::Episode(episode) => {
Self::write_episode_nfo(writer, episode).await?;
}
_ => unreachable!(),
}
tokio_buffer.flush().await?;
Ok(std::str::from_utf8(&buffer).unwrap().to_owned())
Ok(String::from_utf8(buffer)?)
}
async fn write_movie_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, movie: Movie<'_>) -> Result<()> {
writer
.create_element("movie")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("plot")
.write_cdata_content_async(BytesCData::new(Self::format_plot(movie.bvid, movie.intro)))
.await?;
writer.create_element("outline").write_empty_async().await?;
writer
.create_element("title")
.write_text_content_async(BytesText::new(movie.name))
.await?;
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&movie.upper_id.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(movie.upper_name))
.await?;
Ok(writer)
})
.await?;
writer
.create_element("year")
.write_text_content_async(BytesText::new(&movie.aired.format("%Y").to_string()))
.await?;
if let Some(tags) = movie.tags {
for tag in tags {
writer
.create_element("genre")
.write_text_content_async(BytesText::new(&tag))
.await?;
}
}
writer
.create_element("uniqueid")
.with_attribute(("type", "bilibili"))
.write_text_content_async(BytesText::new(movie.bvid))
.await?;
writer
.create_element("aired")
.write_text_content_async(BytesText::new(&movie.aired.format("%Y-%m-%d").to_string()))
.await?;
Ok(writer)
})
.await?;
Ok(())
}
async fn write_tvshow_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, tvshow: TVShow<'_>) -> Result<()> {
writer
.create_element("tvshow")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("plot")
.write_cdata_content_async(BytesCData::new(Self::format_plot(tvshow.bvid, tvshow.intro)))
.await?;
writer.create_element("outline").write_empty_async().await?;
writer
.create_element("title")
.write_text_content_async(BytesText::new(tvshow.name))
.await?;
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&tvshow.upper_id.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(tvshow.upper_name))
.await?;
Ok(writer)
})
.await?;
writer
.create_element("year")
.write_text_content_async(BytesText::new(&tvshow.aired.format("%Y").to_string()))
.await?;
if let Some(tags) = tvshow.tags {
for tag in tags {
writer
.create_element("genre")
.write_text_content_async(BytesText::new(&tag))
.await?;
}
}
writer
.create_element("uniqueid")
.with_attribute(("type", "bilibili"))
.write_text_content_async(BytesText::new(tvshow.bvid))
.await?;
writer
.create_element("aired")
.write_text_content_async(BytesText::new(&tvshow.aired.format("%Y-%m-%d").to_string()))
.await?;
Ok(writer)
})
.await?;
Ok(())
}
async fn write_upper_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, upper: Upper) -> Result<()> {
writer
.create_element("person")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer.create_element("plot").write_empty_async().await?;
writer.create_element("outline").write_empty_async().await?;
writer
.create_element("lockdata")
.write_text_content_async(BytesText::new("false"))
.await?;
writer
.create_element("dateadded")
.write_text_content_async(BytesText::new(&upper.pubtime.format("%Y-%m-%d %H:%M:%S").to_string()))
.await?;
writer
.create_element("title")
.write_text_content_async(BytesText::new(&upper.upper_id))
.await?;
writer
.create_element("sorttitle")
.write_text_content_async(BytesText::new(&upper.upper_id))
.await?;
Ok(writer)
})
.await?;
Ok(())
}
async fn write_episode_nfo(mut writer: Writer<&mut BufWriter<&mut Vec<u8>>>, episode: Episode<'_>) -> Result<()> {
writer
.create_element("episodedetails")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer.create_element("plot").write_empty_async().await?;
writer.create_element("outline").write_empty_async().await?;
writer
.create_element("title")
.write_text_content_async(BytesText::new(episode.name))
.await?;
writer
.create_element("season")
.write_text_content_async(BytesText::new("1"))
.await?;
writer
.create_element("episode")
.write_text_content_async(BytesText::new(&episode.pid))
.await?;
Ok(writer)
})
.await?;
Ok(())
}
#[inline]
fn format_plot(bvid: &str, intro: &str) -> String {
format!(
r#"原始视频:<a href="https://www.bilibili.com/video/{}/">{}</a><br/><br/>{}"#,
bvid, bvid, intro,
)
}
}
@@ -252,39 +260,15 @@ mod tests {
chrono::NaiveDate::from_ymd_opt(2033, 3, 3).unwrap(),
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
),
bvid: "bvid".to_string(),
bvid: "BV1nWcSeeEkV".to_string(),
tags: Some(serde_json::json!(["tag1", "tag2"])),
..Default::default()
};
assert_eq!(
NFOSerializer(ModelWrapper::Video(&video), NFOMode::MOVIE)
.generate_nfo(&NFOTimeType::PubTime)
.await
.unwrap(),
NFO::Movie((&video).into()).generate_nfo().await.unwrap(),
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<movie>
<plot><![CDATA[intro]]></plot>
<outline/>
<title>name</title>
<actor>
<name>1</name>
<role>upper_name</role>
</actor>
<year>2033</year>
<genre>tag1</genre>
<genre>tag2</genre>
<uniqueid type="bilibili">bvid</uniqueid>
<aired>2033-03-03</aired>
</movie>"#,
);
assert_eq!(
NFOSerializer(ModelWrapper::Video(&video), NFOMode::TVSHOW)
.generate_nfo(&NFOTimeType::FavTime)
.await
.unwrap(),
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<tvshow>
<plot><![CDATA[intro]]></plot>
<plot><![CDATA[原始视频:<a href="https://www.bilibili.com/video/BV1nWcSeeEkV/">BV1nWcSeeEkV</a><br/><br/>intro]]></plot>
<outline/>
<title>name</title>
<actor>
@@ -294,15 +278,30 @@ mod tests {
<year>2022</year>
<genre>tag1</genre>
<genre>tag2</genre>
<uniqueid type="bilibili">bvid</uniqueid>
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
<aired>2022-02-02</aired>
</movie>"#,
);
assert_eq!(
NFO::TVShow((&video).into()).generate_nfo().await.unwrap(),
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<tvshow>
<plot><![CDATA[原始视频:<a href="https://www.bilibili.com/video/BV1nWcSeeEkV/">BV1nWcSeeEkV</a><br/><br/>intro]]></plot>
<outline/>
<title>name</title>
<actor>
<name>1</name>
<role>upper_name</role>
</actor>
<year>2022</year>
<genre>tag1</genre>
<genre>tag2</genre>
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
<aired>2022-02-02</aired>
</tvshow>"#,
);
assert_eq!(
NFOSerializer(ModelWrapper::Video(&video), NFOMode::UPPER)
.generate_nfo(&NFOTimeType::FavTime)
.await
.unwrap(),
NFO::Upper((&video).into()).generate_nfo().await.unwrap(),
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<person>
<plot/>
@@ -319,10 +318,7 @@ mod tests {
..Default::default()
};
assert_eq!(
NFOSerializer(ModelWrapper::Page(&page), NFOMode::EPOSODE)
.generate_nfo(&NFOTimeType::FavTime)
.await
.unwrap(),
NFO::Episode((&page).into()).generate_nfo().await.unwrap(),
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<episodedetails>
<plot/>
@@ -334,3 +330,61 @@ mod tests {
);
}
}
impl<'a> From<&'a video::Model> for Movie<'a> {
fn from(video: &'a video::Model) -> Self {
Self {
name: &video.name,
intro: &video.intro,
bvid: &video.bvid,
upper_id: video.upper_id,
upper_name: &video.upper_name,
aired: match VersionedConfig::get().load().nfo_time_type {
NFOTimeType::FavTime => video.favtime,
NFOTimeType::PubTime => video.pubtime,
},
tags: video
.tags
.as_ref()
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
}
}
}
impl<'a> From<&'a video::Model> for TVShow<'a> {
fn from(video: &'a video::Model) -> Self {
Self {
name: &video.name,
intro: &video.intro,
bvid: &video.bvid,
upper_id: video.upper_id,
upper_name: &video.upper_name,
aired: match VersionedConfig::get().load().nfo_time_type {
NFOTimeType::FavTime => video.favtime,
NFOTimeType::PubTime => video.pubtime,
},
tags: video
.tags
.as_ref()
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
}
}
}
impl<'a> From<&'a video::Model> for Upper {
fn from(video: &'a video::Model) -> Self {
Self {
upper_id: video.upper_id.to_string(),
pubtime: video.pubtime,
}
}
}
impl<'a> From<&'a page::Model> for Episode<'a> {
fn from(page: &'a page::Model) -> Self {
Self {
name: &page.name,
pid: page.pid.to_string(),
}
}
}

View File

@@ -0,0 +1,21 @@
use std::io;
use tokio::signal;
#[cfg(target_family = "windows")]
pub async fn terminate() -> io::Result<()> {
signal::ctrl_c().await
}
/// ctrl + c 发送的是 SIGINT 信号docker stop 发送的是 SIGTERM 信号,都需要处理
#[cfg(target_family = "unix")]
pub async fn terminate() -> io::Result<()> {
use tokio::select;
let mut term = signal::unix::signal(signal::unix::SignalKind::terminate())?;
let mut int = signal::unix::signal(signal::unix::SignalKind::interrupt())?;
select! {
_ = term.recv() => Ok(()),
_ = int.recv() => Ok(()),
}
}

View File

@@ -1,136 +1,179 @@
use anyhow::Result;
use crate::error::ExecutionStatus;
static STATUS_MAX_RETRY: u32 = 0b100;
static STATUS_OK: u32 = 0b111;
pub static STATUS_NOT_STARTED: u32 = 0b000;
pub(super) static STATUS_MAX_RETRY: u32 = 0b100;
pub static STATUS_OK: u32 = 0b111;
pub static STATUS_COMPLETED: u32 = 1 << 31;
/// 用来表示下载的状态,不想写太多列了,所以仅使用一个 u32 表示。
/// 从低位开始,固定每三位表示一种数据的状态,从 0b000 开始,每失败一次加一,最多 0b100即重试 4 次),
/// 如果成功,将对应的三位设置为 0b111
/// 当所有任务都成功或者由于尝试次数过多失败,为 status 最高位打上标记 1将来不再继续尝试
#[derive(Clone)]
pub struct Status(u32);
/// 从低位开始,固定每三位表示一种子任务的状态。
/// 子任务状态从 0b000 开始,每执行失败一次将状态加一,最多 0b100即允许重试 4 次),该值定义为 STATUS_MAX_RETRY
/// 如果子任务执行成功,将状态设置为 0b111该值定义为 STATUS_OK
/// 子任务达到最大失败次数或者执行成功时,认为该子任务已经完成。
/// 当所有子任务都已经完成时,为最高位打上标记 1表示整个下载任务已经完成。
#[derive(Clone, Copy, Default)]
pub struct Status<const N: usize>(u32);
impl Status {
/// 如果 status 整体大于等于 1 << 31则表示任务已经被处理过不再需要重试。
/// 数据库可以使用 status < Status::handled() 来筛选需要处理的内容。
pub const fn handled() -> u32 {
1 << 31
impl<const N: usize> Status<N> {
// 获取最高位的完成标记
pub fn get_completed(&self) -> bool {
self.0 >> 31 == 1
}
fn new(status: u32) -> Self {
Self(status)
/// 依次检查所有子任务是否还应该继续执行,返回一个 bool 数组
pub fn should_run(&self) -> [bool; N] {
let mut result = [false; N];
for (i, item) in result.iter_mut().enumerate() {
*item = self.check_continue(i);
}
result
}
/// 一般仅需要被内部调用,用来设置最高位的标记
fn set_flag(&mut self, handled: bool) {
if handled {
/// 重置所有失败的状态,将状态设置为 0b000返回值表示 status 是否发生了变化
pub fn reset_failed(&mut self) -> bool {
let mut changed = false;
for i in 0..N {
let status = self.get_status(i);
if !(status < STATUS_MAX_RETRY || status == STATUS_OK) {
self.set_status(i, STATUS_NOT_STARTED);
changed = true;
}
}
changed
}
/// 重置所有失败的状态,将状态设置为 0b000返回值表示 status 是否发生了变化
/// force 版本在普通版本的基础上,会额外检查是否存在需要运行的任务,如果存在则修正 completed 标记位为“未完成”
/// 这个方法的典型用例是在引入新的任务状态后重置历史视频,允许历史视频执行新引入的任务
pub fn force_reset_failed(&mut self) -> bool {
let mut changed = self.reset_failed();
// 理论上上面的 changed 就足够了,因为 completed 标志位的改变是由子任务状态的改变引起的,子任务没有改变则 completed 也不会改变
// 但考虑特殊情况,新版本引入了一个新的子任务项,此时会出现明明有子任务未执行,但 completed 标记位仍然为 true 的情况
// 当然可以在新版本迁移文件中全局重置 completed 标记位,但这样影响范围太大感觉不太好
// 在后面进行这部分额外判断可以兼容这种情况,在由用户手动触发的 reset_failed 调用中修正 completed 标记位
if self.should_run().into_iter().any(|x| x) {
changed |= self.get_completed();
self.set_completed(false);
}
changed
}
/// 覆盖某个子任务的状态
pub fn set(&mut self, offset: usize, status: u32) {
assert!(status < 0b1000, "status should be less than 0b1000");
self.set_status(offset, status);
if self.should_run().into_iter().all(|x| !x) {
self.set_completed(true);
} else {
self.set_completed(false);
}
}
/// 根据任务结果更新状态,任务结果是一个 Result 数组,需要与子任务一一对应
/// 如果所有子任务都已经完成,那么打上最高位的完成标记
pub fn update_status(&mut self, result: &[ExecutionStatus]) {
assert!(result.len() == N, "result length should be equal to N");
for (i, res) in result.iter().enumerate() {
self.set_result(res, i);
}
if self.should_run().into_iter().all(|x| !x) {
self.set_completed(true);
} else {
self.set_completed(false);
}
}
/// 设置最高位的完成标记
fn set_completed(&mut self, completed: bool) {
if completed {
self.0 |= 1 << 31;
} else {
self.0 &= !(1 << 31);
}
}
/// 从低到高检查状态,如果该位置的任务应该继续尝试执行,则返回 true否则返回 false
fn should_run(&self, size: usize) -> Vec<bool> {
(0..size).map(|x| self.check_continue(x)).collect()
/// 获取某个子任务的状态
fn get_status(&self, offset: usize) -> u32 {
(self.0 >> (offset * 3)) & 0b111
}
/// 如果任务的执行次数小于 STATUS_MAX_RETRY说明可以继续运行
fn check_continue(&self, offset: usize) -> bool {
self.get_status(offset) < STATUS_MAX_RETRY
}
/// 根据任务结果更新状态,如果任务成功,设置为 STATUS_OK否则加一
fn update_status(&mut self, result: &[Result<()>]) {
for (i, res) in result.iter().enumerate() {
self.set_result(res, i);
}
if self.should_run(result.len()).iter().all(|x| !x) {
// 所有任务都成功或者由于尝试次数过多失败,为 status 最高位打上标记,将来不再重试
self.set_flag(true)
}
}
fn set_result(&mut self, result: &Result<()>, offset: usize) {
if result.is_ok() {
// 如果任务已经执行到最大次数,那么此时 Result 也是 Ok此时不应该更新状态
if self.get_status(offset) < STATUS_MAX_RETRY {
self.set_ok(offset);
}
} else {
self.plus_one(offset);
}
/// 设置某个子任务的状态
fn set_status(&mut self, offset: usize, status: u32) {
self.0 = (self.0 & !(0b111 << (offset * 3))) | (status << (offset * 3));
}
// 将某个子任务的状态加一(在任务失败时使用)
fn plus_one(&mut self, offset: usize) {
self.0 += 1 << (3 * offset);
}
// 设置某个子任务的状态为 STATUS_OK在任务成功时使用
fn set_ok(&mut self, offset: usize) {
self.0 |= STATUS_OK << (3 * offset);
}
fn get_status(&self, offset: usize) -> u32 {
let helper = !0u32;
(self.0 & (helper << (offset * 3)) & (helper >> (32 - 3 * offset - 3))) >> (offset * 3)
/// 检查某个子任务是否还应该继续执行,实际是检查该子任务的状态是否小于 STATUS_MAX_RETRY
fn check_continue(&self, offset: usize) -> bool {
self.get_status(offset) < STATUS_MAX_RETRY
}
/// 根据子任务执行结果更新子任务的状态
fn set_result(&mut self, result: &ExecutionStatus, offset: usize) {
// 如果任务返回 FixedFailed 状态,那么无论之前的状态如何,都将状态设置为 FixedFailed 的状态
if let ExecutionStatus::FixedFailed(status, _) = result {
assert!(*status < 0b1000, "status should be less than 0b1000");
self.set_status(offset, *status);
} else if self.get_status(offset) < STATUS_MAX_RETRY {
match result {
ExecutionStatus::Succeeded | ExecutionStatus::Skipped => self.set_ok(offset),
ExecutionStatus::Failed(_) => self.plus_one(offset),
_ => {}
}
}
}
}
impl From<Status> for u32 {
fn from(status: Status) -> Self {
impl<const N: usize> From<u32> for Status<N> {
fn from(status: u32) -> Self {
Status(status)
}
}
impl<const N: usize> From<Status<N>> for u32 {
fn from(status: Status<N>) -> Self {
status.0
}
}
/// 从前到后分别表示视频封面、视频信息、Up 主头像、Up 主信息、分 P 下载
#[derive(Clone)]
pub struct VideoStatus(Status);
impl VideoStatus {
pub fn new(status: u32) -> Self {
Self(Status::new(status))
}
pub fn should_run(&self) -> Vec<bool> {
self.0.should_run(5)
}
pub fn update_status(&mut self, result: &[Result<()>]) {
assert!(result.len() == 5, "VideoStatus should have 5 status");
self.0.update_status(result)
impl<const N: usize> From<Status<N>> for [u32; N] {
fn from(status: Status<N>) -> Self {
let mut result = [0; N];
for (i, item) in result.iter_mut().enumerate() {
*item = status.get_status(i);
}
result
}
}
impl From<VideoStatus> for u32 {
fn from(status: VideoStatus) -> Self {
status.0.into()
impl<const N: usize> From<[u32; N]> for Status<N> {
fn from(status: [u32; N]) -> Self {
let mut result = Status::<N>::default();
for (i, item) in status.iter().enumerate() {
assert!(*item < 0b1000, "status should be less than 0b1000");
result.set_status(i, *item);
}
if result.should_run().iter().all(|x| !x) {
result.set_completed(true);
}
result
}
}
/// 从前到后分别表示:视频封面、视频内容、视频信息
#[derive(Clone)]
pub struct PageStatus(Status);
/// 包含五个子任务从前到后依次是视频封面、视频信息、Up 主头像、Up 主信息、分页下载
pub type VideoStatus = Status<5>;
impl PageStatus {
pub fn new(status: u32) -> Self {
Self(Status::new(status))
}
pub fn should_run(&self) -> Vec<bool> {
self.0.should_run(4)
}
pub fn update_status(&mut self, result: &[Result<()>]) {
assert!(result.len() == 4, "PageStatus should have 4 status");
self.0.update_status(result)
}
}
impl From<PageStatus> for u32 {
fn from(status: PageStatus) -> Self {
status.0.into()
}
}
/// 包含五个子任务,从前到后分别是:视频封面、视频内容、视频信息、视频弹幕、视频字幕
pub type PageStatus = Status<5>;
#[cfg(test)]
mod test {
@@ -139,16 +182,93 @@ mod test {
use super::*;
#[test]
fn test_status() {
let mut status = Status::new(0);
assert_eq!(status.should_run(3), vec![true, true, true]);
for count in 1..=3 {
status.update_status(&[Err(anyhow!("")), Ok(()), Ok(())]);
assert_eq!(status.should_run(3), vec![true, false, false]);
assert_eq!(u32::from(status.clone()), 0b111_111_000 + count);
fn test_status_update() {
let mut status = Status::<3>::default();
assert_eq!(status.should_run(), [true, true, true]);
for _ in 0..3 {
status.update_status(&[
ExecutionStatus::Failed(anyhow!("")),
ExecutionStatus::Succeeded,
ExecutionStatus::Succeeded,
]);
assert_eq!(status.should_run(), [true, false, false]);
}
status.update_status(&[Err(anyhow!("")), Ok(()), Ok(())]);
assert_eq!(status.should_run(3), vec![false, false, false]);
assert_eq!(u32::from(status), 0b111_111_100 | Status::handled());
status.update_status(&[
ExecutionStatus::Failed(anyhow!("")),
ExecutionStatus::Succeeded,
ExecutionStatus::Succeeded,
]);
assert_eq!(status.should_run(), [false, false, false]);
assert!(status.get_completed());
status.update_status(&[
ExecutionStatus::FixedFailed(1, anyhow!("")),
ExecutionStatus::FixedFailed(4, anyhow!("")),
ExecutionStatus::FixedFailed(7, anyhow!("")),
]);
assert_eq!(status.should_run(), [true, false, false]);
assert!(!status.get_completed());
assert_eq!(<[u32; 3]>::from(status), [1, 4, 7]);
}
#[test]
fn test_status_convert() {
let testcases = [[0, 0, 1], [1, 2, 3], [3, 1, 2], [3, 0, 7]];
for testcase in testcases.iter() {
let status = Status::<3>::from(testcase.clone());
assert_eq!(<[u32; 3]>::from(status), *testcase);
}
}
#[test]
fn test_status_convert_and_update() {
let testcases = [([0, 0, 1], [1, 7, 7]), ([3, 4, 3], [4, 4, 7]), ([3, 1, 7], [4, 7, 7])];
for (before, after) in testcases.iter() {
let mut status = Status::<3>::from(before.clone());
status.update_status(&[
ExecutionStatus::Failed(anyhow!("")),
ExecutionStatus::Succeeded,
ExecutionStatus::Succeeded,
]);
assert_eq!(<[u32; 3]>::from(status), *after);
}
}
#[test]
fn test_status_reset_failed() {
// 重置一个已经失败的任务
let mut status = Status::<3>::from([3, 4, 7]);
assert!(!status.get_completed());
assert!(status.reset_failed());
assert!(!status.get_completed());
assert_eq!(<[u32; 3]>::from(status), [3, 0, 7]);
// 没有内容需要重置,但 completed 标记位是错误的(模拟新增一个子任务状态的情况)
// 此时 reset_failed 不会修正 completed 标记位,而 force_reset_failed 会
status.set_completed(true);
assert!(status.get_completed());
assert!(!status.reset_failed());
assert!(status.get_completed());
assert!(status.force_reset_failed());
assert!(!status.get_completed());
// 重置一个已经成功的任务,没有改变状态,也不会修改标记位
let mut status = Status::<3>::from([7, 7, 7]);
assert!(status.get_completed());
assert!(!status.reset_failed());
assert!(status.get_completed());
}
#[test]
fn test_status_set() {
// 设置子状态,从 completed 到 uncompleted
let mut status = Status::<5>::from([7, 7, 7, 7, 7]);
assert!(status.get_completed());
status.set(4, 0);
assert!(!status.get_completed());
assert_eq!(<[u32; 5]>::from(status), [7, 7, 7, 7, 0]);
// 设置子状态,从 uncompleted 到 completed
let mut status = Status::<5>::from([4, 7, 7, 7, 0]);
assert!(!status.get_completed());
status.set(4, 7);
assert!(status.get_completed());
assert_eq!(<[u32; 5]>::from(status), [4, 7, 7, 7, 7]);
}
}

View File

@@ -0,0 +1,79 @@
use std::sync::{Arc, LazyLock};
use serde::Serialize;
use tokio::sync::MutexGuard;
use crate::config::VersionedConfig;
pub static TASK_STATUS_NOTIFIER: LazyLock<TaskStatusNotifier> = LazyLock::new(TaskStatusNotifier::new);
#[derive(Serialize)]
pub struct TaskStatus {
is_running: bool,
last_run: Option<chrono::DateTime<chrono::Local>>,
last_finish: Option<chrono::DateTime<chrono::Local>>,
next_run: Option<chrono::DateTime<chrono::Local>>,
}
pub struct TaskStatusNotifier {
mutex: tokio::sync::Mutex<()>,
tx: tokio::sync::watch::Sender<Arc<TaskStatus>>,
rx: tokio::sync::watch::Receiver<Arc<TaskStatus>>,
}
impl Default for TaskStatus {
fn default() -> Self {
Self {
is_running: false,
last_run: None,
last_finish: None,
next_run: None,
}
}
}
impl TaskStatusNotifier {
pub fn new() -> Self {
let (tx, rx) = tokio::sync::watch::channel(Arc::new(TaskStatus::default()));
Self {
mutex: tokio::sync::Mutex::const_new(()),
tx,
rx,
}
}
pub async fn start_running(&self) -> MutexGuard<()> {
let lock = self.mutex.lock().await;
let _ = self.tx.send(Arc::new(TaskStatus {
is_running: true,
last_run: Some(chrono::Local::now()),
last_finish: None,
next_run: None,
}));
lock
}
pub fn finish_running(&self, _lock: MutexGuard<()>) {
let last_status = self.tx.borrow();
let last_run = last_status.last_run.clone();
drop(last_status);
let config = VersionedConfig::get().load();
let now = chrono::Local::now();
let _ = self.tx.send(Arc::new(TaskStatus {
is_running: false,
last_run,
last_finish: Some(now),
next_run: now.checked_add_signed(chrono::Duration::seconds(config.interval as i64)),
}));
}
/// 精确探测任务执行状态,保证如果读取到“未运行”,那么在锁释放之前任务不会被执行
pub fn detect_running(&self) -> Option<MutexGuard<'_, ()>> {
self.mutex.try_lock().ok()
}
pub fn subscribe(&self) -> tokio::sync::watch::Receiver<Arc<TaskStatus>> {
self.rx.clone()
}
}

View File

@@ -0,0 +1,23 @@
use std::path::Path;
use validator::ValidationError;
use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK};
pub fn validate_status_value(value: u32) -> Result<(), ValidationError> {
if value == STATUS_OK || value == STATUS_NOT_STARTED {
Ok(())
} else {
Err(ValidationError::new(
"status_value must be either STATUS_OK or STATUS_NOT_STARTED",
))
}
}
pub fn validate_path(path: &str) -> Result<(), ValidationError> {
if path.is_empty() || !Path::new(path).is_absolute() {
Err(ValidationError::new("path must be a non-empty absolute path"))
} else {
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,8 @@ pub struct Model {
pub r#type: i32,
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,17 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "config")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub data: String,
pub created_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -12,6 +12,8 @@ pub struct Model {
pub name: String,
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -3,6 +3,7 @@
pub mod prelude;
pub mod collection;
pub mod config;
pub mod favorite;
pub mod page;
pub mod submission;

View File

@@ -11,6 +11,8 @@ pub struct Model {
pub upper_name: String,
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -9,6 +9,8 @@ pub struct Model {
pub id: i32,
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -4,6 +4,10 @@ mod m20240322_000001_create_table;
mod m20240505_130850_add_collection;
mod m20240709_130914_watch_later;
mod m20240724_161008_submission;
mod m20250122_062926_add_latest_row_at;
mod m20250612_090826_add_enabled;
mod m20250613_043257_add_config;
mod m20250712_080013_add_video_created_at_index;
pub struct Migrator;
@@ -15,6 +19,10 @@ impl MigratorTrait for Migrator {
Box::new(m20240505_130850_add_collection::Migration),
Box::new(m20240709_130914_watch_later::Migration),
Box::new(m20240724_161008_submission::Migration),
Box::new(m20250122_062926_add_latest_row_at::Migration),
Box::new(m20250612_090826_add_enabled::Migration),
Box::new(m20250613_043257_add_config::Migration),
Box::new(m20250712_080013_add_video_created_at_index::Migration),
]
}
}

View File

@@ -0,0 +1,122 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::schema::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 为四张 video source 表添加 latest_row_at 字段,表示该列表处理到的最新时间
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.add_column(timestamp(Favorite::LatestRowAt).default("1970-01-01 00:00:00"))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.add_column(timestamp(Collection::LatestRowAt).default("1970-01-01 00:00:00"))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.add_column(timestamp(WatchLater::LatestRowAt).default("1970-01-01 00:00:00"))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.add_column(timestamp(Submission::LatestRowAt).default("1970-01-01 00:00:00"))
.to_owned(),
)
.await?;
// 手动写 SQL 更新这四张表的 latest 字段到当前取值
let db = manager.get_connection();
db.execute_unprepared(
"UPDATE favorite SET latest_row_at = (SELECT IFNULL(MAX(favtime), '1970-01-01 00:00:00') FROM video WHERE favorite_id = favorite.id)",
)
.await?;
db.execute_unprepared(
"UPDATE collection SET latest_row_at = (SELECT IFNULL(MAX(pubtime), '1970-01-01 00:00:00') FROM video WHERE collection_id = collection.id)",
)
.await?;
db.execute_unprepared(
"UPDATE watch_later SET latest_row_at = (SELECT IFNULL(MAX(favtime), '1970-01-01 00:00:00') FROM video WHERE watch_later_id = watch_later.id)",
)
.await?;
db.execute_unprepared(
"UPDATE submission SET latest_row_at = (SELECT IFNULL(MAX(ctime), '1970-01-01 00:00:00') FROM video WHERE submission_id = submission.id)",
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.drop_column(Favorite::LatestRowAt)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.drop_column(Collection::LatestRowAt)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.drop_column(WatchLater::LatestRowAt)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.drop_column(Submission::LatestRowAt)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Favorite {
Table,
LatestRowAt,
}
#[derive(DeriveIden)]
enum Collection {
Table,
LatestRowAt,
}
#[derive(DeriveIden)]
enum WatchLater {
Table,
LatestRowAt,
}
#[derive(DeriveIden)]
enum Submission {
Table,
LatestRowAt,
}

View File

@@ -0,0 +1,101 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.add_column(ColumnDef::new(WatchLater::Enabled).boolean().not_null().default(false))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.add_column(ColumnDef::new(Submission::Enabled).boolean().not_null().default(false))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.add_column(ColumnDef::new(Favorite::Enabled).boolean().not_null().default(false))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.add_column(ColumnDef::new(Collection::Enabled).boolean().not_null().default(false))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.drop_column(WatchLater::Enabled)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.drop_column(Submission::Enabled)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.drop_column(Favorite::Enabled)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.drop_column(Collection::Enabled)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum WatchLater {
Table,
Enabled,
}
#[derive(DeriveIden)]
enum Submission {
Table,
Enabled,
}
#[derive(DeriveIden)]
enum Favorite {
Table,
Enabled,
}
#[derive(DeriveIden)]
enum Collection {
Table,
Enabled,
}

View File

@@ -0,0 +1,44 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Config::Table)
.if_not_exists()
.col(
ColumnDef::new(Config::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Config::Data).text().not_null())
.col(
ColumnDef::new(Config::CreatedAt)
.timestamp()
.default(Expr::current_timestamp())
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Config::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
enum Config {
Table,
Id,
Data,
CreatedAt,
}

View File

@@ -0,0 +1,36 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_index(
Index::create()
.table(Video::Table)
.name("video_created_at_index")
.col(Video::CreatedAt)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.table(Video::Table)
.name("video_created_at_index")
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Video {
Table,
CreatedAt,
}

View File

@@ -21,7 +21,7 @@ export default defineConfig({
nav: [
{ text: "主页", link: "/" },
{
text: "v2.2.0",
text: "v2.6.0",
items: [
{
text: "程序更新",
@@ -45,7 +45,7 @@ export default defineConfig({
{
text: "细节",
items: [
{ text: "配置文件", link: "/configuration" },
{ text: "配置说明", link: "/configuration" },
{ text: "命令行参数", link: "/args" },
{ text: "工作原理", link: "/design" },
],
@@ -55,12 +55,19 @@ export default defineConfig({
items: [
{ text: "获取收藏夹信息", link: "/favorite" },
{
text: "获取视频合集/视频列表信息",
text: "获取合集/列表信息",
link: "/collection",
},
{ text: "获取投稿信息", link: "/submission" },
{ text: "获取用户投稿信息", link: "/submission" },
],
},
{
text: "其它",
items: [
{ text: "常见问题", link: "/question" },
{ text: "管理页", link: "/frontend" },
],
}
],
socialLinks: [
{ icon: "github", link: "https://github.com/amtoaer/bili-sync" },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

BIN
docs/assets/config.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 296 KiB

BIN
docs/assets/webui.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,32 +1,28 @@
# 获取视频合集/视频列表信息
# 获取合集/列表信息
要说明的是,视频合集和视频列表虽然在哔哩哔哩网站交互上行为类似,但在接口层级是两个不同的概念。可以简单将视频列表理解为一个老旧版本的视频合集
视频合集和视频列表虽然在哔哩哔哩网站交互上行为类似,但在接口层级是两个不同的概念,程序配置中需要对两者做出区分
在调试过程中我注意到视频列表的 ID 可以通过某种规则转换为视频合集的 ID从而成功调用视频合集的接口但由于不清楚具体的转换策略在 bili-sync 的实现中还是将其当成两种类型处理
目前 B 站绝大部分内容都是视频合集Season视频列表Series是古早的功能现在已经不常见了
## 区分方法
## 配置形式与区分方法
这两种类型可以很容易地通过如下手段区分
1. 两者的名称前缀不同,视频合集会有显式的“合集”字样
2. 两者的图标不同
新版本 b 站网页端已经对两种类型做了初步整合,将需要的参数展示在了视频合集/视频列表的 URL 中不再需要手动查看接口。URL 的路径格式为
如下图所示,“合集【命运方舟全剧情解说】”是视频合集,而“阿拉德冒险记”是视频列表。
![image](./assets/collection.webp)
在 bili-sync 的设计中,视频合集的 key 为 `season:{mid}:{season_id}`,而视频列表的 key 为 `series:{mid}:{series_id}`
```
/{mid}/lists/{id}?type={season/series}
```
## 参数获取
了解了区分方法后,我们可以通过如下步骤获取视频合集/视频列表的信息。
点开你想要订阅的视频合集/视频列表详情,对照查看 URL 即可获取所需参数。
### 视频合集
![image](./assets/season.webp)
该视频合集的 key 为 `season:521722088:1987140`
类型为 `合集Season`,用户 ID 为 `521722088`,合集 ID 为 `1987140`
### 视频列表
![image](./assets/series.webp)
该视频列表的 key 为 `series:521722088:387214`
类型为 `列表Series`,用户 ID 为 `521722088`,列表 ID 为 `387214`

View File

@@ -1,10 +1,20 @@
# 配置文件
# 配置说明
默认的配置文件已经在[快速开始](/quick-start)中给出,该文档对配置文件的各个参数依次详细解释。
## 基本设置
## video_name 与 page_name
### 绑定地址
`video_name``page_name` 用于设置下载文件的命名规则,对于所有下载的内容,将会维持如下的目录结构:
程序 Web Server 监听的地址,程序启动时会监听该地址,成功后可通过 `http://${bind_address}` 访问管理页。
该配置会在程序重启时生效。
### 同步间隔(秒)
表示程序每次执行扫描下载的间隔时间,单位为秒。
### 视频名称模板、分页名称模板
视频名称模板(`video_name`)和分页名称模板(`page_name`)用于设置下载文件的命名规则。对于所有下载的内容,将会维持如下的目录结构:
1. 单页视频:
@@ -30,7 +40,7 @@
│   └── tvshow.nfo
```
这两个参数支持使用模板,其中用 <code v-pre>{{ }}</code> 包裹的模板变量在执行时会被动态替换为对应的内容。
这两个模板参数会在运行时解析,其中用 <code v-pre>{{ }}</code> 包裹的模板变量会被动态替换为对应的内容。
对于 `video_name`,支持设置 bvid视频编号、title视频标题、upper_nameup 主名称、upper_midup 主 id、pubtime视频发布时间、fav_time视频收藏时间
@@ -40,7 +50,7 @@
> [!TIP]
> 1. 仅收藏夹视频会区分 `fav_time` 和 `pubtime`,其它类型下载两者的取值是完全相同的;
> 2. `fav_time` 和 `pubtime` 的格式受 `time_format` 参数控制,详情可参考 [time_format 小节](#time-format)
> 2. `fav_time` 和 `pubtime` 的格式受[时间格式](#时间格式)控制
此外,`video_name` 和 `page_name` 还支持使用路径分割符,如 <code v-pre>{{ upper_mid }}/{{ title }}_{{ pubtime }}</code> 表示视频会根据 UP 主 id 将视频分到不同的文件夹中。
@@ -49,55 +59,36 @@
> [!CAUTION]
> **路径分隔符**在不同平台定义不同Windows 下为 `\`MacOS 和 Linux 下为 `/`
## `interval`
表示程序每次执行扫描下载的间隔时间,单位为秒。
## `upper_path`
### UP 主头像保存路径
UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务器的用户,需确保此处路径指向 Emby、Jellyfin 配置中的 `/metadata/people/` 才能够正常在媒体服务器中显示 UP 主的头像。
## `nfo_time_type`
### 时间格式
表示在视频信息中使用的时间类型,可选值为 `favtime`(收藏时间)`pubtime`(发布时间)
用于设置 `fav_time` `pubtime` 在视频名称模板、分页名称模板中使用时的显示格式,支持的格式符号可以参考 [chrono strftime 文档](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
仅收藏夹视频会区分 `fav_time``pubtime`,其它类型下载两者取值相同。
### 后端 API 认证 Token
## `time_format`
表示调用程序管理 API 需要的身份凭据,程序会对 API 请求进行身份验证,身份验证不通过会拒绝访问。
时间格式,用于设置 `fav_time``pubtime``video_name``page_name` 中使用时的显示格式,支持的格式符号可以参考 [chrono strftime 文档](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
在修改该 Token 后需要对应修改前端保存的 Token才能正常访问管理页面
## `credential`
哔哩哔哩账号的身份凭据,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)获取并对应填写至配置文件中,后续 bili-sync 会在必要时自动刷新身份凭据,不再需要手动管理。
### 启动 CDN 排序
表示程序每次执行扫描下载的间隔时间,单位为秒。
## B 站认证
哔哩哔哩账号的身份凭据,请参考[凭据获取流程](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 选择视频编码的优先级顺序,优先级按顺序从高到低。此处对编码格式做一个简单说明:
@@ -109,130 +100,100 @@ UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务
而如果你的设备不支持,或者单纯懒得查询,那么推荐将 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
透明度,取值范围为 0-255。透明度可以通过 opacity / 255 计算得到
透明度,取值范围为 0-255。实际透明度百分比为 `透明度 / 255`
### `bold`
是否加粗。
### 描边宽度
### `outline`
弹幕的描边宽度。
描边宽度。
### `time_offset`
### 时间偏移(秒)
时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒。
## `favorite_list`
### 粗体显示
你想要下载的收藏夹与想要保存的位置。简单示例:
```toml
3115878158 = "/home/amtoaer/Downloads/bili-sync/测试收藏夹"
```
收藏夹 ID 的获取方式可以参考[这里](/favorite)。
弹幕是否加粗。
## `collection_list`
## 高级设置
你想要下载的视频合集/视频列表与想要保存的位置。注意“视频合集”与“视频列表”是两种不同的类型。在配置文件中需要做区分:
```toml
"series:387051756:432248" = "/home/amtoaer/Downloads/bili-sync/测试视频列表"
"season:1728547:101343" = "/home/amtoaer/Downloads/bili-sync/测试合集"
```
具体说明可以参考[这里](/collection)。
## `submission_list`
你想要下载的 UP 主投稿与想要保存的位置。简单示例:
```toml
9183758 = "/home/amtoaer/Downloads/bili-sync/测试投稿"
```
UP 主 ID 的获取方式可以参考[这里](/submission)。
## `watch_later`
设置稍后再看的扫描开关与保存位置。
如果你希望下载稍后再看列表中的视频,可以将 `enabled` 设置为 `true`,并填写 `path`
```toml
enabled = true
path = "/home/amtoaer/Downloads/bili-sync/稍后再看"
```
## `concurrent_limit`
对 bili-sync 的并发下载进行多方面的限制,避免 api 请求过于频繁导致的风控。其中 video 和 page 表示下载任务的并发数rate_limit 表示 api 请求的流量限制。默认取值为:
```toml
[concurrent_limit]
video = 3
page = 2
[concurrent_limit.rate_limit]
limit = 4
duration = 250
```
具体来说,程序的处理逻辑是严格从上到下的,即程序会首先并发处理多个 video每个 video 内再并发处理多个 page程序的并行度可以简单衡量为 `video * page`(很多 video 都只有单个 page实际会更接近 `video * 1`),配置项中的 `video``page` 两个参数就是控制此处的,调节这两个参数可以宏观上控制程序的并行度。
另一方面,每个执行的任务内部都会发起若干 api 请求以获取信息,这些请求的整体频率受到 `rate_limit` 的限制,使用漏桶算法实现。如默认配置表示的是每 250ms 允许 4 个 api 请求,超过这个频率的请求会被暂时阻塞,直到漏桶中有空间为止。调节 `rate_limit` 可以从微观上控制程序的并行度,同时也是最直接、最显著的控制 api 请求频率的方法。
据观察 b 站风控限制大多集中在主站,因此目前 `rate_limit` 仅作用于主站的各类请求,如请求各类视频列表、视频信息、获取流下载地址等,对实际的视频、图片下载过程不做限制。
该页主要用于调整程序的请求与下载行为。
> [!TIP]
> 1. 一般来说,`video` 和 `page` 的值不需要过大;
> 2. `rate_limit` 的值可以根据网络环境和 api 请求频率进行调整,如果经常遇到风控可以优先调小 limit。
> 1. 一般来说,视频、分页的并发数不需要过大;
> 2. 请求频率限制可以根据网络环境和 api 请求频率进行调整,如果经常遇到风控可以优先调小该值。
### 视频并发数、分页并发数
视频并发数video和分页并发数page是控制 bili-sync 视频下载任务并发度的配置项。
程序的处理逻辑是严格从上到下的,即程序会首先并发处理多个 video每个 video 内再并发处理多个 page程序的并发度可以简单衡量为 `video * page`(很多 video 都只有单个 page实际会更接近 `video * 1``video``page` 两个参数就是控制此处的,调节这两个参数可以宏观上控制程序的并发度。
### NFO 时间类型
表示在视频 NFO 文件中使用的时间类型,可选值为收藏时间和发布时间。
仅收藏夹视频会对这两项进行区分,其它类型的视频这两者取值完全相同。
### 请求频率限制
每个执行的任务内部都会发起若干 api 请求以获取信息,这些请求的整体频率受到请求频率的限制,使用漏桶算法实现。超过这个频率的请求会被暂时阻塞,直到漏桶中有空间为止。
时间间隔(毫秒)与限制请求数共同表明的意思时:程序在每个时间间隔内最多允许多少个请求。调节这一项可以从微观上控制程序的并行度,同时也是最直接、最显著的控制 api 请求频率的方法。
据观察 b 站风控限制大多集中在主站,因此目前请求频率限制仅作用于主站的各类请求,如请求各类视频列表、视频信息、获取流下载地址等,对实际的视频、图片下载过程不做限制。
### 单文件分块下载
单文件分块下载是指将单个视频文件分成多个小块进行下载,这可能有助于提高下载速度。
程序会首先为这个文件预分配空间,接着将文件分成若干个大小相同的块,为每个块启动单独的异步任务并行下载。
#### 下载分块数
表示单个文件分成多少个小块,默认值为 4。
#### 启动分块下载的文件大小阈值(字节)
表示当单个文件大小超过多少字节时,才会启动分块下载。默认值为 2097152020 MB
如果文件过小,分块成本可能会超过分块下载带来的收益,因此使用该阈值决定下载策略。

View File

@@ -10,7 +10,7 @@
- 每个视频都有唯一的 bvid包含了封面、描述和标签信息并包含了一个或多个分页
- 每个分页都有一个唯一的 cid包含了封面、视频、音频、弹幕。
为了描述方便,在后文会将收藏夹、稍后再看这类结构统称为 video list,将视频称为 video将分页称为 page。不难看出这三者有着很明显的层级关系**video list 包含若干 videovideo 包含若干 page**。
为了描述方便,在后文会将收藏夹、稍后再看这类结构统称为 video source,将视频称为 video将分页称为 page。不难看出这三者有着很明显的层级关系**video source 包含若干 videovideo 包含若干 page**。
一个非常容易混淆的点是视频合集/视频列表与多页视频的区别:
@@ -21,7 +21,7 @@
这两张图中,上图是视频合集,下图是多页视频。这两者在展示上区别较小,但在结构上有相当大的不同。结合上面对 b 站视频结构的介绍,这个区别可以简单总结为:
+ **视频合集是由多个仅包含单个 page 的 video 组成的 video list**
+ **视频合集是由多个仅包含单个 page 的 video 组成的 video source**
+ **多页视频是由多个 page 组成的 video**
@@ -31,7 +31,7 @@
EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/分集`,方便起见,我采用了如下的对应关系:
1. **文件夹**:对应 b 站的 video list
1. **文件夹**:对应 b 站的 video source
2. **电视剧** 对应 b 站的 video
3. **第一季的所有分集**:对应 b 站的 page。
@@ -54,11 +54,11 @@ EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/
> [!NOTE]
> 可以[前往此处](https://github.com/amtoaer/bili-sync/tree/main/crates/bili_sync_entity/src/entities)实时查看当前版本的数据库表结构。
既然拥有着明显的层级关系,那数据库表就很容易设计了。为了简化实现,程序没有额外考虑单个 video 被多个 video list 引用的情况(如一个视频同时在收藏夹和稍后再看中)。而是简单的将其设计为了不交叉的层级结构。
既然拥有着明显的层级关系,那数据库表就很容易设计了。为了简化实现,程序没有额外考虑单个 video 被多个 video source 引用的情况(如一个视频同时在收藏夹和稍后再看中)。而是简单的将其设计为了不交叉的层级结构。
### video list
### video source
从上面的介绍可以看出video list 并不是一个具体的结构,而是拥有多种实现的抽象概念。我选择将其特化实现为多张表:
从上面的介绍可以看出video source 并不是一个具体的结构,而是拥有多种实现的抽象概念。我选择将其特化实现为多张表:
1. favorite收藏夹
2. watch_later稍后再看
@@ -67,9 +67,9 @@ EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/
### video 表
video 表包含了 video 的基本信息,如 bvid、标题、封面、描述、标签等。此外video 表还包含了与 video list 的关联。
video 表包含了 video 的基本信息,如 bvid、标题、封面、描述、标签等。此外video 表还包含了与 video source 的关联。
具体来说,每一种 video list 都在 video 表中有一个对应的列,指向 video list 表中的 id如 favorite_id、collection_id 等。接下来将这些键与 video 的 bvid 绑在一起建立唯一索引,就可以保证在同一个 video list 中不会有重复的 video。
具体来说,每一种 video source 都在 video 表中有一个对应的列,指向 video source 表中的 id如 favorite_id、collection_id 等。接下来将这些键与 video 的 bvid 绑在一起建立唯一索引,就可以保证在同一个 video source 中不会有重复的 video。
### page 表
@@ -81,30 +81,31 @@ page 表包含了 page 的基本信息,如 cid、标题、封面等。与 vide
程序启动时会读取配置文件、迁移数据库、初始化日志等操作。如果发现需要的文件不存在,程序会自动创建。
### 扫描 video list
### 扫描 video source 获取新视频
> [!WARNING]
> b 站实现接口时为了节省资源,通过 video list 获取到的 video 列表通常是分页且不包含详细信息的。
> b 站实现接口时为了节省资源,通过 video source 获取到的 video 列表通常是分页且不包含详细信息的。
程序会扫描所有配置文件中包含的 video list,获取其中包含的 video 的简单信息并填充到数据库。在实现时需要避免频繁的全量扫描。
程序会扫描所有配置文件中包含的 video source,获取其中包含的 video 的简单信息并填充到数据库。在实现时需要避免频繁的全量扫描。
具体到 bili-sync 的实现中,程序在请求接口时会设置按时间顺序排序的参数,确保新发布的视频位于前面。拉取过程会逐页请求,使用视频的 bvid 与 time 字段来检验视频是否已经存在于数据库中。一旦发现 bvid 与 time 均相同的记录则认为已经到达扫描过的位置,停止拉取。
具体到 bili-sync 的实现中,每个 video source 都有一个 `latest_row_at` 列,用于记录处理过的最新视频时间。程序在请求接口时会设置按时间排序,确保新视频位于前面。排序依据的时间根据 video source 的类型而定:收藏夹按收藏时间,投稿按照投稿时间...
拉取过程会逐页请求,程序会不断将获取到的视频保存到数据库中,直到发现第一个小于等于 `latest_row_at` 的视频时停止。接着将 `latest_row_at` 更新为最新的视频时间。
### 填充 video 详情
将新增视频的简单信息写入数据库后,下一步会填充 video 详情。正如上文所述:**通过 video list 获取到的 video 列表通常是不包含详细信息的**,因此需要额外的请求来填充这些信息。
将新增视频的简单信息写入数据库后,下一步会填充 video 详情。正如上文所述:**通过 video source 获取到的 video 列表通常是不包含详细信息的**,因此需要额外的请求来填充这些信息。
这一步会筛选出所有未完全填充信息的 video逐个获取 video 的详细信息(如标签、视频分页等)并填充到数据库中。
这一步会筛选出所有未完全填充信息的 video逐个获取 video 的详细信息(如标签、包含的 page 等)并填充到数据库中。
在这个过程中,如果遇到 -404 错误码则说明视频无法被正常访问,程序会将该视频标记为无效并跳过。
### 下载未处理的视频
经过上面处理后,数据库中已经包含了所有需要的 video 信息,接下来只需要筛选其中“未完全下载”、“成功填充详细信息”的所有视频,并发下载即可。程序在 video 层级最多允许 3 个任务同时下载page 层级最多允许 2 个任务同时下载。
经过上面处理后,数据库中已经包含了所有需要的 video 和 page 信息,接下来只需要筛选其中“未完全下载”、“成功填充详细信息”的所有视频,并发下载即可。程序在 video 层级最多允许 3 个任务同时下载page 层级最多允许 2 个任务同时下载。
数据库中的 status 字段用于标记 video 和 page 的下载状态视频的各个部分封面、视频、nfo 等)包含在 status 的不同位中。程序会根据 status 的不同位来判断视频的下载状态,以此来决定是否需要下载。
如果某些部分下载失败status 字段会记录这些部分的失败次数,程序会在下次下载时重试。如果重试次数超过了设定的阈值,那么视频会被标记为下载失败,后续直接忽略。
此处程序对风控做了额外的处理,一般风控发生时接下来的所有请求都会失败,因此程序检测到风控时不会认为是某个视频下载失败,而是直接终止 video list 的全部下载任务,等待下次扫描时重试。
此处程序对风控做了额外的处理,一般风控发生时接下来的所有请求都会失败,因此程序检测到风控时不会认为是某个视频下载失败,而是直接终止 video source 的全部下载任务,等待下次扫描时重试。

Some files were not shown because too many files have changed in this diff Show More