Compare commits

...
115 Commits
Author SHA1 Message Date
lanyeeee 39082f62b6 fix: 修复插件修改DownloadProgress后导致已完成任务状态被回滚的问题 2026-08-25 10:38:15 +08:00
lanyeeee 843203c043 fix: 修复普通视频准备下载失败的问题 2026-08-19 05:28:51 +08:00
lanyeeee 650ae0f6ca fix: 修复CheeseInfo中的my_bp字段为小数时导致的json解析错误问题 2026-08-18 05:54:54 +08:00
lanyeeee 4aa8e7c032 feat: 支持下载SDR画质 2026-08-17 12:19:58 +08:00
lanyeeee e8f29aa371 feat: 支持未进行URL编码的SESSDATA 2026-08-16 06:06:58 +08:00
lanyeeee 608366a0bd feat: 提高下载任务的恢复效率 2026-08-15 04:16:27 +08:00
lanyeeee 4c563de2cf feat: 获取弹幕失败时会提供更友好的错误信息 2026-08-14 04:45:40 +08:00
lanyeeee 294b510948 feat: 获取字幕失败时会提供更友好的错误信息 2026-08-13 05:26:57 +08:00
lanyeeee bc0f85e420 feat: 获取广告片段失败时会提供更友好的错误信息 2026-08-12 05:23:58 +08:00
lanyeeee 5d9f3ae3ac feat: ffmpeg执行出错时会提供更友好的错误信息 2026-08-11 07:36:57 +08:00
lanyeeee 0a650c24f0 fix: 修复搜索up稿件时偶发的412错误问题 2026-08-10 06:41:43 +08:00
lanyeeee ace6b8efa0 feat: 支持窗口状态记忆 2026-08-09 04:15:29 +08:00
lanyeeee d1ef3a87be fix: 修复目录命名的episode_title字段映射错误的问题 2026-08-08 05:08:29 +08:00
lanyeeee f394ae68e7 fix: 修复 PlayerInfo 潜在的JSON解析错误问题 2026-08-07 04:20:57 +08:00
lanyeeee 1766262d6e refactor: 将下载任务操作逻辑封装到DownloadTask中 2026-08-06 05:17:42 +08:00
lanyeeee c78a11113a fix: 修复下载任务很多时导致前端性能下降的问题 2026-08-03 06:08:17 +08:00
lanyeeee 2ab8a91ef4 version: 0.2.1 2026-08-02 06:25:34 +08:00
lanyeeee 28c81795b8 refactor: 消除 sort_by 的 clippy unnecessary_sort_by 警告 2026-08-02 06:24:56 +08:00
lanyeeee 7d065797fb fix: 修复二维码登录失效的问题 2026-08-02 06:24:56 +08:00
lanyeeee c7261e30f5 version: 0.2.0 2026-03-19 04:02:10 +08:00
lanyeeee a39baa2b9a docs: 添加 pr 应基于develop分支开发的提醒 2026-03-18 10:09:16 +08:00
lanyeeee be62683caa docs: 修改README 2026-03-17 07:08:44 +08:00
lanyeeee d8e9efef87 ci: 将pr的格式检查与代码检查覆盖插件代码 2026-03-16 08:14:25 +08:00
lanyeeee b5698f8d43 feat: 将格式化和代码检查脚本覆盖插件代码 2026-03-15 10:33:29 +08:00
lanyeeee 43d9e8fe4d feat: 基础示例插件 2026-03-14 15:08:12 +08:00
lanyeeee 448b329a2a feat: 前端支持以拖入的方式导入插件 2026-03-13 19:54:28 +08:00
lanyeeee e240e719ce feat: 前端支持管理插件 2026-03-12 18:02:18 +08:00
lanyeeee 933f8000dd feat: 进程内动态库插件系统 2026-03-11 08:57:43 +08:00
lanyeeee 6be3289d93 chore: 添加一些TODO注释 2026-03-10 07:47:51 +08:00
lanyeeee 02d157ba70 refactor: 将后端模块入口从mod.rs改为同名文件 2026-03-09 06:39:58 +08:00
lanyeeee d1be51bec1 chore: 显式配置打包的 targets 2026-03-08 07:13:47 +08:00
lanyeeee e757c6b64e feat: 优化日志Dialog,支持查看实时日志和文件日志 2026-03-07 06:06:56 +08:00
lanyeeee 15cdb30763 pref: 提高LogEvent的效率(移除发送前的反序列化步骤) 2026-03-05 04:30:09 +08:00
lanyeeee 32c175ec2e feat: 给日志和错误信息补充丰富的SpanTrace 2026-03-04 04:59:48 +08:00
lanyeeee b5db04f5bd feat: 优化错误信息的格式 2026-03-03 04:08:46 +08:00
lanyeeee 5478edc770 refactor: 用eyre替换anyhow 2026-03-02 04:21:12 +08:00
lanyeeee 6ebdc83104 ci: 自动检查pr的前端代码 2026-03-01 05:50:15 +08:00
lanyeeee ef75daf6e1 ci: 自动检查pr的后端代码 2026-02-28 06:02:36 +08:00
lanyeeee b8aeff7cb2 feat: 新增代码检查脚本 2026-02-27 06:00:49 +08:00
lanyeeee 7065a4ce73 chore: 清理clippy的警告 2026-02-26 04:08:50 +08:00
lanyeeee 49fac05435 ci: 自动检查pr的代码格式 2026-02-25 04:17:52 +08:00
lanyeeee c560277589 style: 对整个项目进行代码格式化 2026-02-24 04:52:35 +08:00
lanyeeee 4cfafd4306 feat: 新增代码格式化脚本 2026-02-24 04:52:20 +08:00
lanyeeee 74af53f2e4 chore: 将项目迁移至 Rust 2024 Edition 2026-02-23 06:00:07 +08:00
lanyeeee ed368c1c6e chore: 强调复现步骤的重要性 2026-02-23 05:52:04 +08:00
lanyeeee 3bfc9c272d chore: Copyright 更新到2026 2026-02-23 05:48:43 +08:00
lanyeeee 04cc05856c feat: 给试看的资源添加提示 2026-02-22 04:21:09 +08:00
lanyeeee a45a85ee56 refactor: 模板引用全部改用useTemplateRef 2026-02-21 07:32:55 +08:00
lanyeeee 59e28536e1 pref: 优化追番追剧页面的性能 2026-02-20 04:31:37 +08:00
lanyeeee 2c40806eaa pref: 优化收藏夹页面的性能 2026-02-19 04:20:10 +08:00
lanyeeee 143bd0e773 pref: 优化稍后再看页面的性能 2026-02-18 05:03:47 +08:00
lanyeeee 8a91ae9a11 pref: 优化历史记录页面的性能 2026-02-17 05:10:10 +08:00
lanyeeee fa1191d0bb pref: 优化搜索页面的性能 2026-02-16 04:49:59 +08:00
lanyeeee 2a296da7b1 feat: 移除 auto-import 2026-02-15 04:23:23 +08:00
lanyeeee e8702fb734 pref: 优化下载任务进度条的性能 2026-02-10 04:16:30 +08:00
lanyeeee b988e9ff15 feat: 给支持补齐的字段添加提示 2026-02-09 20:55:16 +08:00
lanyeeee fe36c84426 refactor: 优化媒体资源的选择逻辑 2026-02-08 04:08:52 +08:00
lanyeeee c21e92c3a5 feat: 将配置中的下载速度代理合并为网络 2026-02-07 04:21:01 +08:00
lanyeeee 89666f1559 fix: 修复番剧无法获取智能修复下载链接的问题 2026-02-06 12:24:21 +08:00
lanyeeee bbfa31d4fa chore: 采纳一些clippy的建议 2026-02-05 04:57:46 +08:00
lanyeeee b7b99a8e43 feat: 给受版权保护(DRM)的视频添加提示,并跳过视频处理 2026-02-04 20:44:28 +08:00
lanyeeee ff573f1de9 refactor: 把处理DownloadProgress的逻辑移到其内部 2026-02-03 14:56:00 +08:00
lanyeeee bde649575f feat: 自定义目录格式新增画质、编码、音质字段 2026-02-02 11:47:08 +08:00
lanyeeee fa5be81fb8 feat: 支持设置创建下载任务后是否自动开始 2026-02-01 08:45:46 +08:00
lanyeeee f09e8cfc49 feat: 统一画质、编码、音质的名称显示逻辑 2026-02-01 08:45:46 +08:00
lanyeeee c9d377239b feat: 支持在修改下载内容时选择画质和音质 2026-02-01 08:45:46 +08:00
lanyeeee bfba2a1ab5 feat: 后端支持获取视频的画质和音质 2026-02-01 08:45:46 +08:00
lanyeeee b4851e4143 fix: 修复代码编写错误导致的while let提前终止问题 2026-01-29 19:08:09 +08:00
RrOrange 4bfe2df405 fix: 修复 HEAD 无法获取 Content-Length 导致选流降级的问题 2026-01-29 15:27:19 +08:00
lanyeeee 8c5cb7fa5c feat: 支持修改下载内容 2026-01-27 15:20:27 +08:00
lanyeeee 948fecb7cd feat: 后端支持修改下载内容 2026-01-27 05:04:17 +08:00
lanyeeee 747300ef30 fix: 修复下载任务重来后,画质和音质会随设置而变化的问题 2026-01-25 04:57:38 +08:00
lanyeeee 813c0757b4 chore: 解决一些编译警告 2026-01-24 04:22:47 +08:00
lanyeeee e2974ed0f6 fix: 修复部分电影无法获取4K下载链接的问题 2026-01-22 04:31:42 +08:00
lanyeeee dab9c21366 feat: 支持配置下载文件已存在时的行为 2026-01-21 05:24:44 +08:00
lanyeeee 91a81fb1ba feat: 后端支持配置下载文件已存在时的行为 2026-01-21 05:24:44 +08:00
lanyeeee d76130a71f fix: 修复充电试看视频下载失败的问题 2026-01-19 04:55:01 +08:00
lanyeeee c96f69892f fix: 修复充电试看视频的json解析错误问题 2026-01-19 04:55:01 +08:00
lanyeeee 588831ff1b version: 0.1.1 2025-12-03 04:42:53 +08:00
lanyeeee 72811e3b7b feat: 给创建PR添加提示 2025-12-03 04:42:05 +08:00
lanyeeee 7055dbac1f fix: 修复获取视频链接时偶发http状态码为412的问题 2025-12-01 10:02:48 +08:00
lanyeeee 8a498b0f6a docs: 修改README 2025-09-14 07:24:41 +08:00
lanyeeee d8f350df9a docs: 修改README 2025-09-13 00:16:04 +08:00
lanyeeee ca3e106be0 docs: 编写README 2025-09-12 06:44:07 +08:00
lanyeeee ccdb0b5e5a Merge pull request #14 from lanyeeee/develop
Develop
2025-09-12 06:36:00 +08:00
lanyeeee 0bca2912e7 version: 0.1.0 2025-09-12 06:21:12 +08:00
lanyeeee 88c45edb74 feat: 减少JSON解析错误出现的频率 2025-09-11 06:08:22 +08:00
lanyeeee 07f6e18719 refactor: 抽象出IconButton以优化代码 2025-09-10 06:49:27 +08:00
lanyeeee c8b24073bb style: 优化侧边导航栏样式 2025-09-09 07:24:08 +08:00
lanyeeee a47de661f6 style: 调整下载设置中选项的宽度以改善布局 2025-09-09 07:23:32 +08:00
lanyeeee bc6ac4bcc3 feat: 支持查看历史记录 2025-09-08 06:08:20 +08:00
lanyeeee 605c55fdec feat: 后端支持获取历史记录 2025-09-08 06:08:20 +08:00
lanyeeee dd86eb9e41 perf: 仅在必要时构造下载任务的Future 2025-09-06 05:16:09 +08:00
lanyeeee 5ed41ef7b6 feat: 支持设置 画质、音质、编码 的优先级 2025-09-05 05:06:38 +08:00
lanyeeee ae73e474c7 style: 微调下载进度组件中的标签颜色 2025-09-04 04:28:19 +08:00
lanyeeee c0c8af9094 refactor: 优化视频处理任务中ffmpeg命令的构建方式 2025-09-04 04:28:04 +08:00
lanyeeee 19df52b4bc Merge pull request #9 from lanyeeee/develop
Develop
2025-09-03 07:02:02 +08:00
lanyeeee f49340a42b fix: 修复 没分段没广告 的视频会嵌入一个空章节元数据的问题 2025-09-03 04:03:14 +08:00
lanyeeee 8372b05143 refactor: 将自动合并任务与嵌入章节任务合并成视频处理任务 2025-09-02 04:40:51 +08:00
lanyeeee 2ba7504359 feat: 支持标记广告 2025-09-01 06:44:47 +08:00
lanyeeee 39b735e525 feat: 后端支持以章节元数据嵌入跳过片段 2025-09-01 06:44:47 +08:00
lanyeeee f86c882d3d feat: 后端支持获取跳过片段 2025-09-01 06:44:47 +08:00
lanyeeee 387d50030d refactor: 将各任务处理逻辑移动到自己对应的文件中 2025-08-28 05:09:15 +08:00
lanyeeee 91f8c6d50c feat: 支持标记章节 2025-08-27 07:33:47 +08:00
lanyeeee 3a7e30b2cf feat: 后端支持嵌入章节元数据 2025-08-27 07:33:47 +08:00
lanyeeee 8ddb61d6ca feat: 给导航切换添加淡入淡出动画 2025-08-24 04:40:47 +08:00
lanyeeee 697a926398 fix: 解决没有签名导致macOS安装显示文件已损坏的问题 2025-08-24 04:40:32 +08:00
lanyeeee b184bbc469 fix: 修复UserVideoInfo潜在的json解析错误问题 2025-08-23 05:11:09 +08:00
lanyeeee 46ddbf999f fix: 解决Ubuntu 24.04窗口全白的问题 2025-08-23 05:10:52 +08:00
lanyeeee 47ce4a6a62 fix: 修复删除正在下载的任务时,被删除的任务重启后有概率又出现的问题 2025-08-22 04:46:15 +08:00
lanyeeee 2053ca439e fix: 修复使用season_id搜索时,panel的状态未重置的问题 2025-08-22 04:45:58 +08:00
lanyeeee 7cafb754ae feat: 追番追剧 2025-08-21 04:49:57 +08:00
lanyeeee e1088cb8f5 feat: 后端支持获取追番信息 2025-08-21 04:49:57 +08:00
lanyeeee 703723dfaa fix: 修复未登录时UP投稿搜索无法使用的问题 2025-08-19 07:22:18 +08:00
lanyeeee fa27007eb9 feat: 添加ISSUE模板 2025-08-18 20:48:27 +08:00
158 changed files with 11826 additions and 2765 deletions
+76
View File
@@ -0,0 +1,76 @@
name: 🐞 反馈 Bug
description: 反馈遇到的问题
labels: [bug]
title: "[Bug] 修改我!未修改标题的issue将被自动关闭"
body:
- type: checkboxes
attributes:
label: 在提交BUG之前
options:
- label: 我尝试使用了最新版,我确定这个问题在最新版中依然存在
required: true
- type: textarea
id: desc
attributes:
label: 问题描述
description: 发生了什么情况?有什么现状?哪个视频(链接/Bv号/ep号)?问题能稳定触发吗?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 这是整个issue中**最重要**的部分,请参考[这个issue](https://github.com/lanyeeee/bilibili-video-downloader/issues/1)认真填写,否则开发者会假装看不见,甚至直接关闭issue
placeholder: |
复现步骤是影响issue处理效率的最大因素
没有详细的复现步骤将导致问题难以被定位,开发者需要花费大量时间来回沟通以定位问题
仅提供报错信息或截图而没有复现步骤,问题难以被定位,也就难以被解决
详细的复现步骤也是对项目维护工作的尊重
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期行为
description: 正常情况下应该发生什么
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
description: 实际上发生了什么
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志
description: 请提供相关的日志信息
placeholder: |
如果相关日志比较短可以直接粘贴
如果相关日志很长,建议将相关日志保存为txt,然后点击文本框下面小长条上传文件
validations:
required: false
- type: textarea
id: media
attributes:
label: 截图或录屏
description: 问题复现时候的截图或录屏
placeholder: 点击文本框下面小长条可以上传文件
- type: input
id: version
attributes:
label: 工具版本号
placeholder: v0.0.1
validations:
required: true
- type: textarea
id: other
attributes:
label: 其他
description: 其他要补充的内容
placeholder: 其他要补充的内容
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ❓ 提问与讨论
url: https://github.com/lanyeeee/bilibili-video-downloader/discussions
about: 如果有一般性问题或想发起讨论,请使用 GitHub Discussions
@@ -0,0 +1,35 @@
name: 🚀 功能请求
description: 想要请求添加某个功能
labels: [enhancement]
title: "[功能请求] 修改我!未修改标题的issue将被自动关闭"
body:
- type: markdown
attributes:
value: |
为了使我更好地帮助你,请提供以下信息。以及上方的标题
- type: textarea
id: reason
attributes:
label: 原因
description: 为什么想要这个功能
validations:
required: true
- type: textarea
id: desc
attributes:
label: 功能简述
description: 想要个怎样的功能
validations:
required: true
- type: textarea
id: logic
attributes:
label: 功能逻辑
description: 如何互交、如何使用等
validations:
required: true
- type: textarea
id: ref
attributes:
label: 实现参考
description: 该功能可能的实现方式,或者其他已经实现该功能的应用等
+17
View File
@@ -0,0 +1,17 @@
<!--
PR请基于 develop 分支开发,并提交至 develop 分支
提交前请先:
1. 运行 pnpm format 以保证代码格式正确
2. 运行 pnpm check 并确认无报错
如果想新加一个功能,请先开个 issue 或 discussion 讨论一下,避免无效工作
其他情况的PR欢迎直接提交,比如:
1.🔧 对原有功能的改进
2.🐛 修复BUG
3.⚡ 使用更轻量的库实现原有功能
4.📝 修订文档
5.⬆️ 升级、更新依赖的PR也会被接受
-->
+67
View File
@@ -0,0 +1,67 @@
name: Check Backend
on:
pull_request:
jobs:
check-backend:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Check backend changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
backend:
- 'src-tauri/**'
- 'src-plugin/**'
- '.github/workflows/check-backend.yml'
- name: Skip backend checks when unchanged
if: steps.changes.outputs.backend != 'true'
run: echo "No backend changes detected, skipping backend checks."
- name: Checkout code
if: steps.changes.outputs.backend == 'true'
uses: actions/checkout@v4
- name: Setup Rust
if: steps.changes.outputs.backend == 'true'
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Rust cache
if: steps.changes.outputs.backend == 'true'
uses: Swatinem/rust-cache@v2
with:
workspaces: |
src-tauri -> target
src-plugin -> target
src-plugin/examples -> target
- name: Install dependencies
if: steps.changes.outputs.backend == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Run Clippy
if: steps.changes.outputs.backend == 'true'
working-directory: src-tauri
run: cargo clippy -- -D warnings
- name: Run Plugin Clippy
if: steps.changes.outputs.backend == 'true'
working-directory: src-plugin
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run Plugin Examples Clippy
if: steps.changes.outputs.backend == 'true'
working-directory: src-plugin/examples
run: cargo clippy --workspace --all-targets -- -D warnings
+46
View File
@@ -0,0 +1,46 @@
name: Check Format
on:
pull_request:
jobs:
format-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install dependencies
run: pnpm install
- name: Check Prettier formatting
run: |
pnpm exec prettier --check .
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check Tauri Rust formatting
working-directory: src-tauri
run: cargo fmt --all -- --check
- name: Check Plugin Rust formatting
working-directory: src-plugin
run: cargo fmt --all -- --check
- name: Check Plugin Examples Rust formatting
working-directory: src-plugin/examples
run: cargo fmt --all -- --check
+66
View File
@@ -0,0 +1,66 @@
name: Check Frontend
on:
pull_request:
jobs:
frontend-check:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Check frontend changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
frontend:
- 'src/**'
- 'index.html'
- 'vite.config.*'
- 'eslint.config.*'
- '.prettierrc*'
- '.prettierignore'
- 'tsconfig*.json'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/check-frontend.yml'
- name: Skip frontend checks when unchanged
if: steps.changes.outputs.frontend != 'true'
run: echo "No frontend changes detected, skipping frontend checks."
- name: Checkout code
if: steps.changes.outputs.frontend == 'true'
uses: actions/checkout@v4
- name: Setup Node.js
if: steps.changes.outputs.frontend == 'true'
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Setup pnpm
if: steps.changes.outputs.frontend == 'true'
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install dependencies
if: steps.changes.outputs.frontend == 'true'
run: pnpm install
- name: Run Prettier check
if: steps.changes.outputs.frontend == 'true'
run: pnpm exec prettier . --check
- name: Run ESLint check
if: steps.changes.outputs.frontend == 'true'
run: pnpm exec eslint -c eslint.config.js --max-warnings=0 src
- name: Run Vue TSC check
if: steps.changes.outputs.frontend == 'true'
run: pnpm exec vue-tsc --noEmit
@@ -0,0 +1,44 @@
name: Close Issues with Default Title
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-issue-title:
runs-on: ubuntu-latest
steps:
- name: Check issue title
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const defaultTitle = '修改我!';
if (issue.title.includes(defaultTitle)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '检测到此issue的标题未修改\n\n为了更好地管理项目,帮助维护者快速理解问题,方便其他用户检索,请您在编写issue时务必**修改标题**\n\n此issue将被自动关闭。请修改标题后重新提交一个issue。感谢您的理解与合作!'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['invalid']
});
}
+35
View File
@@ -0,0 +1,35 @@
# Auto-generated files
src/bindings.ts
# Build outputs
dist/
dist-ssr/
# Rust/Tauri backend
src-tauri/
src-plugin/
# Dependencies
node_modules/
pnpm-lock.yaml
# IDE
.vscode/
.idea/
# files without parsers
.gitignore
.prettierignore
LICENSE
# Assets and images
*.svg
*.jpg
*.jpeg
*.png
*.gif
*.ico
*.webp
# Others
.github/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 lanyeeee (https://github.com/lanyeeee)
Copyright (c) 2025-2026 lanyeeee (https://github.com/lanyeeee)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+117 -27
View File
@@ -1,41 +1,131 @@
# 🚧 施工中...
<p align="center">
<img src="https://github.com/user-attachments/assets/a66896c7-33a6-463e-81fe-bacca3223191" style="align-self: center"/>
</p>
## 图形界面
# 📺哔哩哔哩视频下载器
哔哩哔哩 bilibili B站 视频 下载器,普通视频、充电视频、番剧、电视剧、电影、课程 全都支持下载,图形界面 + nfo刮削 + 广告标记 + 字幕下载 + 弹幕下载,轻松将视频加入emby等媒体库
## 📥 快速下载
[Release页面](https://github.com/lanyeeee/bilibili-video-downloader/releases)提供了预编译的安装包,直接下载即可使用
**如果本项目对你有帮助,欢迎点个 Star ⭐ 支持!你的支持是我持续更新维护的动力 🙏**
## 🖥️图形界面
![](https://github.com/user-attachments/assets/73d4a9d7-644b-43f2-9b25-66212e8fd9a8)
## ✨ 主要特性
## 主要功能演示
| 特性 | 说明 |
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 🖥️图形界面 | 基于 [Tauri v2](https://www.google.com/url?sa=E&q=https%3A%2F%2Fv2.tauri.app%2Fstart%2F),跨平台、现代、轻量、简洁、易用 |
| ⚡分片下载 | 最大化下载速度,轻松榨干带宽(如果你想的话),当然也支持限速 |
| 📁自定义命名 | `bv号` `标题` `分P` `发布时间` `UP昵称`等...自由组合成你喜欢的目录结构与文件命名规则 |
| 🔍视频搜索 | `视频(av/bv)` `番剧(ep/ss)` `课程(ep/ss)` `UP投稿/个人空间(uid)` `收藏夹(fid)` |
| 👤账号相关 | **登录** `Cookie登录` `二维码登录`<br />**内容** `收藏夹` `历史记录` `稍后再看` `追番追剧` |
| 🎬视频下载 | **类型** `视频(合集、分P、充电视频)` `番剧(正片、PV、相关视频)` `课程` <br />**编码** `AVC` `HEVC` `AV1`<br />**画质** `8K` `杜比视界` `HDR` `4K` `AI智能修复`等... |
| 🎵音频下载 | `无损` `杜比全景声` `192K` `132K` `64K` |
| 📝字幕下载 | 获取视频所有的CC字幕,以`srt`格式保存 |
| 🖼️封面下载 | 最高清、无压缩的原始封面图 |
| 💬弹幕下载 | 不仅能下载 `xml` `json`格式的原始弹幕,还支持将其转为样式可定制的`ass字幕` |
| 📺NFO刮削 | 还会顺便下载poster和fanart,轻松将视频加入emby等媒体库 |
| 🎞️章节标记 | 将原视频的章节信息嵌入视频文件,使视频在各类播放器中支持章节导航 |
| 🚫广告标记 | 将广告片段以章节的形式嵌入视频文件,配合兼容的播放器可自动跳过广告 |
| ⚙️任务管理 | `断点续传` `批量操作` `继续` `暂停` `重来` `删除` |
## 📖 使用方法
这个视频是主要功能的演示
https://github.com/user-attachments/assets/adf84b93-684f-43f3-9948-6ba527213812
## ✨ 主要特性
## 🔌插件系统(实验性)
| 特性 | 说明 |
| :---------- | :----------------------------------------------------------- |
| 🖥️图形界面 | 基于 [Tauri v2](https://www.google.com/url?sa=E&q=https%3A%2F%2Fv2.tauri.app%2Fstart%2F),跨平台、现代、轻量、简洁、易用 |
| ⚡分片下载 | 最大化下载速度,轻松榨干带宽(如果你想的话),当然也支持限速 |
| 📁自定义命名 | `bv号` `标题` `分P` `发布时间` `UP昵称`等...自由组合成你喜欢的目录结构与文件命名规则 |
| 🔍视频搜索 | `视频(av/bv)` `番剧(ep/ss)` `课程(ep/ss)` `UP投稿/个人空间(uid)` `收藏夹(fid)` |
| 👤账号相关 | **登录** `Cookie登录` `二维码登录`<br />**内容** `收藏夹` `历史记录` `稍后再看` `追番追剧` |
| 🎬视频下载 | **类型** `视频(合集、分P、充电视频)` `番剧(正片、PV、相关视频)` `课程` <br />**编码** `AVC` `HEVC` `AV1`<br />**画质** `8K` `杜比视界` `HDR` `4K` `AI智能修复`等... |
| 🎵音频下载 | `无损` `杜比全景声` `192K` `132K` `64K` |
| 📝字幕下载 | 获取视频所有的CC字幕,以`srt`格式保存 |
| 🖼️封面下载 | 最高清、无压缩的原始封面图 |
| 💬弹幕下载 | 不仅能下载 `xml` `json`格式的原始弹幕,还支持将其转为样式可定制的`ass字幕` |
| 📺NFO刮削 | 还会顺便下载poster和fanart,轻松将视频加入emby等媒体库 |
| 🎞️章节标记 | 将原视频的章节信息嵌入视频文件,使视频在各类播放器中支持章节导航 |
| 🚫广告标记 | 将广告片段以章节的形式嵌入视频文件,配合兼容的播放器可自动跳过广告 |
| ⚙️任务管理 | `断点续传` `批量操作` `继续` `暂停` `重来` `删除` |
- 后端提供进程内动态库插件系统,但非常不成熟
- 有特殊需求建议直接改源码,而不是开发插件
- 这个插件系统**没有做任何安全限制**,这是为了给插件最大的功能性与自由度
- 也正因如此,**任何第三方插件的安全性都无法保证**
- 强烈建议:只使用开源插件,并且自行审查代码后再编译使用
- 不要使用他人发的二进制插件(`dll` / `so` / `dylib`)
- 插件开发文档与示例请看:[src-plugin/examples](src-plugin/examples)
## 支持的配置
## ⚠️关于被杀毒软件误判为病毒
![](https://github.com/user-attachments/assets/ee41c384-6448-47a2-ae4f-bde6ebdd594f)
![](https://github.com/user-attachments/assets/519cc6e0-dfa7-4459-ab5c-5c06e86732de)
![](https://github.com/user-attachments/assets/1245b46d-75fa-4f76-86ed-cd7ce1520c8a)
![](https://github.com/user-attachments/assets/f8f4a2ce-f344-49a2-868a-d93dd4322312)
![](https://github.com/user-attachments/assets/0d3c8684-4107-4ade-b24c-5cded9419ec5)
![](https://github.com/user-attachments/assets/5b19040d-67c5-410d-9b63-a4c5504a464a)
对于个人开发的项目来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~)
我能想到的解决办法只有:
1. 根据下面的**如何构建(build)**,自行编译
2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/bilibili-video-downloader/releases)下载到的所有东西都是安全的
## 🛠️如何构建(build)
构建非常简单,一共就3条命令
~~前提是你已经安装了Rust、Node、pnpm~~
#### 📋前提
- [Rust](https://www.rust-lang.org/tools/install)
- [Node](https://nodejs.org/en)
- [pnpm](https://pnpm.io/installation)
#### 📝步骤
#### 1. 克隆本仓库
```
git clone https://github.com/lanyeeee/bilibili-video-downloader.git
```
#### 2.安装依赖
```
cd bilibili-video-downloader
pnpm install
```
#### 3.构建(build)
```
pnpm tauri build
```
## 🤝提交PR
**PR请基于`develop`分支开发,并提交至`develop`分支**
**提交前请先:**
1. 运行`pnpm format`以保证代码格式正确
2. 运行`pnpm check`并确认无报错
**如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作**
其他情况的PR欢迎直接提交,比如:
1. 🔧 对原有功能的改进
2. 🐛 修复BUG
3. ⚡ 使用更轻量的库实现原有功能
4. 📝 修订文档
5. ⬆️ 升级、更新依赖的PR也会被接受
## ⚠️免责声明
- 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险
- 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责
- 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为
## 🙏感谢
[bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect)
[ffmpeg](https://github.com/FFmpeg/FFmpeg)
[danmu2ass](https://github.com/gwy15/danmu2ass)
[BilibiliSponsorBlock](https://github.com/hanydd/BilibiliSponsorBlock)
## 💬其他
任何使用中遇到的问题、任何希望添加的功能,都欢迎提交`issue`或开`discussion`交流,我会尽力解决
-75
View File
@@ -1,75 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
-50
View File
@@ -1,50 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ColorfulTag: typeof import('./src/components/ColorfulTag.vue')['default']
FloatLabelInput: typeof import('./src/components/FloatLabelInput.vue')['default']
NA: typeof import('naive-ui')['NA']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialog: typeof import('naive-ui')['NDialog']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NEl: typeof import('naive-ui')['NEl']
NEmpty: typeof import('naive-ui')['NEmpty']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSelect: typeof import('naive-ui')['NSelect']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTime: typeof import('naive-ui')['NTime']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
SimpleCheckbox: typeof import('./src/components/SimpleCheckbox.vue')['default']
TaskToQueueAnimation: typeof import('./src/components/TaskToQueueAnimation.vue')['default']
TitleBar: typeof import('./src/components/TitleBar.vue')['default']
UpInfoBadge: typeof import('./src/components/UpInfoBadge.vue')['default']
}
}
+3
View File
@@ -20,4 +20,7 @@ export default defineConfig([
},
},
},
{
ignores: ['src/bindings.ts', 'src/vite-env.d.ts'],
},
])
+7 -4
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"format": "prettier . -w && cargo fmt --all --manifest-path src-tauri/Cargo.toml && cargo fmt --all --manifest-path src-plugin/Cargo.toml && cargo fmt --all --manifest-path src-plugin/examples/Cargo.toml",
"check": "prettier . --check && eslint -c eslint.config.js --max-warnings=0 src && vue-tsc --noEmit && cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings && cargo clippy --manifest-path src-plugin/Cargo.toml --workspace -- -D warnings && cargo clippy --manifest-path src-plugin/examples/Cargo.toml --workspace -- -D warnings"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
@@ -19,9 +21,10 @@
"lazysizes": "^5.3.2",
"naive-ui": "^2.42.0",
"pinia": "^3.0.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vue": "^3.5.13"
"virtua": "^0.48.6",
"vue": "^3.5.13",
"vue-draggable-plus": "^0.6.0",
"z-vue-scan": "^0.0.35"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
+80 -122
View File
@@ -35,15 +35,18 @@ importers:
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.6.3)(vue@3.5.17(typescript@5.6.3))
unplugin-auto-import:
specifier: ^19.3.0
version: 19.3.0
unplugin-vue-components:
specifier: ^28.8.0
version: 28.8.0(@babel/parser@7.28.0)(vue@3.5.17(typescript@5.6.3))
virtua:
specifier: ^0.48.6
version: 0.48.6(vue@3.5.17(typescript@5.6.3))
vue:
specifier: ^3.5.13
version: 3.5.17(typescript@5.6.3)
vue-draggable-plus:
specifier: ^0.6.0
version: 0.6.0(@types/sortablejs@1.15.8)
z-vue-scan:
specifier: ^0.0.35
version: 0.0.35(vue@3.5.17(typescript@5.6.3))
devDependencies:
'@eslint/js':
specifier: ^9.30.1
@@ -738,6 +741,9 @@ packages:
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/sortablejs@1.15.8':
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
'@typescript-eslint/eslint-plugin@8.36.0':
resolution: {integrity: sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1184,10 +1190,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-plugin-vue@10.3.0:
resolution: {integrity: sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1240,9 +1242,6 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -1442,9 +1441,6 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@@ -1711,9 +1707,6 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
seemly@0.3.10:
resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
@@ -1758,9 +1751,6 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-literal@3.0.0:
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
superjson@2.2.2:
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
engines: {node: '>=16'}
@@ -1819,10 +1809,6 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unimport@4.2.0:
resolution: {integrity: sha512-mYVtA0nmzrysnYnyb3ALMbByJ+Maosee2+WyE0puXl+Xm2bUwPorPaaeZt0ETfuroPOtG8jj1g/qeFZ6buFnag==}
engines: {node: '>=18.12.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -1839,39 +1825,10 @@ packages:
vite:
optional: true
unplugin-auto-import@19.3.0:
resolution: {integrity: sha512-iIi0u4Gq2uGkAOGqlPJOAMI8vocvjh1clGTfSK4SOrJKrt+tirrixo/FjgBwXQNNdS7ofcr7OxzmOb/RjWxeEQ==}
engines: {node: '>=14'}
peerDependencies:
'@nuxt/kit': ^3.2.2
'@vueuse/core': '*'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@vueuse/core':
optional: true
unplugin-utils@0.2.4:
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
engines: {node: '>=18.12.0'}
unplugin-vue-components@28.8.0:
resolution: {integrity: sha512-2Q6ZongpoQzuXDK0ZsVzMoshH0MWZQ1pzVL538G7oIDKRTVzHjppBDS8aB99SADGHN3lpGU7frraCG6yWNoL5Q==}
engines: {node: '>=14'}
peerDependencies:
'@babel/parser': ^7.15.8
'@nuxt/kit': ^3.2.2 || ^4.0.0
vue: 2 || 3
peerDependenciesMeta:
'@babel/parser':
optional: true
'@nuxt/kit':
optional: true
unplugin@2.3.5:
resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==}
engines: {node: '>=18.12.0'}
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
@@ -1889,6 +1846,26 @@ packages:
peerDependencies:
vue: ^3.0.11
virtua@0.48.6:
resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
solid-js: '>=1.0'
svelte: '>=5.0'
vue: '>=3.2'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
vite-hot-client@2.1.0:
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
peerDependencies:
@@ -1963,6 +1940,26 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-draggable-plus@0.6.0:
resolution: {integrity: sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==}
peerDependencies:
'@types/sortablejs': ^1.15.0
'@vue/composition-api': '*'
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@10.2.0:
resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1993,9 +1990,6 @@ packages:
peerDependencies:
vue: ^3.0.11
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -2020,6 +2014,15 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
z-vue-scan@0.0.35:
resolution: {integrity: sha512-isWALsDyRFhvGJrWCKbZ8ORYXmrWp+ewvoaBxBQe0WWvilzyuoTLW8IG5gL2kORLdTWmyj2j1NvehaJpKavJcw==}
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^2.0.0 || >=3.0.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
snapshots:
'@ampproject/remapping@2.3.0':
@@ -2576,6 +2579,8 @@ snapshots:
'@types/lodash@4.17.20': {}
'@types/sortablejs@1.15.8': {}
'@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.6.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.6.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@@ -3181,8 +3186,6 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-plugin-vue@10.3.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.6.3))(eslint@9.30.1(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.30.1(jiti@2.4.2))):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.30.1(jiti@2.4.2))
@@ -3265,10 +3268,6 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
evtd@0.2.4: {}
@@ -3431,8 +3430,6 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -3708,8 +3705,6 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
scule@1.3.0: {}
seemly@0.3.10: {}
semver@6.3.1: {}
@@ -3738,10 +3733,6 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-literal@3.0.0:
dependencies:
js-tokens: 9.0.1
superjson@2.2.2:
dependencies:
copy-anything: 3.0.5
@@ -3796,23 +3787,6 @@ snapshots:
unicorn-magic@0.3.0: {}
unimport@4.2.0:
dependencies:
acorn: 8.15.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 1.1.1
magic-string: 0.30.17
mlly: 1.7.4
pathe: 2.0.3
picomatch: 4.0.2
pkg-types: 2.2.0
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin-utils: 0.2.4
universalify@2.0.1: {}
unocss@66.3.3(postcss@8.5.6)(vite@6.3.5(jiti@2.4.2))(vue@3.5.17(typescript@5.6.3)):
@@ -3843,42 +3817,11 @@ snapshots:
- supports-color
- vue
unplugin-auto-import@19.3.0:
dependencies:
local-pkg: 1.1.1
magic-string: 0.30.17
picomatch: 4.0.2
unimport: 4.2.0
unplugin: 2.3.5
unplugin-utils: 0.2.4
unplugin-utils@0.2.4:
dependencies:
pathe: 2.0.3
picomatch: 4.0.2
unplugin-vue-components@28.8.0(@babel/parser@7.28.0)(vue@3.5.17(typescript@5.6.3)):
dependencies:
chokidar: 3.6.0
debug: 4.4.1
local-pkg: 1.1.1
magic-string: 0.30.17
mlly: 1.7.4
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin-utils: 0.2.4
vue: 3.5.17(typescript@5.6.3)
optionalDependencies:
'@babel/parser': 7.28.0
transitivePeerDependencies:
- supports-color
unplugin@2.3.5:
dependencies:
acorn: 8.15.0
picomatch: 4.0.2
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.1.3(browserslist@4.25.1):
dependencies:
browserslist: 4.25.1
@@ -3896,6 +3839,10 @@ snapshots:
evtd: 0.2.4
vue: 3.5.17(typescript@5.6.3)
virtua@0.48.6(vue@3.5.17(typescript@5.6.3)):
optionalDependencies:
vue: 3.5.17(typescript@5.6.3)
vite-hot-client@2.1.0(vite@6.3.5(jiti@2.4.2)):
dependencies:
vite: 6.3.5(jiti@2.4.2)
@@ -3966,6 +3913,14 @@ snapshots:
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.17(typescript@5.6.3)):
dependencies:
vue: 3.5.17(typescript@5.6.3)
vue-draggable-plus@0.6.0(@types/sortablejs@1.15.8):
dependencies:
'@types/sortablejs': 1.15.8
vue-eslint-parser@10.2.0(eslint@9.30.1(jiti@2.4.2)):
dependencies:
debug: 4.4.1
@@ -4009,8 +3964,6 @@ snapshots:
vooks: 0.2.12(vue@3.5.17(typescript@5.6.3))
vue: 3.5.17(typescript@5.6.3)
webpack-virtual-modules@0.6.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -4024,3 +3977,8 @@ snapshots:
yocto-queue@0.1.0: {}
yoctocolors@2.1.1: {}
z-vue-scan@0.0.35(vue@3.5.17(typescript@5.6.3)):
dependencies:
vue: 3.5.17(typescript@5.6.3)
vue-demi: 0.14.10(vue@3.5.17(typescript@5.6.3))
+3
View File
@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
target/
+215
View File
@@ -0,0 +1,215 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "bilibili-video-downloader-plugin-sdk"
version = "0.1.0"
dependencies = [
"bilibili-video-downloader-plugin-api",
"eyre",
"parking_lot",
"serde_json",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "indenter"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["plugin-api", "plugin-sdk"]
resolver = "3"
+1508
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
[workspace]
members = ["basic-example"]
resolver = "3"
[profile.release]
strip = true
lto = true
codegen-units = 1
+121
View File
@@ -0,0 +1,121 @@
# 插件系统(v1,实验性)
> [!WARNING]
> 插件是进程内动态库(`dll` / `so` / `dylib`),与宿主进程同权限运行
> 没有沙箱、权限隔离、签名校验,也没有网络或文件系统限制
> 插件还可以读取完整的宿主配置(包括 `sessdata`)
## 当前示例
- 当前仓库的示例插件只有一个:`basic-example`
- 位置:`src-plugin/examples/basic-example`
- 用途:演示 Descriptor、Hook 处理、异步逻辑、读取宿主配置、返回修改后的 `payload`
## 快速构建示例
```bash
cd src-plugin/examples
cargo build --release
```
构建产物位于 `src-plugin/examples/target/release/`,文件名按平台分别是:
- Windows: `basic_example.dll`
- Linux: `libbasic_example.so`
- macOS: `libbasic_example.dylib`
## 如何加载插件
- 在应用设置页的插件面板点击“添加插件”,选择动态库文件
- 后端要求路径必须是绝对路径,且文件必须存在
- 成功后会写入 `app_data_dir/plugin.json` 持久化配置
- `plugin.json` 保存字段:`path``enabled``priority``descriptor`
## Descriptor(插件内声明)
`PluginDescriptorV1` 由插件代码返回,宿主不会直接修改:
- `sdk_api_version`SDK API 版本,当前必须等于 `1`
- `id`:插件 ID
- `name`:展示名称
- `version`:插件版本
- `hooks`:声明插件希望被调用的 Hook 点列表
- `failure_policy`:失败策略,`FailOpen``FailClosed`
- `description`:插件描述
## 运行顺序与优先级
- Hook 点相同的插件,宿主按 `priority` 从大到小执行
- 每个 Hook 点按顺序串行执行,不是并行
- 前一个插件返回后的修改,会成为后一个插件看到的输入
## Hook 时机(以实际代码为准)
| HookPoint | 触发位置 |
|:---------------------|:----------------------------|
| `AfterPrepare` | `prepare()` 成功之后,开始下载前 |
| `BeforeVideoProcess` | 视频任务和音频任务结束后,视频处理任务前 |
| `OnCompleted` | 所有任务结束后,`completed_ts` 已写入后 |
三个 Hook 都可读写 `progress`,但修改只有在当前 Hook 返回后才会被宿主应用。
## 输入输出协议
- 输入:`HookInputV1 { hook_point, payload, readonly_meta }`
- 输出:`HookOutputV1 { payload }`
- `payload` 是枚举 `HookPayloadV1`,必须与 `hook_point` 匹配
- 不匹配会被判定为插件输出无效,再按失败策略处理
- `readonly_meta` 包含 `app_version``os``arch``process_id`
## 可修改范围与约束
- `payload.progress` 大多数字段都可被插件修改
- `task_id` 明确禁止修改,改动会被宿主拒绝
- 宿主没有提供修改Config的 Host API
## 失败策略
- `FailOpen`:插件出错时记录日志,继续执行后续流程
- `FailClosed`:插件出错时中断当前下载流程并返回错误
- 插件返回 `Err(...)` 时,宿主会调用 `bilibili_video_downloader_plugin_last_error_v1` 读取错误文本
## Host API(当前仅 v1
插件可在 `on_hook` 中调用:
- `host::get_config()`:读取宿主配置快照(`HostConfigV1`
注意:
- 该配置是只读快照
- 返回内容包含敏感信息(例如 `sessdata`
## SDK 入口(插件侧)
- 实现 trait`PluginV1`
- 使用宏导出:`export_plugin_v1!(YourPluginType)`
- 插件类型需要满足:`Default + Send + 'static`
常规最小结构:
```rust
use bilibili_video_downloader_plugin_sdk::{
HookInputV1, HookOutputV1, PluginDescriptorV1, PluginV1, export_plugin_v1, eyre
};
#[derive(Default)]
struct MyPlugin;
impl PluginV1 for MyPlugin {
fn descriptor(&self) -> PluginDescriptorV1 {
unimplemented!()
}
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1> {
let _ = input;
unimplemented!()
}
}
export_plugin_v1!(MyPlugin);
```
@@ -0,0 +1,13 @@
[package]
name = "basic-example"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
bilibili-video-downloader-plugin-sdk = { path = "../../plugin-sdk" }
tokio = { version = "1.49.0", features = ["full"] }
reqwest = { version = "0.13.2", default-features = false, features = ["native-tls", "system-proxy"] }
@@ -0,0 +1,95 @@
use bilibili_video_downloader_plugin_sdk::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, HookInputV1, HookOutputV1, HookPayloadV1,
HookPointV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, PluginV1,
SDK_API_VERSION, export_plugin_v1,
eyre::{self, eyre},
host,
};
#[derive(Default)]
struct BasicExamplePlugin;
impl PluginV1 for BasicExamplePlugin {
fn descriptor(&self) -> PluginDescriptorV1 {
PluginDescriptorV1 {
sdk_api_version: SDK_API_VERSION,
id: "basic-example".to_string(),
name: "Basic Example".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hooks: vec![HookPointV1::BeforeVideoProcess, HookPointV1::AfterPrepare],
failure_policy: PluginFailurePolicy::FailOpen,
description: "基础示例插件:演示推荐的代码结构、在 Hook 中执行异步任务、读取宿主配置、处理 HookPayload,并修改 DownloadProgress".to_string(),
}
}
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1> {
// 如果你需要用到异步,可以这样在同步 Hook 入口中创建 Tokio 运行时
// 然后让代码在异步运行时里执行
let output = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async { main(input).await })?;
Ok(output)
}
}
// 这并不是一个真的main函数,叫main只是为了方便理解
// 推荐将主要逻辑集中在此函数中,减少 on_hook 内部的嵌套层级
async fn main(input: HookInputV1) -> eyre::Result<HookOutputV1> {
// 示例:读取宿主当前配置。
let host_config = host::get_config()?;
println!("{}", host_config.dir_fmt);
// 示例:发起一次 HTTP 请求。
let client = reqwest::Client::new();
let body = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.text()
.await?;
println!("HTTP 请求结果:{body}");
let payload = match input.payload {
HookPayloadV1::BeforeVideoProcess(payload) => handle_before_video_process(payload),
HookPayloadV1::AfterPrepare(payload) => handle_after_prepare(payload),
HookPayloadV1::OnCompleted(payload) => handle_on_completed(payload),
}?;
// 插件需要返回 payload,宿主会根据该返回值回写并更新自身状态
// 所以插件内对 payload 的改动不会实时生效,只有当前 Hook 返回后,宿主才会应用这些修改
Ok(HookOutputV1 { payload })
}
#[allow(clippy::unnecessary_wraps)]
fn handle_before_video_process(
mut payload: BeforeVideoProcessPayloadV1,
) -> eyre::Result<HookPayloadV1> {
println!("===========================BeforeVideoProcess========================");
payload.progress.episode_title = "BeforeVideoProcess 修改了标题".to_string();
Ok(HookPayloadV1::BeforeVideoProcess(
BeforeVideoProcessPayloadV1 {
progress: payload.progress,
},
))
}
#[allow(clippy::unnecessary_wraps)]
fn handle_after_prepare(mut payload: AfterPreparePayloadV1) -> eyre::Result<HookPayloadV1> {
println!("===========================AfterPrepare========================");
payload.progress.episode_title = "AfterPrepare 修改了标题".to_string();
Ok(HookPayloadV1::AfterPrepare(AfterPreparePayloadV1 {
progress: payload.progress,
}))
}
fn handle_on_completed(mut _payload: OnCompletedPayloadV1) -> eyre::Result<HookPayloadV1> {
Err(eyre!(
"插件未声明 OnCompleted HookPoint,按预期不应进入此分支。"
))
}
// 别把这行忘了
export_plugin_v1!(BasicExamplePlugin);
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
edition = "2024"
[lib]
name = "bilibili_video_downloader_plugin_api"
[dependencies]
serde = { version = "1", features = ["derive"] }
+352
View File
@@ -0,0 +1,352 @@
pub const SDK_API_VERSION_V1: u32 = 1;
pub mod v1 {
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HookPointV1 {
AfterPrepare,
BeforeVideoProcess,
OnCompleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PluginFailurePolicy {
FailOpen,
FailClosed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginDescriptorV1 {
pub sdk_api_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub hooks: Vec<HookPointV1>,
pub failure_policy: PluginFailurePolicy,
pub description: String,
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookReadonlyMetaV1 {
pub app_version: String,
pub os: String,
pub arch: String,
pub process_id: u32,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BeforeVideoProcessPayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AfterPreparePayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OnCompletedPayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HookPayloadV1 {
BeforeVideoProcess(BeforeVideoProcessPayloadV1),
AfterPrepare(AfterPreparePayloadV1),
OnCompleted(OnCompletedPayloadV1),
}
impl Default for HookPayloadV1 {
fn default() -> Self {
Self::BeforeVideoProcess(BeforeVideoProcessPayloadV1::default())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookInputV1 {
pub hook_point: HookPointV1,
pub payload: HookPayloadV1,
pub readonly_meta: HookReadonlyMetaV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookOutputV1 {
pub payload: HookPayloadV1,
}
pub type HostApiGetConfigJsonV1 =
unsafe extern "C" fn(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32;
pub type HostApiFreeBufferV1 = unsafe extern "C" fn(ptr: *mut u8, len: usize);
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct HostApiV1 {
pub get_config_json: HostApiGetConfigJsonV1,
pub free_buffer: HostApiFreeBufferV1,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProxyModeV1 {
#[default]
NoProxy,
System,
Custom,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileExistActionV1 {
#[default]
Overwrite,
Skip,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CanvasConfigV1 {
pub duration: f64,
pub width: u32,
pub height: u32,
pub font: String,
pub font_size: u32,
pub width_ratio: f64,
pub horizontal_gap: f64,
pub lane_size: u32,
pub float_percentage: f64,
pub alpha: f64,
pub bold: bool,
pub outline: f64,
pub time_offset: f64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::struct_field_names)]
pub struct HostConfigV1 {
pub download_dir: PathBuf,
pub enable_file_logger: bool,
pub sessdata: String,
pub video_quality_priority: Vec<VideoQualityV1>,
pub codec_type_priority: Vec<CodecTypeV1>,
pub audio_quality_priority: Vec<AudioQualityV1>,
pub download_video: bool,
pub download_audio: bool,
pub auto_merge: bool,
pub embed_chapter: bool,
pub embed_skip: bool,
pub download_xml_danmaku: bool,
pub download_ass_danmaku: bool,
pub download_json_danmaku: bool,
pub download_subtitle: bool,
pub download_cover: bool,
pub download_nfo: bool,
pub download_json: bool,
pub dir_fmt: String,
pub dir_fmt_for_part: String,
pub time_fmt: String,
pub proxy_mode: ProxyModeV1,
pub proxy_host: String,
pub proxy_port: u16,
pub task_concurrency: usize,
pub task_download_interval_sec: u64,
pub chunk_concurrency: usize,
pub chunk_download_interval_sec: u64,
pub danmaku_config: CanvasConfigV1,
pub file_exist_action: FileExistActionV1,
pub auto_start_download_task: bool,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EpisodeTypeV1 {
#[default]
Normal,
Bangumi,
Cheese,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum VideoQualityV1 {
#[default]
Unknown = -1,
#[serde(rename = "240P")]
Video240P = 6,
#[serde(rename = "360P")]
Video360P = 16,
#[serde(rename = "480P")]
Video480P = 32,
#[serde(rename = "720P")]
Video720P = 64,
#[serde(rename = "720P60")]
Video720P60 = 74,
#[serde(rename = "1080P")]
Video1080P = 80,
#[serde(rename = "AiRepair")]
VideoAiRepair = 100,
#[serde(rename = "1080P+")]
Video1080PPlus = 112,
#[serde(rename = "1080P60")]
Video1080P60 = 116,
#[serde(rename = "4K")]
Video4K = 120,
#[serde(rename = "SDR")]
VideoSDR = 122,
#[serde(rename = "HDR")]
VideoHDR = 125,
#[serde(rename = "Dolby")]
VideoDolby = 126,
#[serde(rename = "8K")]
Video8K = 127,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum AudioQualityV1 {
#[default]
Unknown = -1,
#[serde(rename = "64K")]
Audio64K = 30216,
#[serde(rename = "132K")]
Audio132K = 30232,
#[serde(rename = "192K")]
Audio192K = 30280,
#[serde(rename = "Dolby")]
AudioDolby = 30250,
#[serde(rename = "HiRes")]
AudioHiRes = 30251,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum CodecTypeV1 {
#[default]
Unknown = -1,
Audio = 0,
AVC = 7,
HEVC = 12,
AV1 = 13,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaChunkV1 {
pub start: u64,
pub end: u64,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoTaskV1 {
pub selected: bool,
pub url: String,
pub video_quality: VideoQualityV1,
pub codec_type: CodecTypeV1,
pub content_length: u64,
pub chunks: Vec<MediaChunkV1>,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AudioTaskV1 {
pub selected: bool,
pub url: String,
pub audio_quality: AudioQualityV1,
pub content_length: u64,
pub chunks: Vec<MediaChunkV1>,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct VideoProcessTaskV1 {
pub merge_selected: bool,
pub embed_chapter_selected: bool,
pub embed_skip_selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SubtitleTaskV1 {
pub selected: bool,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct DanmakuTaskV1 {
pub xml_selected: bool,
pub ass_selected: bool,
pub json_selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CoverTaskV1 {
pub selected: bool,
pub url: String,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct NfoTaskV1 {
pub selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JsonTaskV1 {
pub selected: bool,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DownloadProgressV1 {
pub task_id: String,
pub episode_type: EpisodeTypeV1,
pub aid: i64,
pub bvid: Option<String>,
pub cid: i64,
pub ep_id: Option<i64>,
pub duration: u64,
pub pub_ts: i64,
pub collection_title: String,
pub part_title: Option<String>,
pub part_order: Option<i64>,
pub episode_title: String,
pub episode_title_in_collection: Option<String>,
pub episode_order: i64,
pub up_name: Option<String>,
pub up_uid: Option<i64>,
pub up_avatar: Option<String>,
pub episode_dir: PathBuf,
pub filename: String,
pub video_task: VideoTaskV1,
pub audio_task: AudioTaskV1,
pub video_process_task: VideoProcessTaskV1,
pub subtitle_task: SubtitleTaskV1,
pub danmaku_task: DanmakuTaskV1,
pub cover_task: CoverTaskV1,
pub nfo_task: NfoTaskV1,
pub json_task: JsonTaskV1,
pub create_ts: u64,
pub completed_ts: Option<u64>,
pub is_drm: bool,
pub is_preview: bool,
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "bilibili-video-downloader-plugin-sdk"
version = "0.1.0"
edition = "2024"
[lib]
name = "bilibili_video_downloader_plugin_sdk"
[dependencies]
bilibili-video-downloader-plugin-api = { path = "../plugin-api" }
eyre = { version = "0.6.12" }
serde_json = { version = "1" }
parking_lot = { version = "0.12.5" }
+191
View File
@@ -0,0 +1,191 @@
pub use bilibili_video_downloader_plugin_api::SDK_API_VERSION_V1 as SDK_API_VERSION;
pub use bilibili_video_downloader_plugin_api::v1::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, CanvasConfigV1, DownloadProgressV1,
FileExistActionV1, HookInputV1, HookOutputV1, HookPayloadV1, HookPointV1, HostApiV1,
HostConfigV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, ProxyModeV1,
};
pub use eyre;
pub use parking_lot;
pub use serde_json;
use std::sync::LazyLock;
use parking_lot::Mutex;
pub trait PluginV1: Default + Send + 'static {
fn descriptor(&self) -> PluginDescriptorV1;
#[allow(clippy::missing_errors_doc)]
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1>;
}
static HOST_API_V1: LazyLock<Mutex<Option<HostApiV1>>> = LazyLock::new(|| Mutex::new(None));
#[doc(hidden)]
pub unsafe fn register_host_api_v1(api_ptr: *const HostApiV1) -> i32 {
if api_ptr.is_null() {
return 1;
}
let api = unsafe { *api_ptr };
*HOST_API_V1.lock() = Some(api);
0
}
fn get_host_api_v1() -> eyre::Result<HostApiV1> {
HOST_API_V1
.lock()
.as_ref()
.copied()
.ok_or_else(|| eyre::eyre!("host api 未注册"))
}
pub mod host {
use crate::HostConfigV1;
#[allow(clippy::missing_errors_doc)]
pub fn get_config() -> eyre::Result<HostConfigV1> {
let host_api = crate::get_host_api_v1()?;
let mut output_ptr: *mut u8 = std::ptr::null_mut();
let mut output_len: usize = 0;
let rc = unsafe { (host_api.get_config_json)(&raw mut output_ptr, &raw mut output_len) };
if rc != 0 {
return Err(eyre::eyre!("host get_config_json 调用失败: rc={rc}"));
}
if output_ptr.is_null() {
return Err(eyre::eyre!("host get_config_json 返回的缓冲区为空指针"));
}
let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec();
unsafe {
(host_api.free_buffer)(output_ptr, output_len);
}
let host_config = serde_json::from_slice::<HostConfigV1>(&output_bytes)?;
Ok(host_config)
}
}
#[macro_export]
macro_rules! export_plugin_v1 {
($ty:ty) => {
use std::ffi::{CString, c_char};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::LazyLock;
use $crate::parking_lot::Mutex;
fn to_cstring_lossy(value: String) -> CString {
// Replacing NUL guarantees CString invariants and avoids fallible construction.
let sanitized = value.replace('\0', " ");
unsafe { CString::from_vec_unchecked(sanitized.into_bytes()) }
}
static INSTANCE_V1: LazyLock<Mutex<$ty>> = LazyLock::new(|| Mutex::new(<$ty>::default()));
static DESCRIPTOR_JSON_V1: LazyLock<CString> = LazyLock::new(|| {
let instance = INSTANCE_V1.lock();
let descriptor = instance.descriptor();
let descriptor_json = match $crate::serde_json::to_string(&descriptor) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"序列化 descriptor 失败: {err}\"}}"),
};
to_cstring_lossy(descriptor_json)
});
static LAST_ERROR_V1: LazyLock<Mutex<CString>> =
LazyLock::new(|| Mutex::new(to_cstring_lossy(String::new())));
fn set_last_error_v1(message: String) {
let mut guard = LAST_ERROR_V1.lock();
*guard = to_cstring_lossy(message);
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_descriptor_v1")]
pub extern "C" fn descriptor_v1() -> *const c_char {
DESCRIPTOR_JSON_V1.as_ptr()
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_last_error_v1")]
pub extern "C" fn last_error_v1() -> *const c_char {
LAST_ERROR_V1.lock().as_ptr()
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_set_host_api_v1")]
pub unsafe extern "C" fn set_host_api_v1(api: *const $crate::HostApiV1) -> i32 {
let rc = unsafe { $crate::register_host_api_v1(api) };
if rc != 0 {
set_last_error_v1("无效的 host api 指针".to_string());
}
rc
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_on_hook_v1")]
pub unsafe extern "C" fn on_hook_v1(
input_ptr: *const u8,
input_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
if input_ptr.is_null() || out_ptr.is_null() || out_len.is_null() {
set_last_error_v1("参数里有空指针".to_string());
return 1;
}
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let hook_input: $crate::HookInputV1 = match $crate::serde_json::from_slice(input_slice)
{
Ok(input) => input,
Err(err) => {
set_last_error_v1(format!("解析 hook 输入失败: {err}"));
return 2;
}
};
let hook_output = match catch_unwind(AssertUnwindSafe(|| {
let mut plugin = INSTANCE_V1.lock();
plugin.on_hook(hook_input)
})) {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
set_last_error_v1(format!("{err:?}"));
return 3;
}
Err(_) => {
set_last_error_v1("处理 on_hook 时插件内部发生 panic".to_string());
return 5;
}
};
let output_bytes = match $crate::serde_json::to_vec(&hook_output) {
Ok(bytes) => bytes,
Err(err) => {
set_last_error_v1(format!("序列化 hook 输出失败: {err}"));
return 4;
}
};
let boxed = output_bytes.into_boxed_slice();
let len = boxed.len();
let ptr = Box::into_raw(boxed) as *mut u8;
unsafe {
*out_ptr = ptr;
*out_len = len;
}
0
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_free_buffer_v1")]
pub unsafe extern "C" fn free_buffer_v1(ptr: *mut u8, len: usize) {
if ptr.is_null() || len == 0 {
return;
}
let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len);
unsafe {
drop(Box::from_raw(raw_slice));
}
}
};
}
@@ -0,0 +1,54 @@
use bilibili_video_downloader_plugin_sdk::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, HookInputV1, HookOutputV1, HookPayloadV1,
HookPointV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, PluginV1,
SDK_API_VERSION, export_plugin_v1, eyre, host,
};
#[derive(Default)]
struct MacroSmokePlugin;
impl PluginV1 for MacroSmokePlugin {
fn descriptor(&self) -> PluginDescriptorV1 {
PluginDescriptorV1 {
sdk_api_version: SDK_API_VERSION,
id: "macro-smoke".to_string(),
name: "Macro Smoke".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hooks: vec![HookPointV1::BeforeVideoProcess],
failure_policy: PluginFailurePolicy::FailOpen,
description: "Compile-time macro smoke test".to_string(),
}
}
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1> {
let payload = match input.payload {
HookPayloadV1::BeforeVideoProcess(payload) => {
HookPayloadV1::BeforeVideoProcess(BeforeVideoProcessPayloadV1 {
progress: payload.progress,
})
}
HookPayloadV1::AfterPrepare(payload) => {
HookPayloadV1::AfterPrepare(AfterPreparePayloadV1 {
progress: payload.progress,
})
}
HookPayloadV1::OnCompleted(payload) => {
HookPayloadV1::OnCompleted(OnCompletedPayloadV1 {
progress: payload.progress,
})
}
};
Ok(HookOutputV1 { payload })
}
}
export_plugin_v1!(MacroSmokePlugin);
#[test]
fn macro_smoke_builds() {
assert_eq!(SDK_API_VERSION, 1);
let err = host::get_config().unwrap_err();
assert!(err.to_string().contains("host api 未注册"));
}
+116 -2
View File
@@ -285,10 +285,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
name = "bilibili-video-downloader"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"bilibili-video-downloader-plugin-api",
"byteorder",
"bytes",
"chrono",
"dlopen2 0.8.2",
"eyre",
"float-ord",
"fs4",
"md-5",
@@ -297,11 +300,14 @@ dependencies = [
"num_enum",
"parking_lot 0.12.4",
"prost",
"rand 0.9.1",
"rayon",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"serde",
"serde_json",
"serde_repr",
"specta",
"specta-typescript",
"strfmt",
@@ -310,15 +316,25 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-window-state",
"tauri-specta",
"tokio",
"tracing",
"tracing-appender",
"tracing-error",
"tracing-subscriber",
"urlencoding",
"uuid",
"yaserde",
]
[[package]]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -664,6 +680,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -857,6 +892,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
dependencies = [
"dlopen2_derive",
"libc",
"once_cell",
"winapi",
]
[[package]]
name = "dlopen2_derive"
version = "0.4.1"
@@ -1004,6 +1051,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1847,6 +1904,12 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indenter"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3318,6 +3381,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
@@ -4166,7 +4249,7 @@ dependencies = [
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
"dlopen2 0.7.0",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
@@ -4422,6 +4505,21 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e"
dependencies = [
"bitflags 2.9.1",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-runtime"
version = "2.7.0"
@@ -4871,6 +4969,16 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -5026,6 +5134,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
+11 -2
View File
@@ -3,7 +3,7 @@ name = "bilibili-video-downloader"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -22,9 +22,13 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
dlopen2 = { version = "0.8.2" }
bilibili-video-downloader-plugin-api = { path = "../src-plugin/plugin-api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_repr = "0.1"
specta = { version = "=2.0.0-rc.20", features = ["serde_json"] }
tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] }
@@ -34,11 +38,12 @@ reqwest = { version = "0.12.22", default-features = false, features = ["default-
reqwest-retry = { version = "0.7.0" }
reqwest-middleware = { version = "0.4.2" }
anyhow = { version = "1.0.98" }
eyre = { version = "0.6.12" }
parking_lot = { version = "0.12.4", features = ["send_guard"] }
tracing = { version = "0.1.41" }
tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] }
tracing-appender = { version = "0.2.3" }
tracing-error = { version = "0.2.1" }
notify = { version = "8.0.0" }
tokio = { version = "1.46.0", features = ["full"] }
byteorder = { version = "1.5.0" }
@@ -53,6 +58,10 @@ yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
float-ord = { version = "0.3.2" }
memchr = { version = "2.7.5" }
md-5 = { version = "0.10.6" }
rand = { version = "0.9.1" }
base64 = { version = "0.22.1" }
rayon = { version = "1.12.0" }
urlencoding = { version = "2.1.3" }
[profile.release]
strip = true
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build();
}
File diff suppressed because it is too large Load Diff
+268 -21
View File
@@ -1,31 +1,48 @@
use anyhow::Context;
use std::{
fs::File,
io::{BufRead, BufReader},
};
use eyre::WrapErr;
use parking_lot::RwLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use tracing::instrument;
use crate::{
config::Config,
downloader::download_task::RestoredDownloadTask,
errors::{CommandError, CommandResult},
extensions::AppHandleExt,
logger,
types::{
bangumi_info::EpInBangumi,
available_media_formats::AvailableMediaFormats,
bangumi_follow_info::BangumiFollowInfo,
bangumi_info::{BangumiInfo, EpInBangumi},
create_download_task_params::CreateDownloadTaskParams,
fav_folders::FavFolders,
fav_info::FavInfo,
get_available_media_formats_params::GetAvailableMediaFormatsParams,
get_bangumi_follow_info_params::GetBangumiFollowInfoParams,
get_bangumi_info_params::GetBangumiInfoParams,
get_cheese_info_params::GetCheeseInfoParams,
get_fav_info_params::GetFavInfoParams,
get_history_info_params::GetHistoryInfoParams,
get_normal_info_params::GetNormalInfoParams,
get_user_video_info_params::GetUserVideoInfoParams,
history_info::HistoryInfo,
log_metadata::LogMetadata,
normal_info::NormalInfo,
plugin_info::PluginInfo,
qrcode_data::QrcodeData,
qrcode_status::QrcodeStatus,
restart_download_task_params::RestartDownloadTaskParams,
search_params::SearchParams,
search_result::{
BangumiSearchResult, CheeseSearchResult, FavSearchResult, NormalSearchResult,
SearchResult, UserVideoSearchResult,
},
skip_segments::SkipSegments,
user_info::UserInfo,
user_video_info::UserVideoInfo,
watch_later_info::WatchLaterInfo,
@@ -35,6 +52,8 @@ use crate::{
#[tauri::command(async)]
#[specta::specta]
#[allow(clippy::needless_pass_by_value)]
#[instrument(level = "error", skip_all)]
// TODO: 改成 app.get_config_manager().read().clone()
pub fn get_config(config: tauri::State<RwLock<Config>>) -> Config {
config.read().clone()
}
@@ -42,19 +61,21 @@ pub fn get_config(config: tauri::State<RwLock<Config>>) -> Config {
#[tauri::command(async)]
#[specta::specta]
#[allow(clippy::needless_pass_by_value)]
#[instrument(level = "error", skip_all)]
pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> {
let bili_client = app.get_bili_client();
let config_state = app.get_config();
let proxy_changed = {
let config_state = config_state.read();
config_state.proxy_mode != config.proxy_mode
|| config_state.proxy_host != config.proxy_host
|| config_state.proxy_port != config.proxy_port
};
let enable_file_logger = config.enable_file_logger;
let file_logger_changed = config_state.read().enable_file_logger != enable_file_logger;
let (proxy_changed, file_logger_changed) = {
let current_config = config_state.read();
(
current_config.proxy_mode != config.proxy_mode
|| current_config.proxy_host != config.proxy_host
|| current_config.proxy_port != config.proxy_port,
current_config.enable_file_logger != enable_file_logger,
)
};
{
// 包裹在大括号中,以便自动释放写锁
@@ -86,6 +107,7 @@ pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn generate_qrcode(app: AppHandle) -> CommandResult<QrcodeData> {
let bili_client = app.get_bili_client();
let qrcode_data = bili_client
@@ -98,6 +120,7 @@ pub async fn generate_qrcode(app: AppHandle) -> CommandResult<QrcodeData> {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_qrcode_status(app: AppHandle, qrcode_key: String) -> CommandResult<QrcodeStatus> {
let bili_client = app.get_bili_client();
let qrcode_status = bili_client
@@ -109,6 +132,7 @@ pub async fn get_qrcode_status(app: AppHandle, qrcode_key: String) -> CommandRes
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult<UserInfo> {
let bili_client = app.get_bili_client();
let user_info = bili_client
@@ -120,6 +144,22 @@ pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult<Us
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(ep_id = params.get_ep_id(), season_id = params.get_season_id()))]
pub async fn get_bangumi_info(
app: AppHandle,
params: GetBangumiInfoParams,
) -> CommandResult<BangumiInfo> {
let bili_client = app.get_bili_client();
let bangumi_info = bili_client
.get_bangumi_info(params)
.await
.map_err(|err| CommandError::from("获取番剧视频信息失败", err))?;
Ok(bangumi_info)
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(bvid = params.get_bvid(), aid = params.get_aid()))]
pub async fn get_normal_info(
app: AppHandle,
params: GetNormalInfoParams,
@@ -134,6 +174,7 @@ pub async fn get_normal_info(
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_user_video_info(
app: AppHandle,
params: GetUserVideoInfoParams,
@@ -148,6 +189,7 @@ pub async fn get_user_video_info(
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_fav_folders(app: AppHandle, uid: i64) -> CommandResult<FavFolders> {
let bili_client = app.get_bili_client();
let fav_folders = bili_client
@@ -159,6 +201,7 @@ pub async fn get_fav_folders(app: AppHandle, uid: i64) -> CommandResult<FavFolde
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_fav_info(app: AppHandle, params: GetFavInfoParams) -> CommandResult<FavInfo> {
let bili_client = app.get_bili_client();
let fav_info = bili_client
@@ -170,6 +213,7 @@ pub async fn get_fav_info(app: AppHandle, params: GetFavInfoParams) -> CommandRe
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<WatchLaterInfo> {
let bili_client = app.get_bili_client();
let watch_later_info = bili_client
@@ -179,18 +223,49 @@ pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<Wa
Ok(watch_later_info)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
pub fn create_download_tasks(app: AppHandle, params: CreateDownloadTaskParams) {
let download_manager = app.get_download_manager();
download_manager.create_download_tasks(&params);
tracing::debug!("下载任务创建成功");
#[instrument(level = "error", skip_all)]
pub async fn get_bangumi_follow_info(
app: AppHandle,
params: GetBangumiFollowInfoParams,
) -> CommandResult<BangumiFollowInfo> {
let bili_client = app.get_bili_client();
let bangumi_follow_info = bili_client
.get_bangumi_follow_info(params)
.await
.map_err(|err| CommandError::from("获取追番信息失败", err))?;
Ok(bangumi_follow_info)
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_history_info(
app: AppHandle,
params: GetHistoryInfoParams,
) -> CommandResult<HistoryInfo> {
let bili_client = app.get_bili_client();
let history_info = bili_client
.get_history_info(params)
.await
.map_err(|err| CommandError::from("获取历史记录失败", err))?;
Ok(history_info)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn create_download_tasks(app: AppHandle, params: CreateDownloadTaskParams) {
let download_manager = app.get_download_manager();
download_manager.create_download_tasks(&params);
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn pause_download_tasks(app: AppHandle, task_ids: Vec<String>) {
let download_manager = app.get_download_manager();
download_manager.pause_download_tasks(&task_ids);
@@ -199,6 +274,7 @@ pub fn pause_download_tasks(app: AppHandle, task_ids: Vec<String>) {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn resume_download_tasks(app: AppHandle, task_ids: Vec<String>) {
let download_manager = app.get_download_manager();
download_manager.resume_download_tasks(&task_ids);
@@ -207,6 +283,7 @@ pub fn resume_download_tasks(app: AppHandle, task_ids: Vec<String>) {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn delete_download_tasks(app: AppHandle, task_ids: Vec<String>) {
let download_manager = app.get_download_manager();
download_manager.delete_download_tasks(&task_ids);
@@ -215,6 +292,7 @@ pub fn delete_download_tasks(app: AppHandle, task_ids: Vec<String>) {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn restart_download_tasks(app: AppHandle, task_ids: Vec<String>) {
let download_manager = app.get_download_manager();
download_manager.restart_download_tasks(&task_ids);
@@ -223,17 +301,29 @@ pub fn restart_download_tasks(app: AppHandle, task_ids: Vec<String>) {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
#[instrument(level = "error", skip_all, fields(task_id = params.task_id))]
pub fn restart_download_task(app: AppHandle, params: RestartDownloadTaskParams) {
let download_manager = app.get_download_manager();
download_manager
download_manager.restart_download_task(&params);
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn restore_download_tasks(app: AppHandle) -> CommandResult<Vec<RestoredDownloadTask>> {
let download_manager = app.get_download_manager();
let restored_tasks = download_manager
.restore_download_tasks()
.await
.map_err(|err| CommandError::from("恢复下载任务失败", err))?;
tracing::debug!("恢复下载任务成功");
Ok(())
Ok(restored_tasks)
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn search(app: AppHandle, params: SearchParams) -> CommandResult<SearchResult> {
use SearchParams::{Bangumi, Cheese, Fav, Normal, UserVideo};
let bili_client = app.get_bili_client();
@@ -306,12 +396,13 @@ pub async fn search(app: AppHandle, params: SearchParams) -> CommandResult<Searc
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn get_logs_dir_size(app: AppHandle) -> CommandResult<u64> {
let logs_dir = logger::logs_dir(&app)
.context("获取日志目录失败")
.wrap_err("获取日志目录失败")
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?;
let logs_dir_size = std::fs::read_dir(&logs_dir)
.context(format!("读取日志目录`{}`失败", logs_dir.display()))
.wrap_err(format!("读取日志目录`{}`失败", logs_dir.display()))
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?
.filter_map(Result::ok)
.filter_map(|entry| entry.metadata().ok())
@@ -324,10 +415,166 @@ pub fn get_logs_dir_size(app: AppHandle) -> CommandResult<u64> {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> {
app.opener()
.reveal_item_in_dir(path)
.context(format!("在文件管理器中打开`{path}`失败"))
.wrap_err(format!("在文件管理器中打开`{path}`失败"))
.map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
Ok(())
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(bvid = bvid, cid = cid))]
pub async fn get_skip_segments(
app: AppHandle,
bvid: String,
cid: Option<i64>,
) -> CommandResult<SkipSegments> {
let bili_client = app.get_bili_client();
let skip_segments = bili_client
.get_skip_segments(&bvid, cid)
.await
.map_err(|err| CommandError::from("获取跳过片段失败", err))?;
Ok(skip_segments)
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_available_media_formats(
app: AppHandle,
params: GetAvailableMediaFormatsParams,
) -> CommandResult<AvailableMediaFormats> {
let bili_client = app.get_bili_client();
let result = match params {
GetAvailableMediaFormatsParams::Normal(params) => {
let media_url = bili_client
.get_normal_url(&params.bvid, params.cid)
.await
.map_err(|err| CommandError::from("获取普通视频可用格式失败", err))?;
media_url.to_get_available_media_formats_result()
}
GetAvailableMediaFormatsParams::Bangumi(params) => {
let media_url = bili_client
.get_bangumi_url(params.cid)
.await
.map_err(|err| CommandError::from("获取番剧视频可用格式失败", err))?;
media_url.to_get_available_media_formats_result()
}
GetAvailableMediaFormatsParams::Cheese(params) => {
let media_url = bili_client
.get_cheese_url(params.ep_id)
.await
.map_err(|err| CommandError::from("获取课程视频可用格式失败", err))?;
media_url.to_get_available_media_formats_result()
}
};
Ok(result)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(path = path))]
pub fn open_log_file(path: &str) -> CommandResult<Vec<LogMetadata>> {
let log_file = File::open(path).map_err(|err| CommandError::from("打开日志文件失败", err))?;
let reader = BufReader::new(log_file);
let mut logs = Vec::new();
let mut line_num = 0;
for line_result in reader.lines() {
line_num += 1;
let line = line_result
.wrap_err(format!("读取日志文件的第`{line_num}`行失败"))
.map_err(|err| CommandError::from("打开日志文件失败", err))?;
if line.trim().is_empty() {
continue;
}
let log: LogMetadata = serde_json::from_str(&line)
.wrap_err(format!("将日志文件的第`{line_num}`行解析为LogMetadata失败"))
.map_err(|err| CommandError::from("打开日志文件失败", err))?;
logs.push(log);
}
Ok(logs)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn get_plugin_infos(app: AppHandle) -> Vec<PluginInfo> {
app.get_plugin_manager().get_plugin_infos()
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn add_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.add_plugin(&plugin_path)
.map_err(|err| CommandError::from("加载插件失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn uninstall_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.uninstall_plugin(&plugin_path)
.map_err(|err| CommandError::from("卸载插件失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))]
pub fn set_plugin_enabled(app: AppHandle, plugin_path: String, enabled: bool) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.set_plugin_enabled(&plugin_path, enabled)
.map_err(|err| CommandError::from("设置插件启用状态失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, priority = priority))]
pub fn set_plugin_priority(
app: AppHandle,
plugin_path: String,
priority: i32,
) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.set_plugin_priority(&plugin_path, priority)
.map_err(|err| CommandError::from("设置插件优先级失败", err))?;
Ok(())
}
+80 -105
View File
@@ -1,11 +1,14 @@
use std::path::{Path, PathBuf};
use num_enum::{FromPrimitive, IntoPrimitive};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager};
use tracing::instrument;
use crate::danmaku_xml_to_ass::canvas::CanvasConfig;
use crate::{
danmaku_xml_to_ass::canvas::CanvasConfig,
types::{audio_quality::AudioQuality, codec_type::CodecType, video_quality::VideoQuality},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[allow(clippy::struct_excessive_bools)]
@@ -14,12 +17,14 @@ pub struct Config {
pub download_dir: PathBuf,
pub enable_file_logger: bool,
pub sessdata: String,
pub prefer_video_quality: PreferVideoQuality,
pub prefer_codec_type: PreferCodecType,
pub prefer_audio_quality: PreferAudioQuality,
pub video_quality_priority: Vec<VideoQuality>,
pub codec_type_priority: Vec<CodecType>,
pub audio_quality_priority: Vec<AudioQuality>,
pub download_video: bool,
pub download_audio: bool,
pub auto_merge: bool,
pub embed_chapter: bool,
pub embed_skip: bool,
pub download_xml_danmaku: bool,
pub download_ass_danmaku: bool,
pub download_json_danmaku: bool,
@@ -38,14 +43,17 @@ pub struct Config {
pub chunk_concurrency: usize,
pub chunk_download_interval_sec: u64,
pub danmaku_config: CanvasConfig,
pub file_exist_action: FileExistAction,
pub auto_start_download_task: bool,
}
impl Config {
pub fn new(app: &AppHandle) -> anyhow::Result<Config> {
#[instrument(level = "error", skip_all)]
pub fn new(app: &AppHandle) -> eyre::Result<Config> {
let app_data_dir = app.path().app_data_dir()?;
let config_path = app_data_dir.join("config.json");
let config = if config_path.exists() {
let mut config = if config_path.exists() {
let config_string = std::fs::read_to_string(config_path)?;
match serde_json::from_str(&config_string) {
// 如果能够直接解析为Config,则直接返回
@@ -57,11 +65,14 @@ impl Config {
} else {
Config::default(&app_data_dir)
};
config.reconcile_priority_lists();
config.save(app)?;
Ok(config)
}
pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
pub fn save(&self, app: &AppHandle) -> eyre::Result<()> {
let app_data_dir = app.path().app_data_dir()?;
let config_path = app_data_dir.join("config.json");
let config_string = serde_json::to_string_pretty(self)?;
@@ -69,6 +80,18 @@ impl Config {
Ok(())
}
fn reconcile_priority_lists(&mut self) {
reconcile(
&mut self.video_quality_priority,
DEFAULT_VIDEO_QUALITY_PRIORITY,
);
reconcile(
&mut self.audio_quality_priority,
DEFAULT_AUDIO_QUALITY_PRIORITY,
);
reconcile(&mut self.codec_type_priority, DEFAULT_CODEC_TYPE_PRIORITY);
}
fn merge_config(config_string: &str, app_data_dir: &Path) -> Config {
let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(config_string) else {
return Config::default(app_data_dir);
@@ -94,16 +117,19 @@ impl Config {
fn default(app_data_dir: &Path) -> Config {
const DEFAULT_FMT_FOR_PART: &str =
"{collection_title}/{episode_title}/{episode_title}-P{part_order} {part_title}";
Config {
download_dir: app_data_dir.join("视频下载"),
enable_file_logger: true,
sessdata: String::new(),
prefer_video_quality: PreferVideoQuality::Best,
prefer_codec_type: PreferCodecType::AVC,
prefer_audio_quality: PreferAudioQuality::Best,
video_quality_priority: DEFAULT_VIDEO_QUALITY_PRIORITY.to_vec(),
codec_type_priority: DEFAULT_CODEC_TYPE_PRIORITY.to_vec(),
audio_quality_priority: DEFAULT_AUDIO_QUALITY_PRIORITY.to_vec(),
download_video: true,
download_audio: true,
auto_merge: true,
embed_chapter: true,
embed_skip: true,
download_xml_danmaku: true,
download_ass_danmaku: true,
download_json_danmaku: true,
@@ -122,104 +148,18 @@ impl Config {
chunk_concurrency: 16,
chunk_download_interval_sec: 0,
danmaku_config: CanvasConfig::default(),
file_exist_action: FileExistAction::Overwrite,
auto_start_download_task: true,
}
}
}
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Serialize,
Deserialize,
Type,
IntoPrimitive,
FromPrimitive,
)]
#[repr(i64)]
pub enum PreferVideoQuality {
#[default]
Best = -1,
#[serde(rename = "240P")]
Video240P = 6,
#[serde(rename = "360P")]
Video360P = 16,
#[serde(rename = "480P")]
Video480P = 32,
#[serde(rename = "720P")]
Video720P = 64,
#[serde(rename = "720P60")]
Video720P60 = 74,
#[serde(rename = "1080P")]
Video1080P = 80,
#[serde(rename = "AiRepair")]
VideoAiRepair = 100,
#[serde(rename = "1080P+")]
Video1080PPlus = 112,
#[serde(rename = "1080P60")]
Video1080P60 = 116,
#[serde(rename = "4K")]
Video4K = 120,
#[serde(rename = "HDR")]
VideoHDR = 125,
#[serde(rename = "Dolby")]
VideoDolby = 126,
#[serde(rename = "8K")]
Video8K = 127,
}
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Serialize,
Deserialize,
Type,
IntoPrimitive,
FromPrimitive,
)]
#[repr(i64)]
#[allow(clippy::upper_case_acronyms)]
pub enum PreferCodecType {
#[default]
Unknown = -1,
AVC = 7,
HEVC = 12,
AV1 = 13,
}
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Serialize,
Deserialize,
Type,
IntoPrimitive,
FromPrimitive,
)]
#[repr(i64)]
pub enum PreferAudioQuality {
#[default]
Best = -1,
#[serde(rename = "64K")]
Audio64K = 30216,
#[serde(rename = "132K")]
Audio132K = 30232,
#[serde(rename = "192K")]
Audio192K = 30280,
#[serde(rename = "Dolby")]
AudioDolby = 30250,
#[serde(rename = "HiRes")]
AudioHiRes = 30251,
fn reconcile<T: Copy + Eq>(priority: &mut Vec<T>, all: &[T]) {
for &item in all {
if !priority.contains(&item) {
priority.push(item);
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)]
@@ -229,3 +169,38 @@ pub enum ProxyMode {
System,
Custom,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)]
pub enum FileExistAction {
#[default]
Overwrite,
Skip,
}
const DEFAULT_VIDEO_QUALITY_PRIORITY: &[VideoQuality] = &[
VideoQuality::Video8K,
VideoQuality::VideoDolby,
VideoQuality::VideoHDR,
VideoQuality::VideoSDR,
VideoQuality::Video4K,
VideoQuality::Video1080P60,
VideoQuality::Video1080PPlus,
VideoQuality::Video1080P,
VideoQuality::VideoAiRepair,
VideoQuality::Video720P60,
VideoQuality::Video720P,
VideoQuality::Video480P,
VideoQuality::Video360P,
VideoQuality::Video240P,
];
const DEFAULT_AUDIO_QUALITY_PRIORITY: &[AudioQuality] = &[
AudioQuality::AudioHiRes,
AudioQuality::AudioDolby,
AudioQuality::Audio192K,
AudioQuality::Audio132K,
AudioQuality::Audio64K,
];
const DEFAULT_CODEC_TYPE_PRIORITY: &[CodecType] =
&[CodecType::AVC, CodecType::HEVC, CodecType::AV1];
@@ -5,10 +5,11 @@ pub mod drawable;
use std::{cmp::Ordering, fs::File};
use anyhow::anyhow;
use ass_writer::AssWriter;
use canvas::CanvasConfig;
use danmaku::{Danmaku, DanmakuType};
use eyre::eyre;
use tracing::instrument;
use yaserde::{YaDeserialize, YaSerialize};
#[derive(YaSerialize, YaDeserialize)]
@@ -28,12 +29,13 @@ pub struct DanmakuXmlITag {
pub elems: Vec<DamakuXmlDTag>,
}
#[instrument(level = "error", skip_all)]
pub fn xml_to_ass(
xml: &str,
ass_file: File,
title: String,
config: CanvasConfig,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
let mut canvas = config.canvas();
@@ -54,24 +56,26 @@ pub fn xml_to_ass(
}
trait ToDanmakuType {
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType>;
fn to_danmaku_type(&self) -> eyre::Result<DanmakuType>;
}
impl ToDanmakuType for u32 {
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType> {
#[instrument(level = "error", skip_all)]
fn to_danmaku_type(&self) -> eyre::Result<DanmakuType> {
match self {
1 => Ok(DanmakuType::Float),
4 => Ok(DanmakuType::Bottom),
5 => Ok(DanmakuType::Top),
6 => Ok(DanmakuType::Reverse),
_ => Err(anyhow!("未知的弹幕类型:{self}")),
_ => Err(eyre!("未知的弹幕类型:{self}")),
}
}
}
pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
#[instrument(level = "error", skip_all)]
pub fn xml_to_danmakus(xml: &str) -> eyre::Result<Vec<Danmaku>> {
let xml = sanitize_xml(xml);
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| anyhow!(e))?;
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| eyre!(e))?;
let mut danmakus = Vec::new();
@@ -83,7 +87,7 @@ pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
let mut p_attr = elem.p.split(',');
let Some(timeline_s) = p_attr.next().and_then(|s| s.parse::<f64>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有时间"));
return Err(eyre!("弹幕`{content}`的p属性中没有时间"));
};
let Some(r#type) = p_attr
@@ -91,15 +95,15 @@ pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
.and_then(|s| s.parse::<u32>().ok())
.and_then(|num| num.to_danmaku_type().ok())
else {
return Err(anyhow!("弹幕`{content}`的p属性中没有弹幕类型"));
return Err(eyre!("弹幕`{content}`的p属性中没有弹幕类型"));
};
let Some(fontsize) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有字体大小"));
return Err(eyre!("弹幕`{content}`的p属性中没有字体大小"));
};
let Some(rgb) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有颜色"));
return Err(eyre!("弹幕`{content}`的p属性中没有颜色"));
};
// rgb 是个数字,类似 0x010203
@@ -1,8 +1,9 @@
use anyhow::Result;
use std::borrow::Cow;
use std::fmt;
use std::io::{BufWriter, Write};
use tracing::instrument;
use super::canvas::CanvasConfig;
use super::drawable::{DrawEffect, Drawable};
@@ -99,7 +100,8 @@ pub struct AssWriter<W: Write> {
}
impl<W: Write> AssWriter<W> {
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
#[instrument(level = "error", skip_all)]
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> eyre::Result<Self> {
let mut this = AssWriter {
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
f: BufWriter::with_capacity(10 << 20, f),
@@ -112,7 +114,8 @@ impl<W: Write> AssWriter<W> {
Ok(this)
}
pub fn init(&mut self) -> Result<()> {
#[instrument(level = "error", skip_all)]
pub fn init(&mut self) -> eyre::Result<()> {
write!(
self.f,
"\
@@ -147,7 +150,8 @@ impl<W: Write> AssWriter<W> {
Ok(())
}
pub fn write(&mut self, drawable: Drawable) -> Result<()> {
#[instrument(level = "error", skip_all)]
pub fn write(&mut self, drawable: Drawable) -> eyre::Result<()> {
writeln!(
self.f,
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
@@ -171,7 +175,7 @@ impl<W: Write> AssWriter<W> {
}
}
fn escape_text(text: &str) -> Cow<str> {
fn escape_text(text: &str) -> Cow<'_, str> {
let text = text.trim();
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
Cow::from(text.replace('\n', "\\N"))
@@ -1,7 +1,10 @@
pub mod chapter_segments;
pub mod download_chunk_task;
pub mod download_manager;
pub mod download_progress;
pub mod download_task;
pub mod download_task_state;
pub mod episode_info;
pub mod episode_type;
pub mod fmt_params;
pub mod media_chunk;
@@ -0,0 +1,122 @@
pub struct ChapterSegments {
pub segments: Vec<ChapterSegment>,
}
#[derive(Clone)]
pub struct ChapterSegment {
pub title: String,
pub start: i64,
pub end: i64,
}
impl ChapterSegments {
/// 插入一个新的章节片段
///
/// 此函数会处理新片段与现有片段的重叠情况:
/// - 对于与新片段重叠的现有片段,会将其分割为非重叠的部分
/// - 新片段会替换所有重叠区域
/// - 最终结果会按开始时间排序
///
/// # 参数
/// * `new_segment` - 要插入的新章节片段
///
/// # 示例
/// ```
/// // 假设现有片段: [0-10], [20-30]
/// // 插入新片段: [5-25]
/// // 结果: [0-5], [5-25], [25-30]
/// ```
pub fn insert(&mut self, new_segment: ChapterSegment) {
// 创建一个新的 Vec 来存储处理后的片段
// 预分配容量为当前片段数量 + 2,因为最坏情况下每个现有片段可能被分割成两部分,再加上新片段
let mut processed_segments = Vec::with_capacity(self.segments.len() + 2);
for segment in &self.segments {
if !Self::overlaps(segment, &new_segment) {
// 如果当前片段与新片段没有重叠,直接将当前片段添加到结果中
processed_segments.push(segment.clone());
continue;
}
// 如果有重叠,需要分割当前片段,只保留不与新片段重叠的部分
// 处理左侧部分:当前片段开始到新片段开始之间的部分
// left_end 是左侧部分的结束时间,取当前片段结束时间和新片段开始时间的较小值
let left_end = segment.end.min(new_segment.start);
if segment.start < left_end {
// 只有当左侧部分确实存在时(start < end)才添加
processed_segments.push(ChapterSegment {
title: segment.title.clone(),
start: segment.start,
end: left_end,
});
}
// 处理右侧部分:新片段结束到当前片段结束之间的部分
// right_start 是右侧部分的开始时间,取当前片段开始时间和新片段结束时间的较大值
let right_start = segment.start.max(new_segment.end);
if right_start < segment.end {
// 只有当右侧部分确实存在时(start < end)才添加
processed_segments.push(ChapterSegment {
title: segment.title.clone(),
start: right_start,
end: segment.end,
});
}
}
// 遍历完所有现有片段并处理完所有重叠后,将新的片段添加到结果列表中
processed_segments.push(new_segment);
processed_segments.sort_by_key(|a| a.start);
self.segments = processed_segments;
}
pub fn generate_chapter_metadata(&self, video_duration: u64) -> String {
use std::fmt::Write;
fn write_segment(content: &mut String, title: &str, start: i64, end: i64) {
let _ = writeln!(
content,
"[CHAPTER]\nTIMEBASE=1/1\nSTART={start}\nEND={end}\ntitle={title}\n"
);
}
let video_duration = i64::try_from(video_duration).unwrap_or(i64::MAX);
let mut metadata_content = ";FFMETADATA1\n".to_string();
let mut last_end = 0;
for segment in &self.segments {
// 检查当前片段的开始时间与上一个片段的结束时间之间是否有间隙
if segment.start > last_end {
// 如果有间隙,则插入一个标题为空格的空白片段
write_segment(&mut metadata_content, " ", last_end, segment.start);
}
// 写入当前片段
write_segment(
&mut metadata_content,
&segment.title,
segment.start,
segment.end,
);
// 更新上一个片段的结束时间
last_end = segment.end;
}
// 循环结束后,检查最后一个片段的结尾与视频总时长之间是否还有间隙
if video_duration > last_end {
// 如果有,则填充从 last_end 到视频结尾的剩余部分
write_segment(&mut metadata_content, " ", last_end, video_duration);
}
metadata_content
}
/// 检查两个片段是否重叠。
fn overlaps(s1: &ChapterSegment, s2: &ChapterSegment) -> bool {
s1.start < s1.end && s2.start < s2.end && s1.start < s2.end && s2.start < s1.end
}
}
@@ -0,0 +1,134 @@
use std::{
fs::File,
io::{Seek, Write},
sync::Arc,
time::Duration,
};
use parking_lot::Mutex;
use tokio::{sync::SemaphorePermit, time::sleep};
use tracing::instrument;
use crate::{
downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState},
extensions::AppHandleExt,
};
pub struct DownloadChunkTask {
pub download_task: Arc<DownloadTask>,
pub start: u64,
pub end: u64,
pub url: String,
pub file: Arc<Mutex<File>>,
pub chunk_index: usize,
}
impl DownloadChunkTask {
#[instrument(
level = "error",
skip_all,
fields(
url = self.url,
chunk_index = ?self.chunk_index,
start = self.start,
end = self.end,
)
)]
pub async fn process(self) -> eyre::Result<usize> {
let download_chunk_task = self.download_chunk();
tokio::pin!(download_chunk_task);
let mut state_receiver = self.download_task.state_sender.subscribe();
state_receiver.mark_changed();
let mut restart_receiver = self.download_task.restart_sender.subscribe();
let mut delete_receiver = self.download_task.delete_sender.subscribe();
let mut permit = None;
loop {
let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading;
tokio::select! {
result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result,
result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => {
match result {
Ok(()) => {},
Err(err) => break Err(err),
}
},
_ = state_receiver.changed() => {
if *state_receiver.borrow() == DownloadTaskState::Paused {
// 稍微等一下再释放permit
sleep(Duration::from_millis(100)).await;
if let Some(permit) = permit.take() {
drop(permit);
}
}
},
// FIXME: 直接返回chunk_index存在进度误标风险
// 上层会将这个分片标记为已下载,而分片其实是被打断的
// 应该把返回值改成 enum DownloadChunkResult { Downloaded(idx), Interrupted }
// 然后由上层处理
_ = restart_receiver.changed() => break Ok(self.chunk_index),
_ = delete_receiver.changed() => break Ok(self.chunk_index),
}
}
}
#[instrument(level = "error", skip_all)]
async fn download_chunk(&self) -> eyre::Result<usize> {
let bili_client = self.download_task.app.get_bili_client();
let chunk_data = bili_client
.get_media_chunk(&self.url, self.start, self.end)
.await?;
let len = chunk_data.len() as u64;
self.download_task
.app
.get_download_manager()
.byte_per_sec
.fetch_add(len, std::sync::atomic::Ordering::Relaxed);
// 将下载的内容写入文件
{
let mut file = self.file.lock();
file.seek(std::io::SeekFrom::Start(self.start))?;
file.write_all(&chunk_data)?;
}
let chunk_download_interval_sec = self
.download_task
.app
.get_config()
.read()
.chunk_download_interval_sec;
sleep(Duration::from_secs(chunk_download_interval_sec)).await;
Ok(self.chunk_index)
}
#[instrument(level = "error", skip_all)]
async fn acquire_chunk_permit<'a>(
&'a self,
permit: &mut Option<SemaphorePermit<'a>>,
) -> eyre::Result<()> {
*permit = match permit.take() {
// 如果有permit,则直接用
Some(permit) => Some(permit),
// 如果没有permit,则获取permit
None => Some(
self.download_task
.app
.get_download_manager()
.inner()
.media_chunk_sem
.acquire()
.await?,
),
};
Ok(())
}
}
+145 -72
View File
@@ -2,28 +2,34 @@ use std::{
collections::HashMap,
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use anyhow::Context;
use eyre::{WrapErr, eyre};
use parking_lot::RwLock;
use rayon::prelude::*;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tokio::sync::Semaphore;
use tokio::{
sync::{Semaphore, oneshot},
task::JoinSet,
};
use tracing::{Instrument, instrument};
use crate::{
downloader::download_progress::DownloadProgress,
events::DownloadEvent,
extensions::{AnyhowErrorToStringChain, AppHandleExt},
types::create_download_task_params::CreateDownloadTaskParams,
extensions::{AppHandleExt, EyreReportToMessage},
types::{
create_download_task_params::CreateDownloadTaskParams,
restart_download_task_params::RestartDownloadTaskParams,
},
};
use super::{
download_progress::DownloadProgress, download_task::DownloadTask,
download_task_state::DownloadTaskState,
};
use super::download_task::{DownloadTask, RestoredDownloadTask};
pub struct DownloadManager {
pub app: AppHandle,
@@ -56,41 +62,93 @@ impl DownloadManager {
manager
}
pub fn restore_download_tasks(&self) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
pub async fn restore_download_tasks(&self) -> eyre::Result<Vec<RestoredDownloadTask>> {
struct TaskFile {
path: PathBuf,
content: String,
}
let task_dir = self.get_task_dir()?;
std::fs::create_dir_all(&task_dir)
.context(format!("创建下载任务目录`{}`失败", task_dir.display()))?;
.wrap_err(format!("创建下载任务目录`{}`失败", task_dir.display()))?;
let mut tasks = self.download_tasks.write();
let mut join_set = JoinSet::new();
for entry in std::fs::read_dir(&task_dir)?.filter_map(Result::ok) {
let path = entry.path();
let extension = path.extension().and_then(|s| s.to_str());
if extension != Some("json") {
// 如果这个文件不是json则删除
let _ = std::fs::remove_file(&path);
continue;
}
let read_task_file = async move {
let path = entry.path();
let progress_json = std::fs::read_to_string(&path)?;
let extension = path.extension().and_then(|s| s.to_str());
if extension != Some("json") {
// 如果这个文件不是json则删除
let _ = tokio::fs::remove_file(path).await;
return None;
}
let progress: DownloadProgress =
if let Ok(progress) = serde_json::from_str(&progress_json) {
progress
} else {
// 如果这个json解析失败则删除
let _ = std::fs::remove_file(&path);
continue;
let content = match tokio::fs::read_to_string(&path)
.await
.map_err(eyre::Report::from)
{
Ok(content) => content,
Err(err) => {
let err_title = format!("读取下载任务文件`{}`失败", path.display());
let message = err.to_message();
tracing::error!(err_title, message);
return None;
}
};
let new_task = DownloadTask::from_progress(self.app.clone(), progress);
Some(TaskFile { path, content })
};
join_set.spawn(read_task_file.in_current_span());
}
let mut task_files = Vec::new();
while let Some(join_result) = join_set.join_next().await {
let Ok(Some(task_file)) = join_result else {
continue;
};
task_files.push(task_file);
}
let (progresses_sender, progresses_receiver) = oneshot::channel();
rayon::spawn(move || {
let progresses = task_files
.into_par_iter()
.filter_map(|task_file| {
if let Ok(progress) = serde_json::from_str(&task_file.content) {
Some(progress)
} else {
// 如果这个json解析失败则删除
let _ = std::fs::remove_file(&task_file.path);
None
}
})
.collect();
let _ = progresses_sender.send(progresses);
});
let progresses: Vec<DownloadProgress> = progresses_receiver.await?;
let mut tasks = self.download_tasks.write();
let mut restored_tasks = Vec::new();
for progress in progresses {
let new_task = DownloadTask::from_progress(self.app.clone(), progress.clone());
let state = *new_task.state_sender.borrow();
let old_task = tasks.insert(new_task.task_id.clone(), new_task);
if let Some(old_task) = old_task {
// 如果同一个ID的下载任务已经存在,则取消旧的任务
let _ = old_task.cancel_sender.send(());
old_task.cancel();
}
restored_tasks.push(RestoredDownloadTask { state, progress });
}
Ok(())
Ok(restored_tasks)
}
pub fn create_download_tasks(&self, params: &CreateDownloadTaskParams) {
@@ -101,84 +159,107 @@ impl DownloadManager {
}
}
#[instrument(level = "error", skip_all)]
pub fn pause_download_tasks(&self, task_ids: &Vec<String>) {
let tasks = self.download_tasks.read();
for task_id in task_ids {
let span = tracing::error_span!("pause_download_task", task_id = task_id);
let _enter = span.enter();
let Some(task) = tasks.get(task_id) else {
let err = eyre!("未找到ID对应的下载任务");
let err_title = "暂停下载任务失败";
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
tracing::error!(err_title, message = err_msg);
let message = err.to_message();
tracing::error!(err_title, message);
continue;
};
task.set_state(DownloadTaskState::Paused);
tracing::debug!("已将ID为`{task_id}`的下载任务状态设置为`Paused`");
task.pause();
}
}
#[instrument(level = "error", skip_all)]
pub fn resume_download_tasks(&self, task_ids: &Vec<String>) {
let tasks = self.download_tasks.read();
for task_id in task_ids {
let span = tracing::error_span!("resume_download_task", task_id = task_id);
let _enter = span.enter();
let Some(task) = tasks.get(task_id) else {
let err = eyre!("未找到ID对应的下载任务");
let err_title = "继续下载任务失败";
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
tracing::error!(err_title, message = err_msg);
let message = err.to_message();
tracing::error!(err_title, message);
continue;
};
task.set_state(DownloadTaskState::Pending);
tracing::debug!("已将ID为`{task_id}`的下载任务状态设置为`Pending`");
task.resume();
}
}
#[instrument(level = "error", skip_all)]
pub fn delete_download_tasks(&self, task_ids: &Vec<String>) {
let mut tasks = self.download_tasks.write();
for task_id in task_ids {
let span = tracing::error_span!("delete_download_task", task_id = task_id);
let _enter = span.enter();
let Some(task) = tasks.remove(task_id) else {
let err = eyre!("未找到ID对应的下载任务");
let err_title = "删除下载任务失败";
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
tracing::error!(err_title, message = err_msg);
let message = err.to_message();
tracing::error!(err_title, message);
continue;
};
if let Err(err) = self.delete_progress_file(task_id) {
if let Err(err) = task.delete() {
let err_title = "删除下载任务失败";
let err_msg = format!("删除ID为`{task_id}`的下载任务文件失败: {err}");
tracing::error!(err_title, message = err_msg);
let message = err.to_message();
tracing::error!(err_title, message);
tasks.insert(task_id.clone(), task);
continue;
}
if let Err(err) = task.delete_sender.send(()).map_err(anyhow::Error::from) {
let err_title = "删除下载任务失败";
let err = err.context(format!("通知ID为`{task_id}`的下载任务删除失败"));
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
tasks.insert(task_id.clone(), task);
continue;
}
tracing::debug!("已通知ID为`{task_id}`的下载任务删除");
}
}
#[instrument(level = "error", skip_all)]
pub fn restart_download_tasks(&self, task_ids: &Vec<String>) {
let tasks = self.download_tasks.read();
for task_id in task_ids {
let span = tracing::error_span!("restart_download_task", task_id = task_id);
let _enter = span.enter();
let Some(task) = tasks.get(task_id) else {
let err = eyre!("未找到ID对应的下载任务");
let err_title = "重来下载任务失败";
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
tracing::error!(err_title, message = err_msg);
let message = err.to_message();
tracing::error!(err_title, message);
continue;
};
if let Err(err) = task.restart_sender.send(()).map_err(anyhow::Error::from) {
if let Err(err) = task.restart() {
let err_title = "重来下载任务失败";
let err = err.context(format!("通知ID为`{task_id}`的下载任务重来失败"));
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
}
}
tracing::debug!("已通知ID为`{task_id}`的下载任务重来");
#[instrument(level = "error", skip_all, fields(task_id = params.task_id))]
pub fn restart_download_task(&self, params: &RestartDownloadTaskParams) {
let task_id = &params.task_id;
let tasks = self.download_tasks.read();
let Some(task) = tasks.get(task_id) else {
let err = eyre!("未找到ID对应的下载任务");
let err_title = "重来下载任务失败";
let message = err.to_message();
tracing::error!(err_title, message);
return;
};
if let Err(err) = task.restart_with_params(params) {
let err_title = "重来下载任务失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
@@ -195,18 +276,10 @@ impl DownloadManager {
}
}
fn get_task_dir(&self) -> anyhow::Result<PathBuf> {
#[instrument(level = "error", skip_all)]
fn get_task_dir(&self) -> eyre::Result<PathBuf> {
let app_data_dir = self.app.path().app_data_dir()?;
let task_dir = app_data_dir.join(".下载任务");
Ok(task_dir)
}
fn delete_progress_file(&self, task_id: &str) -> anyhow::Result<()> {
let task_dir = self.get_task_dir()?;
let task_file = task_dir.join(format!("{task_id}.json"));
if task_file.exists() {
std::fs::remove_file(task_file)?;
}
Ok(())
}
}
+285 -104
View File
@@ -1,27 +1,38 @@
use std::{
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{anyhow, Context};
use eyre::{OptionExt, WrapErr, eyre};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tracing::instrument;
use uuid::Uuid;
use crate::{
config::Config,
downloader::tasks::{
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
json_task::JsonTask, merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
video_task::VideoTask,
downloader::{
download_task::DownloadTask,
tasks::{
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
json_task::JsonTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
video_process_task::VideoProcessTask, video_task::VideoTask,
},
},
events::DownloadEvent,
extensions::AppHandleExt,
plugin::hook_context::{
AfterPrepareContext, BeforeVideoProcessContext, HookContext, OnCompletedContext,
},
types::{
audio_quality::AudioQuality,
bangumi_info::BangumiInfo,
cheese_info::CheeseInfo,
codec_type::CodecType,
get_normal_info_params::GetNormalInfoParams,
normal_info::{NormalInfo, UgcSeason},
video_quality::VideoQuality,
},
@@ -44,6 +55,7 @@ pub struct DownloadProgress {
pub part_title: Option<String>,
pub part_order: Option<i64>,
pub episode_title: String,
pub episode_title_in_collection: Option<String>,
pub episode_order: i64,
pub up_name: Option<String>,
pub up_uid: Option<i64>,
@@ -52,7 +64,7 @@ pub struct DownloadProgress {
pub filename: String,
pub video_task: VideoTask,
pub audio_task: AudioTask,
pub merge_task: MergeTask,
pub video_process_task: VideoProcessTask,
pub subtitle_task: SubtitleTask,
pub danmaku_task: DanmakuTask,
pub cover_task: CoverTask,
@@ -60,15 +72,18 @@ pub struct DownloadProgress {
pub json_task: JsonTask,
pub create_ts: u64,
pub completed_ts: Option<u64>,
pub is_drm: bool,
pub is_preview: bool,
}
impl DownloadProgress {
#[instrument(level = "error", skip_all)]
pub fn from_normal(
app: &AppHandle,
info: &NormalInfo,
aid: i64,
cid: Option<i64>,
) -> anyhow::Result<Vec<Self>> {
) -> eyre::Result<Vec<Self>> {
let config = app.get_config().read().clone();
if let Some(ugc_season) = &info.ugc_season {
@@ -79,10 +94,11 @@ impl DownloadProgress {
}
#[allow(clippy::cast_possible_wrap)]
pub fn from_bangumi(app: &AppHandle, info: &BangumiInfo, ep_id: i64) -> anyhow::Result<Self> {
#[instrument(level = "error", skip_all)]
pub fn from_bangumi(app: &AppHandle, info: &BangumiInfo, ep_id: i64) -> eyre::Result<Self> {
let (episode, episode_order) = info.get_episode_with_order(ep_id)?;
let Some(duration) = episode.duration else {
return Err(anyhow!("找不到ep_id为`{ep_id}`的番剧的时长"));
return Err(eyre!("duration为None"));
};
// 将毫秒转换为秒
let duration = duration / 1000;
@@ -90,7 +106,6 @@ impl DownloadProgress {
let config = app.get_config().read().clone();
let tasks = Tasks::new(&config, &episode.cover);
let (up_name, up_uid, up_avatar) = if let Some(up_info) = &info.up_info {
(
Some(up_info.uname.clone()),
@@ -103,7 +118,7 @@ impl DownloadProgress {
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let mut progress = Self {
let progress = Self {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Bangumi,
aid: episode.aid,
@@ -116,6 +131,7 @@ impl DownloadProgress {
part_title: None,
part_order: None,
episode_title: episode.show_title.clone().unwrap_or(episode.title.clone()),
episode_title_in_collection: None,
episode_order,
up_name,
up_uid,
@@ -124,7 +140,7 @@ impl DownloadProgress {
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -132,21 +148,20 @@ impl DownloadProgress {
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(&config)
.context("更新需要格式化的字段失败")?;
Ok(progress)
}
pub fn from_cheese(app: &AppHandle, info: &CheeseInfo, ep_id: i64) -> anyhow::Result<Self> {
#[instrument(level = "error", skip_all)]
pub fn from_cheese(app: &AppHandle, info: &CheeseInfo, ep_id: i64) -> eyre::Result<Self> {
let episode = info
.episodes
.iter()
.find(|ep| ep.id == ep_id)
.context(format!("找不到ep_id为`{ep_id}`的课程"))?;
.ok_or_eyre("找不到ep_id对应的课程")?;
let config = app.get_config().read().clone();
@@ -154,7 +169,7 @@ impl DownloadProgress {
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let mut progress = Self {
let progress = Self {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Cheese,
aid: episode.aid,
@@ -167,6 +182,7 @@ impl DownloadProgress {
part_title: None,
part_order: None,
episode_title: episode.title.clone(),
episode_title_in_collection: None,
episode_order: episode.index,
up_name: Some(info.up_info.uname.clone()),
up_uid: Some(info.up_info.mid),
@@ -175,7 +191,7 @@ impl DownloadProgress {
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -183,23 +199,167 @@ impl DownloadProgress {
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(&config)
.context("更新需要格式化的字段失败")?;
Ok(progress)
}
pub async fn prepare(&mut self, app: &AppHandle) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
#[allow(clippy::too_many_lines)]
pub async fn process(&mut self, download_task: &Arc<DownloadTask>) -> eyre::Result<()> {
let app = &download_task.app;
let _ = DownloadEvent::ProgressPreparing {
task_id: self.task_id.clone(),
}
.emit(app);
self.prepare(app).await.wrap_err("准备下载失败")?;
self.completed_ts = None; // 重置完成时间戳
download_task.update_progress(|p| *p = self.clone());
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::AfterPrepare(AfterPrepareContext::new(self)))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
}
std::fs::create_dir_all(&self.episode_dir)
.wrap_err(format!("创建目录`{}`失败", self.episode_dir.display()))?;
let mut player_info = None;
let mut episode_info = None;
if !self.video_task.is_completed() && self.video_task.content_length != 0 {
self.video_task
.process(download_task, self)
.await
.wrap_err("下载视频文件失败")?;
tracing::debug!("视频下载任务完成");
}
if !self.audio_task.is_completed() && self.audio_task.content_length != 0 {
self.audio_task
.process(download_task, self)
.await
.wrap_err("下载音频文件失败")?;
tracing::debug!("音频下载任务完成");
}
*self = download_task.progress.read().clone();
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::BeforeVideoProcess(
BeforeVideoProcessContext::new(self),
))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
}
let video_process_task_is_completed = self.video_process_task.is_completed();
if self.is_drm && !video_process_task_is_completed {
download_task.update_progress(|p| {
p.video_process_task.skipped = true;
p.video_process_task.completed = true;
});
tracing::debug!("受版权保护(DRM),无法处理,已跳过视频处理任务");
} else if !video_process_task_is_completed {
self.video_process_task
.process(download_task, self, &mut player_info)
.await
.wrap_err("视频处理失败")?;
tracing::debug!("视频处理任务完成");
}
if !self.danmaku_task.is_completed() {
self.danmaku_task
.process(download_task, self)
.await
.wrap_err("下载弹幕失败")?;
tracing::debug!("弹幕下载任务完成");
}
if !self.subtitle_task.is_completed() {
self.subtitle_task
.process(download_task, self, &mut player_info)
.await
.wrap_err("下载字幕失败")?;
tracing::debug!("字幕下载任务完成");
}
if !self.cover_task.is_completed() {
self.cover_task
.process(download_task, self)
.await
.wrap_err("下载封面失败")?;
tracing::debug!("封面下载任务完成");
}
if !self.nfo_task.is_completed() {
self.nfo_task
.process(download_task, self, &mut episode_info)
.await
.wrap_err("下载NFO失败")?;
tracing::debug!("NFO下载任务完成");
}
if !self.json_task.is_completed() {
self.json_task
.process(download_task, self, &mut episode_info)
.await
.wrap_err("下载JSON元数据失败")?;
tracing::debug!("JSON元数据下载任务完成");
}
let completed_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.ok();
if let Some(completed_ts) = completed_ts {
self.completed_ts = Some(completed_ts);
download_task.update_progress(|p| p.completed_ts = Some(completed_ts));
}
*self = download_task.progress.read().clone();
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::OnCompleted(OnCompletedContext::new(self)))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
}
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn prepare(&mut self, app: &AppHandle) -> eyre::Result<()> {
let video_selected = self.video_task.selected;
let video_completed = self.video_task.completed;
let audio_selected = self.audio_task.selected;
let audio_completed = self.audio_task.completed;
// 通过合集批量创建的任务时,合集接口只提供视频在合集内的标题
// 所以在创建任务阶段,episode_title暂时用它占位
// 在真正开始下载前,需要重新获取视频的真实标题
if self.episode_type == EpisodeType::Normal && self.episode_title_in_collection.is_some() {
let bili_client = app.get_bili_client();
let info = bili_client
.get_normal_info(GetNormalInfoParams::Aid(self.aid))
.await
.wrap_err("获取视频信息失败")?;
self.episode_title = info.title;
}
if (!video_selected && !audio_selected) || (video_completed && audio_completed) {
// 如果视频和音频都没有选中,或者都已经完成,则不需要准备
// 如果视频和音频都没有选中,或者都已经完成,则更新需要格式化的字段就返回
self.update_fmt_fields(app)
.wrap_err("更新需要格式化的字段失败")?;
return Ok(());
}
@@ -208,12 +368,15 @@ impl DownloadProgress {
match self.episode_type {
EpisodeType::Normal => {
let Some(bvid) = &self.bvid else {
return Err(anyhow!("progress中的bvid为None,无法获取视频链接"));
return Err(eyre!("progress中的bvid为None,无法获取视频链接"));
};
let media_url = bili_client
.get_normal_url(bvid, self.cid)
.await
.context("获取视频链接失败")?;
.wrap_err("获取视频链接失败")?;
self.is_preview = !media_url.durl.is_empty() && media_url.dash.video.is_empty();
if video_selected && !video_completed {
// 如果视频被选中且未完成,则准备视频任务
@@ -222,14 +385,16 @@ impl DownloadProgress {
if audio_selected && !audio_completed {
// 如果音频被选中且未完成,则准备音频任务
self.audio_task.prepare_normal(app, &media_url).await?;
self.audio_task.prepare_normal(app, &media_url).await;
}
}
EpisodeType::Bangumi => {
let media_url = bili_client
.get_bangumi_url(self.cid)
.await
.context("获取番剧视频链接失败")?;
.wrap_err("获取番剧视频链接失败")?;
self.is_preview = media_url.is_preview != 0;
if video_selected && !video_completed {
// 如果视频被选中且未完成,则准备视频任务
@@ -238,17 +403,20 @@ impl DownloadProgress {
if audio_selected && !audio_completed {
// 如果音频被选中且未完成,则准备音频任务
self.audio_task.prepare_bangumi(app, &media_url).await?;
self.audio_task.prepare_bangumi(app, &media_url).await;
}
}
EpisodeType::Cheese => {
let Some(ep_id) = self.ep_id else {
return Err(anyhow!("progress中的ep_id为None,无法获取课程视频链接"));
return Err(eyre!("progress中的ep_id为None,无法获取课程视频链接"));
};
let media_url = bili_client
.get_cheese_url(ep_id)
.await
.context("获取课程视频链接失败")?;
.wrap_err("获取课程视频链接失败")?;
self.is_drm = media_url.is_drm;
self.is_preview = media_url.is_preview != 0;
if video_selected && !video_completed {
// 如果视频被选中且未完成,则准备视频任务
@@ -257,18 +425,23 @@ impl DownloadProgress {
if audio_selected && !audio_completed {
// 如果音频被选中且未完成,则准备音频任务
self.audio_task.prepare_cheese(app, &media_url).await?;
self.audio_task.prepare_cheese(app, &media_url).await;
}
}
}
self.update_fmt_fields(app)
.wrap_err("更新需要格式化的字段失败")?;
Ok(())
}
fn update_fmt_fields(&mut self, config: &Config) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
fn update_fmt_fields(&mut self, app: &AppHandle) -> eyre::Result<()> {
let fmt_params = self.create_fmt_params();
let (episode_dir, filename) = fmt_params.get_episode_dir_and_filename(config)?;
let config = app.get_config().read().clone();
let (episode_dir, filename) = fmt_params.get_episode_dir_and_filename(&config)?;
self.episode_dir = episode_dir;
self.filename = filename;
@@ -288,24 +461,33 @@ impl DownloadProgress {
pub_ts: self.pub_ts,
collection_title: self.collection_title.clone(),
episode_title: self.episode_title.clone(),
episode_title_in_collection: self.episode_title_in_collection.clone(),
episode_order: self.episode_order,
part_title: self.part_title.clone(),
part_order: self.part_order,
up_name: self.up_name.clone(),
up_uid: self.up_uid,
create_ts: self.create_ts,
video_quality: self.video_task.video_quality,
codec_type: self.video_task.codec_type,
audio_quality: self.audio_task.audio_quality,
}
}
pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
pub fn save(&self, app: &AppHandle, allow_create: bool) -> eyre::Result<()> {
let progress = self.clone();
let file_name = format!("{}.json", progress.task_id);
let app_data_dir = app.path().app_data_dir()?;
let task_dir = app_data_dir.join(".下载任务");
std::fs::create_dir_all(&task_dir)?;
let tasks_dir = app_data_dir.join(".下载任务");
std::fs::create_dir_all(&tasks_dir)?;
let save_path = tasks_dir.join(file_name);
if !allow_create && !save_path.exists() {
return Ok(());
}
let save_path = task_dir.join(file_name);
let progress_json = serde_json::to_string(&progress)?;
std::fs::write(save_path, progress_json)?;
@@ -315,7 +497,7 @@ impl DownloadProgress {
pub fn is_completed(&self) -> bool {
self.video_task.is_completed()
&& self.audio_task.is_completed()
&& self.merge_task.is_completed()
&& self.video_process_task.is_completed()
&& self.danmaku_task.is_completed()
&& self.subtitle_task.is_completed()
&& self.cover_task.is_completed()
@@ -326,29 +508,22 @@ impl DownloadProgress {
pub fn mark_uncompleted(&mut self) {
self.video_task.mark_uncompleted();
self.audio_task.mark_uncompleted();
self.merge_task.completed = false;
self.danmaku_task.completed = false;
self.subtitle_task.completed = false;
self.cover_task.completed = false;
self.nfo_task.completed = false;
self.json_task.completed = false;
}
pub fn get_ids_string(&self) -> String {
let aid = self.aid;
let bvid = self.bvid.as_deref().unwrap_or("None");
let cid = self.cid;
let ep_id = self.ep_id.map_or("None".to_string(), |id| id.to_string());
format!("aid: {aid}, bvid: {bvid}, cid: {cid}, ep_id: {ep_id}")
self.video_process_task.mark_uncompleted();
self.danmaku_task.mark_uncompleted();
self.subtitle_task.mark_uncompleted();
self.cover_task.mark_uncompleted();
self.nfo_task.mark_uncompleted();
self.json_task.mark_uncompleted();
}
}
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
fn create_normal_progresses_for_single(
info: &NormalInfo,
cid: Option<i64>,
config: &Config,
) -> anyhow::Result<Vec<DownloadProgress>> {
) -> eyre::Result<Vec<DownloadProgress>> {
let tasks = Tasks::new(config, &info.pic);
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
@@ -356,9 +531,9 @@ fn create_normal_progresses_for_single(
if let Some(cid) = cid {
// 如果有cid,则说明是要下载单个分P
let Some(page) = info.pages.iter().find(|p| p.cid == cid) else {
return Err(anyhow!("找不到cid为`{cid}`的分P"));
return Err(eyre!("找不到cid对应的分P"));
};
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: info.aid,
@@ -371,6 +546,7 @@ fn create_normal_progresses_for_single(
part_title: Some(page.part.clone()),
part_order: Some(page.page),
episode_title: info.title.clone(),
episode_title_in_collection: None,
episode_order: 1,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -379,7 +555,7 @@ fn create_normal_progresses_for_single(
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -387,18 +563,16 @@ fn create_normal_progresses_for_single(
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
return Ok(vec![progress]);
}
if info.pages.len() == 1 {
// 如果只有一个分P,则直接创建一个progress
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: info.aid,
@@ -411,6 +585,7 @@ fn create_normal_progresses_for_single(
part_title: None,
part_order: None,
episode_title: info.title.clone(),
episode_title_in_collection: None,
episode_order: 1,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -419,7 +594,7 @@ fn create_normal_progresses_for_single(
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -427,18 +602,16 @@ fn create_normal_progresses_for_single(
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
return Ok(vec![progress]);
}
// 如果有多个分P,则为每个分P创建一个progress
let mut progresses = Vec::new();
for page in &info.pages {
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: info.aid,
@@ -451,6 +624,7 @@ fn create_normal_progresses_for_single(
part_title: Some(page.part.clone()),
part_order: Some(page.page),
episode_title: info.title.clone(),
episode_title_in_collection: None,
episode_order: 1,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -459,7 +633,7 @@ fn create_normal_progresses_for_single(
filename: String::new(),
video_task: tasks.video.clone(),
audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(),
video_process_task: tasks.video_process.clone(),
danmaku_task: tasks.danmaku.clone(),
subtitle_task: tasks.subtitle.clone(),
cover_task: tasks.cover.clone(),
@@ -467,30 +641,29 @@ fn create_normal_progresses_for_single(
json_task: tasks.json.clone(),
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
progresses.push(progress);
}
Ok(progresses)
}
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
fn create_normal_progresses_for_season(
ugc_season: &UgcSeason,
info: &NormalInfo,
aid: i64,
cid: Option<i64>,
config: &Config,
) -> anyhow::Result<Vec<DownloadProgress>> {
) -> eyre::Result<Vec<DownloadProgress>> {
let section_index = ugc_season
.sections
.iter()
.position(|s| s.episodes.iter().any(|e| e.aid == aid))
.context(format!("找不到含有aid为`{aid}`的ep的section"))?;
.ok_or_eyre("找不到含有对应aid的section")?;
let section = &ugc_season.sections[section_index];
#[allow(clippy::cast_possible_wrap)]
let (ep, episode_order) = section
@@ -499,18 +672,22 @@ fn create_normal_progresses_for_season(
.enumerate()
.map(|(i, e)| (e, i as i64 + 1))
.find(|(e, _)| e.aid == aid)
.context(format!("在section中找不到aid为`{aid}`的ep"))?;
.ok_or_eyre("在section中找不到aid对应的ep")?;
let tasks = Tasks::new(config, &ep.arc.pic);
// 合集接口返回的只有在合集内的标题,视频标题先用它占位,到prepare时再按aid获取真实标题
let episode_title = ep.title.clone();
let episode_title_in_collection = Some(ep.title.clone());
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
if let Some(cid) = cid {
// 如果有cid,则说明是要下载单个分P
let Some(page) = ep.pages.iter().find(|p| p.cid == cid) else {
return Err(anyhow!("找不到cid为`{cid}`的分P"));
return Err(eyre!("找不到cid对应的分P"));
};
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: ep.aid,
@@ -522,7 +699,8 @@ fn create_normal_progresses_for_season(
collection_title: ugc_season.title.clone(),
part_title: Some(page.part.clone()),
part_order: Some(page.page),
episode_title: ep.title.clone(),
episode_title: episode_title.clone(),
episode_title_in_collection: episode_title_in_collection.clone(),
episode_order,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -531,7 +709,7 @@ fn create_normal_progresses_for_season(
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -539,18 +717,16 @@ fn create_normal_progresses_for_season(
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
return Ok(vec![progress]);
}
if ep.pages.len() == 1 {
// 如果只有一个分P,则直接创建一个progress
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: ep.aid,
@@ -562,7 +738,8 @@ fn create_normal_progresses_for_season(
collection_title: ugc_season.title.clone(),
part_title: None,
part_order: None,
episode_title: ep.title.clone(),
episode_title: episode_title.clone(),
episode_title_in_collection: episode_title_in_collection.clone(),
episode_order,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -571,7 +748,7 @@ fn create_normal_progresses_for_season(
filename: String::new(),
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
video_process_task: tasks.video_process,
danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
cover_task: tasks.cover,
@@ -579,19 +756,17 @@ fn create_normal_progresses_for_season(
json_task: tasks.json,
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
return Ok(vec![progress]);
}
// 如果有多个分P,则为每个分P创建一个progress
let mut progresses = Vec::new();
for page in &ep.pages {
let mut progress = DownloadProgress {
let progress = DownloadProgress {
task_id: Uuid::new_v4().to_string(),
episode_type: EpisodeType::Normal,
aid: ep.aid,
@@ -603,7 +778,8 @@ fn create_normal_progresses_for_season(
collection_title: ugc_season.title.clone(),
part_title: Some(page.part.clone()),
part_order: Some(page.page),
episode_title: ep.title.clone(),
episode_title: episode_title.clone(),
episode_title_in_collection: episode_title_in_collection.clone(),
episode_order,
up_name: Some(info.owner.name.clone()),
up_uid: Some(info.owner.mid),
@@ -612,7 +788,7 @@ fn create_normal_progresses_for_season(
filename: String::new(),
video_task: tasks.video.clone(),
audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(),
video_process_task: tasks.video_process.clone(),
danmaku_task: tasks.danmaku.clone(),
subtitle_task: tasks.subtitle.clone(),
cover_task: tasks.cover.clone(),
@@ -620,12 +796,10 @@ fn create_normal_progresses_for_season(
json_task: tasks.json.clone(),
create_ts,
completed_ts: None,
is_drm: false,
is_preview: false,
};
progress
.update_fmt_fields(config)
.context("更新需要格式化的字段失败")?;
progresses.push(progress);
}
Ok(progresses)
@@ -634,7 +808,7 @@ fn create_normal_progresses_for_season(
struct Tasks {
video: VideoTask,
audio: AudioTask,
merge: MergeTask,
video_process: VideoProcessTask,
danmaku: DanmakuTask,
subtitle: SubtitleTask,
cover: CoverTask,
@@ -652,6 +826,7 @@ impl Tasks {
content_length: 0,
chunks: Vec::new(),
completed: false,
skipped: false,
};
let audio = AudioTask {
@@ -661,11 +836,15 @@ impl Tasks {
content_length: 0,
chunks: Vec::new(),
completed: false,
skipped: false,
};
let merge = MergeTask {
selected: config.auto_merge,
let video_process = VideoProcessTask {
merge_selected: config.auto_merge,
embed_chapter_selected: config.embed_chapter,
embed_skip_selected: config.embed_skip,
completed: false,
skipped: false,
};
let danmaku = DanmakuTask {
@@ -673,6 +852,7 @@ impl Tasks {
ass_selected: config.download_ass_danmaku,
json_selected: config.download_json_danmaku,
completed: false,
skipped: false,
};
let subtitle = SubtitleTask {
@@ -689,6 +869,7 @@ impl Tasks {
let nfo = NfoTask {
selected: config.download_nfo,
completed: false,
skipped: false,
};
let json = JsonTask {
@@ -699,7 +880,7 @@ impl Tasks {
Self {
video,
audio,
merge,
video_process,
danmaku,
subtitle,
cover,
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
use eyre::{OptionExt, WrapErr};
use tauri::AppHandle;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, episode_type::EpisodeType},
extensions::AppHandleExt,
types::{
bangumi_info::BangumiInfo, cheese_info::CheeseInfo,
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
},
};
#[derive(Clone)]
pub enum EpisodeInfo {
Normal(NormalInfo),
Bangumi(BangumiInfo, i64),
Cheese(CheeseInfo, i64),
}
pub trait GetOrInitEpisodeInfo {
async fn get_or_init<'a>(
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> eyre::Result<&'a mut EpisodeInfo>;
}
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
#[instrument(level = "error", skip_all)]
async fn get_or_init<'a>(
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> eyre::Result<&'a mut EpisodeInfo> {
if let Some(info) = self {
return Ok(info);
}
let bili_client = app.get_bili_client();
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
let new_info = match episode_type {
EpisodeType::Normal => {
let info = bili_client
.get_normal_info(GetNormalInfoParams::Aid(aid))
.await
.wrap_err("获取普通视频信息失败")?;
EpisodeInfo::Normal(info)
}
EpisodeType::Bangumi => {
let ep_id = ep_id.ok_or_eyre("ep_id为None")?;
let info = bili_client
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
.await
.wrap_err("获取番剧信息失败")?;
EpisodeInfo::Bangumi(info, ep_id)
}
EpisodeType::Cheese => {
let ep_id = ep_id.ok_or_eyre("ep_id为None")?;
let info = bili_client
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
.await
.wrap_err("获取课程信息失败")?;
EpisodeInfo::Cheese(info, ep_id)
}
};
Ok(self.insert(new_info))
}
}
+25 -18
View File
@@ -1,10 +1,15 @@
use std::{collections::HashMap, path::PathBuf};
use anyhow::Context;
use eyre::{OptionExt, WrapErr};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::instrument;
use crate::{config::Config, utils::filename_filter};
use crate::{
config::Config,
types::{audio_quality::AudioQuality, codec_type::CodecType, video_quality::VideoQuality},
utils::filename_filter,
};
use super::episode_type::EpisodeType;
@@ -20,27 +25,29 @@ pub struct FmtParams {
pub pub_ts: i64,
pub collection_title: String,
pub episode_title: String,
pub episode_title_in_collection: Option<String>,
pub episode_order: i64,
pub part_title: Option<String>,
pub part_order: Option<i64>,
pub up_name: Option<String>,
pub up_uid: Option<i64>,
pub create_ts: u64,
pub video_quality: VideoQuality,
pub codec_type: CodecType,
pub audio_quality: AudioQuality,
}
impl FmtParams {
pub fn get_episode_dir_and_filename(
&self,
config: &Config,
) -> anyhow::Result<(PathBuf, String)> {
#[instrument(level = "error", skip_all)]
pub fn get_episode_dir_and_filename(&self, config: &Config) -> eyre::Result<(PathBuf, String)> {
use strfmt::strfmt;
let mut json_value =
serde_json::to_value(self).context("将FmtParams转为serde_json::Value失败")?;
serde_json::to_value(self).wrap_err("将FmtParams转为serde_json::Value失败")?;
let json_map = json_value
.as_object_mut()
.context("FmtParams不是JSON对象")?;
.ok_or_eyre("FmtParams不是JSON对象")?;
// 格式化时间字段
format_time_fields(json_map, &config.time_fmt);
@@ -67,7 +74,7 @@ impl FmtParams {
let dir_fmt_parts: Vec<&str> = dir_fmt.split('/').collect();
let mut dir_names = Vec::new();
for fmt in dir_fmt_parts {
let dir_name = strfmt(fmt, &vars).context("格式化目录名失败")?;
let dir_name = strfmt(fmt, &vars).wrap_err("格式化目录名失败")?;
let dir_name = filename_filter(&dir_name);
if !dir_name.is_empty() {
dir_names.push(dir_name);
@@ -75,7 +82,7 @@ impl FmtParams {
}
// 最后一部分是文件名
let filename = dir_names.pop().context("没有找到文件名部分")?;
let filename = dir_names.pop().ok_or_eyre("没有找到文件名部分")?;
// 剩下的部分是目录名
let mut episode_dir = config.download_dir.clone();
for dir_name in dir_names {
@@ -88,16 +95,16 @@ impl FmtParams {
#[allow(clippy::cast_possible_wrap)]
fn format_time_fields(json_map: &mut Map<String, Value>, time_fmt: &str) {
if let Some(ts) = json_map.get("pub_ts").and_then(Value::as_i64) {
if let Some(ts_string) = ts_to_string(ts, time_fmt) {
json_map.insert("pub_ts".to_string(), Value::String(ts_string));
}
if let Some(ts) = json_map.get("pub_ts").and_then(Value::as_i64)
&& let Some(ts_string) = ts_to_string(ts, time_fmt)
{
json_map.insert("pub_ts".to_string(), Value::String(ts_string));
}
if let Some(ts) = json_map.get("create_ts").and_then(Value::as_u64) {
if let Some(ts_string) = ts_to_string(ts as i64, time_fmt) {
json_map.insert("create_ts".to_string(), Value::String(ts_string));
}
if let Some(ts) = json_map.get("create_ts").and_then(Value::as_u64)
&& let Some(ts_string) = ts_to_string(ts as i64, time_fmt)
{
json_map.insert("create_ts".to_string(), Value::String(ts_string));
}
}
+238 -99
View File
@@ -1,23 +1,36 @@
use std::cmp::Reverse;
use std::{
collections::HashMap,
fs::{File, OpenOptions},
sync::Arc,
};
use anyhow::anyhow;
use eyre::{WrapErr, eyre};
use fs4::fs_std::FileExt;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::AppHandle;
use tokio::task::JoinSet;
use tracing::{Instrument, instrument};
use crate::{
downloader::media_chunk::MediaChunk,
extensions::AppHandleExt,
config::FileExistAction,
downloader::{
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
download_task::DownloadTask, media_chunk::MediaChunk,
},
extensions::{AppHandleExt, EyreReportToMessage},
types::{
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
},
utils,
};
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct AudioTask {
pub selected: bool,
pub url: String,
@@ -25,14 +38,11 @@ pub struct AudioTask {
pub content_length: u64,
pub chunks: Vec<MediaChunk>,
pub completed: bool,
pub skipped: bool,
}
impl AudioTask {
pub async fn prepare_normal(
&mut self,
app: &AppHandle,
media_url: &NormalMediaUrl,
) -> anyhow::Result<()> {
pub async fn prepare_normal(&mut self, app: &AppHandle, media_url: &NormalMediaUrl) {
let mut join_set = JoinSet::new();
if let Some(medias) = &media_url.dash.audio {
@@ -44,7 +54,7 @@ impl AudioTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -52,7 +62,9 @@ impl AudioTask {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -65,7 +77,7 @@ impl AudioTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -73,7 +85,9 @@ impl AudioTask {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -86,54 +100,50 @@ impl AudioTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
MediaForPrepare {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
let mut medias: Vec<MediaForPrepare> = Vec::new();
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取音频地址失败"));
}
self.prepare(app, medias);
Ok(())
self.prepare(app, &medias);
}
pub async fn prepare_bangumi(
&mut self,
app: &AppHandle,
media_url: &BangumiMediaUrl,
) -> anyhow::Result<()> {
pub async fn prepare_bangumi(&mut self, app: &AppHandle, media_url: &BangumiMediaUrl) {
let Some(dash) = &media_url.dash else {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
};
let Some(medias) = &dash.audio else {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
};
if medias.is_empty() {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
}
let mut join_set = JoinSet::new();
@@ -146,54 +156,50 @@ impl AudioTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
MediaForPrepare {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
let mut medias: Vec<MediaForPrepare> = Vec::new();
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取音频地址失败"));
}
self.prepare(app, medias);
Ok(())
self.prepare(app, &medias);
}
pub async fn prepare_cheese(
&mut self,
app: &AppHandle,
media_url: &CheeseMediaUrl,
) -> anyhow::Result<()> {
pub async fn prepare_cheese(&mut self, app: &AppHandle, media_url: &CheeseMediaUrl) {
let Some(dash) = &media_url.dash else {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
};
let Some(medias) = &dash.audio else {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
};
if medias.is_empty() {
// 如果没有音频,则直接返回
self.completed = true;
return Ok(());
return;
}
let mut join_set = JoinSet::new();
@@ -206,55 +212,52 @@ impl AudioTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
MediaForPrepare {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
let mut medias: Vec<MediaForPrepare> = Vec::new();
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取音频地址失败"));
}
self.prepare(app, medias);
Ok(())
self.prepare(app, &medias);
}
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) {
medias.sort_by_key(|m| Reverse(m.id.to_audio_quality_for_prepare()));
let best_quality_id = medias[0].id;
fn prepare(&mut self, app: &AppHandle, medias: &[MediaForPrepare]) {
if medias.is_empty() {
self.completed = true;
return;
}
let prefer_quality = app.get_config().read().prefer_audio_quality;
let prefer_quality_id: i64 = prefer_quality.into();
let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id);
let quality_filtered_medias: Vec<MediaForPrepare> = if prefer_quality_found {
// 如果用户指定质量存在,则使用用户指定的质量
medias
.into_iter()
.filter(|m| m.id == prefer_quality_id)
.collect()
// 如果`audio_quality`为`Unknown`,则更倾向于使用优先级选择
let prefer_select_by_priority = self.audio_quality == AudioQuality::Unknown;
let selected_media = if prefer_select_by_priority {
select_media_by_priority(app, medias)
} else {
// 否则使用最高质量
medias
.into_iter()
.filter(|m| m.id == best_quality_id)
.collect()
select_exact_match_media(self, medias).or_else(|| select_media_by_priority(app, medias))
};
let media = &quality_filtered_medias[0];
let Some(media) = selected_media else {
self.completed = true;
return;
};
self.audio_quality = media.id.into();
@@ -292,11 +295,142 @@ impl AudioTask {
self.chunks.iter_mut().for_each(|chunk| {
chunk.completed = false;
});
self.skipped = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let audio_task = progress.audio_task.clone();
let m4a_path = episode_dir.join(format!("{filename}.m4a"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip && m4a_path.exists() {
tracing::debug!("音频文件已存在,跳过下载");
download_task.update_progress(|p| {
p.audio_task.skipped = true;
p.audio_task.completed = true;
});
return Ok(());
}
let temp_file_path = episode_dir.join(format!(
"{filename}.m4a.com.lanyeeee.bilibili-video-downloader"
));
let should_reuse_temp_file = temp_file_path
.metadata()
.map(|m| m.len() == audio_task.content_length)
.unwrap_or(false);
let file = if should_reuse_temp_file {
// 如果临时文件可以重用,则直接打开它
OpenOptions::new()
.read(true)
.write(true)
.open(&temp_file_path)?
} else {
// 如果临时文件不能重用,则创建个新的
let file = File::create(&temp_file_path)?;
file.allocate(audio_task.content_length)?;
file
};
let file = Arc::new(Mutex::new(file));
let chunk_count = audio_task.chunks.len();
let mut join_set = JoinSet::new();
for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() {
if chunk.completed {
continue;
}
let (start, end) = (chunk.start, chunk.end);
let download_chunk_task = DownloadChunkTask {
download_task: download_task.clone(),
start,
end,
url: audio_task.url.clone(),
file: file.clone(),
chunk_index,
};
let chunk_order = chunk_index + 1;
let chunk_task = async move {
download_chunk_task.process().await.wrap_err(format!(
"分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})"
))
};
join_set.spawn(chunk_task.in_current_span());
}
while let Some(join_result) = join_set.join_next().await {
let Ok(download_audio_result) = join_result else {
continue;
};
match download_audio_result {
Ok(i) => download_task.update_progress(|p| p.audio_task.chunks[i].completed = true),
Err(err) => {
let err_title = "音频的一个分片下载失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
let download_completed = download_task
.progress
.read()
.audio_task
.chunks
.iter()
.all(|chunk| chunk.completed);
if !download_completed {
return Err(eyre!(
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
temp_file_path.display()
));
}
let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).wrap_err(format!(
"检查音频文件`{}`是否完整失败",
temp_file_path.display()
))?;
if !is_audio_file_complete {
download_task.update_progress(|p| p.audio_task.mark_uncompleted());
return Err(eyre!(
"音频文件`{}`不完整,[继续]会重新下载所有分片",
temp_file_path.display()
));
}
// 重命名临时文件
if m4a_path.exists() {
std::fs::remove_file(&m4a_path)
.wrap_err(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?;
}
std::fs::rename(&temp_file_path, &m4a_path).wrap_err(format!(
"将临时文件`{}`重命名为`{}`失败",
temp_file_path.display(),
m4a_path.display()
))?;
download_task.update_progress(|p| p.audio_task.completed = true);
Ok(())
}
}
#[derive(Debug, Clone)]
@@ -305,29 +439,34 @@ struct MediaForPrepare {
pub url_with_content_length: Vec<(String, u64)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum AudioQualityForPrepare {
Audio64K,
Audio132K,
Audio192K,
AudioDolby,
AudioHiRes,
fn select_exact_match_media(
audio_task: &AudioTask,
medias: &[MediaForPrepare],
) -> Option<MediaForPrepare> {
let media = medias.iter().find(|m| {
let quality: AudioQuality = m.id.into();
quality == audio_task.audio_quality
});
media.cloned()
}
trait ToAudioQualityForPrepare {
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare>;
}
fn select_media_by_priority(
app: &AppHandle,
medias: &[MediaForPrepare],
) -> Option<MediaForPrepare> {
let quality_priority = app.get_config().read().audio_quality_priority.clone();
impl ToAudioQualityForPrepare for i64 {
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare> {
let audio_quality: AudioQuality = self.into();
match audio_quality {
AudioQuality::Audio64K => Some(AudioQualityForPrepare::Audio64K),
AudioQuality::Audio132K => Some(AudioQualityForPrepare::Audio132K),
AudioQuality::Audio192K => Some(AudioQualityForPrepare::Audio192K),
AudioQuality::AudioDolby => Some(AudioQualityForPrepare::AudioDolby),
AudioQuality::AudioHiRes => Some(AudioQualityForPrepare::AudioHiRes),
AudioQuality::Unknown => None,
}
}
let priority_map: HashMap<&AudioQuality, usize> = quality_priority
.iter()
.enumerate()
.map(|(index, quality)| (quality, index))
.collect();
let media = medias.iter().min_by_key(|media| {
let quality: AudioQuality = media.id.into();
priority_map.get(&quality).unwrap_or(&usize::MAX)
});
media.cloned()
}
@@ -1,7 +1,17 @@
use std::sync::Arc;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
extensions::AppHandleExt,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CoverTask {
pub selected: bool,
pub url: String,
@@ -9,7 +19,34 @@ pub struct CoverTask {
}
impl CoverTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let bili_client = download_task.app.get_bili_client();
let (cover_data, ext) = bili_client
.get_cover_data_and_ext(&progress.cover_task.url)
.await
.wrap_err("获取封面失败")?;
let save_path = episode_dir.join(format!("{filename}.{ext}"));
std::fs::write(&save_path, cover_data)
.wrap_err(format!("保存封面到`{}`失败", save_path.display()))?;
download_task.update_progress(|p| p.cover_task.completed = true);
Ok(())
}
}
@@ -1,17 +1,99 @@
use std::{fs::File, sync::Arc};
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::{
config::FileExistAction,
danmaku_xml_to_ass::xml_to_ass,
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
extensions::AppHandleExt,
utils::ToXml,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct DanmakuTask {
pub xml_selected: bool,
pub ass_selected: bool,
pub json_selected: bool,
pub completed: bool,
pub skipped: bool,
}
impl DanmakuTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
self.skipped = false;
}
pub fn is_completed(&self) -> bool {
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> eyre::Result<()> {
let danmaku_task = &progress.danmaku_task;
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
let ass_path = episode_dir.join(format!("{filename}.弹幕.ass"));
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip {
let skip_xml = !danmaku_task.xml_selected || xml_path.exists();
let skip_ass = !danmaku_task.ass_selected || ass_path.exists();
let skip_json = !danmaku_task.json_selected || json_path.exists();
if skip_xml && skip_ass && skip_json {
tracing::debug!("弹幕文件已存在,跳过下载");
download_task.update_progress(|p| {
p.danmaku_task.skipped = true;
p.danmaku_task.completed = true;
});
return Ok(());
}
}
let bili_client = download_task.app.get_bili_client();
let replies = bili_client
.get_danmaku(progress.aid, progress.cid, progress.duration)
.await
.wrap_err("获取弹幕失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[下载弹幕]相关的所有选项")?;
let xml = replies
.to_xml(progress.cid)
.wrap_err("将弹幕转换为XML失败")?;
if danmaku_task.xml_selected {
std::fs::write(&xml_path, &xml)
.wrap_err(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
}
if danmaku_task.ass_selected {
let config = download_task.app.get_config().read().danmaku_config.clone();
let ass_file = File::create(&ass_path)
.wrap_err(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
let title = filename.clone();
xml_to_ass(&xml, ass_file, title, config).wrap_err("将弹幕XML转换为ASS失败")?;
}
if danmaku_task.json_selected {
let json_string = serde_json::to_string(&replies).wrap_err("将弹幕转换为JSON失败")?;
std::fs::write(&json_path, json_string)
.wrap_err(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
}
download_task.update_progress(|p| p.danmaku_task.completed = true);
Ok(())
}
}
@@ -1,14 +1,62 @@
use std::sync::Arc;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::downloader::{
download_progress::DownloadProgress,
download_task::DownloadTask,
episode_info::{EpisodeInfo, GetOrInitEpisodeInfo},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct JsonTask {
pub selected: bool,
pub completed: bool,
}
impl JsonTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
episode_info: &mut Option<EpisodeInfo>,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let episode_info = episode_info
.get_or_init(&download_task.app, progress)
.await?;
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
let json_string = match episode_info {
EpisodeInfo::Normal(info) => {
serde_json::to_string(&info).wrap_err("将普通视频信息转换为JSON失败")?
}
EpisodeInfo::Bangumi(info, _ep_id) => {
serde_json::to_string(&info).wrap_err("将番剧信息转换为JSON失败")?
}
EpisodeInfo::Cheese(info, _ep_id) => {
serde_json::to_string(&info).wrap_err("将课程信息转换为JSON失败")?
}
};
std::fs::write(&json_path, json_string)
.wrap_err(format!("保存JSON到`{}`失败", json_path.display()))?;
download_task.update_progress(|p| p.json_task.completed = true);
Ok(())
}
}
@@ -1,14 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct MergeTask {
pub selected: bool,
pub completed: bool,
}
impl MergeTask {
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ pub mod audio_task;
pub mod cover_task;
pub mod danmaku_task;
pub mod json_task;
pub mod merge_task;
pub mod nfo_task;
pub mod subtitle_task;
pub mod video_process_task;
pub mod video_task;
+241 -23
View File
@@ -1,23 +1,236 @@
use anyhow::{anyhow, Context};
use std::sync::Arc;
use chrono::{DateTime, Datelike, NaiveDateTime};
use eyre::{OptionExt, WrapErr, eyre};
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use yaserde::{YaDeserialize, YaSerialize};
use crate::types::{
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
use crate::{
config::FileExistAction,
downloader::{
download_progress::DownloadProgress,
download_task::DownloadTask,
episode_info::{EpisodeInfo, GetOrInitEpisodeInfo},
},
extensions::AppHandleExt,
types::{
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct NfoTask {
pub selected: bool,
pub completed: bool,
pub skipped: bool,
}
impl NfoTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
self.skipped = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
episode_info: &mut Option<EpisodeInfo>,
) -> eyre::Result<()> {
let episode_info = episode_info
.get_or_init(&download_task.app, progress)
.await?;
match episode_info {
EpisodeInfo::Normal(info) => {
self.process_normal(download_task, progress, info).await?;
}
EpisodeInfo::Bangumi(info, ep_id) => {
self.process_bangumi(download_task, progress, info, ep_id)
.await?;
}
EpisodeInfo::Cheese(info, ep_id) => {
self.process_cheese(download_task, progress, info, ep_id)
.await?;
}
}
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn process_normal(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
info: &NormalInfo,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let nfo_path = episode_dir.join(format!("{filename}.nfo"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip && nfo_path.exists() {
tracing::debug!("NFO文件已存在,跳过下载");
download_task.update_progress(|p| {
p.nfo_task.skipped = true;
p.nfo_task.completed = true;
});
return Ok(());
}
let bili_client = download_task.app.get_bili_client();
let tags = bili_client
.get_tags(progress.aid)
.await
.wrap_err("获取视频标签失败")?;
let movie_nfo = info
.to_movie_nfo(tags)
.wrap_err("将普通视频信息转换为movie NFO失败")?;
std::fs::write(&nfo_path, movie_nfo)
.wrap_err(format!("保存普通视频NFO到`{}`失败", nfo_path.display()))?;
if let Some(ugc_season) = &info.ugc_season {
let collection_cover = &ugc_season.cover;
let (cover_data, ext) = bili_client
.get_cover_data_and_ext(collection_cover)
.await
.wrap_err("获取普通视频合集封面失败")?;
let cover_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&cover_path, cover_data).wrap_err(format!(
"保存普通视频合集封面到`{}`失败",
cover_path.display()
))?;
}
download_task.update_progress(|p| p.nfo_task.completed = true);
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn process_bangumi(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
info: &BangumiInfo,
ep_id: &i64,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip && episode_details_nfo_path.exists() {
tracing::debug!("NFO文件已存在,跳过下载");
download_task.update_progress(|p| {
p.nfo_task.skipped = true;
p.nfo_task.completed = true;
});
return Ok(());
}
let bili_client = download_task.app.get_bili_client();
let tvshow_nfo = info
.to_tvshow_nfo()
.wrap_err("将番剧信息转换为tvshow NFO失败")?;
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
.wrap_err(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?;
let episode_details_nfo = info
.to_episode_details_nfo(*ep_id)
.wrap_err("将番剧信息转换为episodedetail NFO失败")?;
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
std::fs::write(&episode_details_nfo_path, episode_details_nfo).wrap_err(format!(
"保存番剧NFO到`{}`失败",
episode_details_nfo_path.display()
))?;
let poster_url = &info.cover;
let (poster_data, ext) = bili_client
.get_cover_data_and_ext(poster_url)
.await
.wrap_err("获取番剧封面失败")?;
let poster_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&poster_path, poster_data)
.wrap_err(format!("保存番剧封面到`{}`失败", poster_path.display()))?;
let fanart_url = &info.bkg_cover;
if !fanart_url.is_empty() {
let (fanart_data, ext) = bili_client
.get_cover_data_and_ext(fanart_url)
.await
.wrap_err("获取番剧封面失败")?;
let fanart_path = episode_dir.join(format!("fanart.{ext}"));
std::fs::write(&fanart_path, fanart_data)
.wrap_err(format!("保存番剧封面到`{}`失败", fanart_path.display()))?;
}
download_task.update_progress(|p| p.nfo_task.completed = true);
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn process_cheese(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
info: &CheeseInfo,
ep_id: &i64,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip && episode_details_nfo_path.exists() {
tracing::debug!("NFO文件已存在,跳过下载");
download_task.update_progress(|p| {
p.nfo_task.skipped = true;
p.nfo_task.completed = true;
});
return Ok(());
}
let bili_client = download_task.app.get_bili_client();
let tvshow_nfo = info
.to_tvshow_nfo()
.wrap_err("将课程信息转换为tvshow NFO失败")?;
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
.wrap_err(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?;
let episode_details_nfo = info
.to_episode_details_nfo(*ep_id)
.wrap_err("将课程信息转换为episodedetail NFO失败")?;
std::fs::write(&episode_details_nfo_path, episode_details_nfo).wrap_err(format!(
"保存课程NFO到`{}`失败",
episode_details_nfo_path.display()
))?;
let poster_url = &info.cover;
let (poster_data, ext) = bili_client
.get_cover_data_and_ext(poster_url)
.await
.wrap_err("获取课程封面失败")?;
let poster_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&poster_path, poster_data)
.wrap_err(format!("保存课程封面到`{}`失败", poster_path.display()))?;
download_task.update_progress(|p| p.nfo_task.completed = true);
Ok(())
}
}
#[derive(YaSerialize, YaDeserialize)]
@@ -88,7 +301,8 @@ struct EpisodeDetails {
}
impl NormalInfo {
pub fn to_movie_nfo(&self, tags: Tags) -> anyhow::Result<String> {
#[instrument(level = "error", skip_all)]
pub fn to_movie_nfo(&self, tags: Tags) -> eyre::Result<String> {
let genre = vec![
"Bilibili视频".to_string(),
self.tname.clone(),
@@ -103,7 +317,7 @@ impl NormalInfo {
let ts = self.pubdate;
let date_time = DateTime::from_timestamp(ts, 0)
.context(format!("将视频发布时间戳转换为日期时间失败: {ts}"))?
.ok_or_eyre(format!("将视频发布时间戳转换为日期时间失败: {ts}"))?
.with_timezone(&chrono::Local);
let set = self.ugc_season.as_ref().map(|ugc_season| Set {
@@ -146,16 +360,17 @@ impl NormalInfo {
..Default::default()
};
let nfo = yaserde::ser::to_string_with_config(&movie, &cfg).map_err(|e| anyhow!(e))?;
let nfo = yaserde::ser::to_string_with_config(&movie, &cfg).map_err(|e| eyre!(e))?;
Ok(nfo)
}
}
impl BangumiInfo {
pub fn to_tvshow_nfo(&self) -> anyhow::Result<String> {
#[instrument(level = "error", skip_all)]
pub fn to_tvshow_nfo(&self) -> eyre::Result<String> {
let time_str = &self.publish.pub_time;
let date_time = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%d %H:%M:%S").context(
let date_time = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%d %H:%M:%S").wrap_err(
format!("将番剧发布时间字符串转换为日期时间失败: {time_str}"),
)?;
@@ -183,30 +398,31 @@ impl BangumiInfo {
..Default::default()
};
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| anyhow!(e))?;
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| eyre!(e))?;
Ok(nfo)
}
pub fn to_episode_details_nfo(&self, ep_id: i64) -> anyhow::Result<String> {
#[instrument(level = "error", skip_all)]
pub fn to_episode_details_nfo(&self, ep_id: i64) -> eyre::Result<String> {
let (episode, episode_order) = self.get_episode_with_order(ep_id)?;
let ts = episode.pub_time;
let date_time = DateTime::from_timestamp(ts, 0)
.context(format!("将番剧发布时间戳转换为日期时间失败: {ts}"))?
.ok_or_eyre(format!("将番剧发布时间戳转换为日期时间失败: {ts}"))?
.with_timezone(&chrono::Local);
let title = episode
.show_title
.clone()
.context("episode.show_title为None")?;
.ok_or_eyre("episode.show_title为None")?;
let plot = episode
.share_copy
.clone()
.context("episode.share_copy为None")?;
.ok_or_eyre("episode.share_copy为None")?;
let duration = episode.duration.context("episode.duration为None")?;
let duration = episode.duration.ok_or_eyre("episode.duration为None")?;
let episode_details = EpisodeDetails {
title,
@@ -229,7 +445,7 @@ impl BangumiInfo {
};
let nfo =
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| anyhow!(e))?;
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| eyre!(e))?;
Ok(nfo)
}
@@ -278,11 +494,12 @@ impl BangumiInfo {
}
impl CheeseInfo {
pub fn to_tvshow_nfo(&self) -> anyhow::Result<String> {
let episode = self.episodes.first().context("episodes列表为空")?;
#[instrument(level = "error", skip_all)]
pub fn to_tvshow_nfo(&self) -> eyre::Result<String> {
let episode = self.episodes.first().ok_or_eyre("episodes列表为空")?;
let ts = episode.release_date;
let date_time = DateTime::from_timestamp(ts, 0)
.context(format!("将课程的发布时间戳转换为日期时间失败: {ts}"))?
.ok_or_eyre(format!("将课程的发布时间戳转换为日期时间失败: {ts}"))?
.with_timezone(&chrono::Local);
let status = match self.release_status.as_str() {
@@ -309,21 +526,22 @@ impl CheeseInfo {
..Default::default()
};
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| anyhow!(e))?;
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| eyre!(e))?;
Ok(nfo)
}
pub fn to_episode_details_nfo(&self, ep_id: i64) -> anyhow::Result<String> {
#[instrument(level = "error", skip_all)]
pub fn to_episode_details_nfo(&self, ep_id: i64) -> eyre::Result<String> {
let episode = self
.episodes
.iter()
.find(|ep| ep.id == ep_id)
.context(format!("找不到ep_id为`{ep_id}`的课程"))?;
.ok_or_eyre("找不到ep_id对应的课程")?;
let ts = episode.release_date;
let date_time = DateTime::from_timestamp(ts, 0)
.context(format!("将课程发布时间戳转换为日期时间失败: {ts}"))?
.ok_or_eyre(format!("将课程发布时间戳转换为日期时间失败: {ts}"))?
.with_timezone(&chrono::Local);
let episode_details = EpisodeDetails {
@@ -347,7 +565,7 @@ impl CheeseInfo {
};
let nfo =
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| anyhow!(e))?;
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| eyre!(e))?;
Ok(nfo)
}
@@ -1,14 +1,75 @@
use std::sync::Arc;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
extensions::{AppHandleExt, GetOrInitPlayerInfo},
types::player_info::PlayerInfo,
utils,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SubtitleTask {
pub selected: bool,
pub completed: bool,
}
impl SubtitleTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> eyre::Result<()> {
use std::fmt::Write;
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let player_info = player_info
.get_or_init(&download_task.app, progress)
.await?;
let bili_client = download_task.app.get_bili_client();
for subtitle_detail in &player_info.subtitle.subtitles {
let url = format!("http:{}", subtitle_detail.subtitle_url);
let subtitle = bili_client.get_subtitle(&url).await.wrap_err(
"获取字幕失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[下载字幕]",
)?;
let mut srt_content = String::new();
for (i, b) in subtitle.body.iter().enumerate() {
let index = i + 1;
let content = &b.content;
let start_time = utils::seconds_to_srt_time(b.from);
let end_time = utils::seconds_to_srt_time(b.to);
let _ = writeln!(
&mut srt_content,
"{index}\n{start_time} --> {end_time}\n{content}\n"
);
}
let lan = utils::filename_filter(&subtitle_detail.lan);
let save_path = episode_dir.join(format!("{filename}.{lan}.srt"));
std::fs::write(save_path, srt_content)?;
}
download_task.update_progress(|p| p.subtitle_task.completed = true);
Ok(())
}
}
@@ -0,0 +1,347 @@
use std::{io, path::PathBuf, sync::Arc};
use eyre::{WrapErr, eyre};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::AppHandle;
use tracing::instrument;
use crate::{
downloader::{
chapter_segments::{ChapterSegment, ChapterSegments},
download_progress::DownloadProgress,
download_task::DownloadTask,
},
extensions::{AppHandleExt, GetOrInitPlayerInfo},
types::player_info::PlayerInfo,
utils,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct VideoProcessTask {
pub merge_selected: bool,
pub embed_chapter_selected: bool,
pub embed_skip_selected: bool,
pub completed: bool,
pub skipped: bool,
}
impl VideoProcessTask {
pub fn mark_uncompleted(&mut self) {
self.completed = false;
self.skipped = false;
}
pub fn is_completed(&self) -> bool {
!self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected
|| self.completed
}
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> eyre::Result<()> {
let embed_selected = self.embed_chapter_selected || self.embed_skip_selected;
if self.merge_selected && embed_selected {
self.merge_and_embed(download_task, progress, player_info)
.await
.wrap_err("自动合并+嵌入章节元数据失败")?;
} else if self.merge_selected {
self.merge(download_task, progress)
.await
.wrap_err("自动合并失败")?;
} else if embed_selected {
self.embed(download_task, progress, player_info)
.await
.wrap_err("嵌入章节元数据失败")?;
}
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn merge_and_embed(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let video_path = episode_dir.join(format!("{filename}.mp4"));
if !video_path.exists() {
download_task.update_progress(|p| p.video_process_task.completed = true);
return Ok(());
}
let audio_path = episode_dir.join(format!("{filename}.m4a"));
if !audio_path.exists() {
// 如果音频文件不存在,则只嵌入章节元数据
self.embed(download_task, progress, player_info)
.await
.wrap_err("嵌入章节元数据失败")?;
return Ok(());
}
let metadata_path = self
.create_chapter_metadata(&download_task.app, progress, player_info)
.await
.wrap_err("创建章节元数据失败")?;
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(&video_path);
command.arg("-i").arg(&audio_path);
if let Some(metadata_path) = &metadata_path {
command.arg("-i").arg(metadata_path);
command.arg("-map_metadata").arg("2");
}
command.arg("-c").arg("copy");
command.arg("-map").arg("0:v:0");
command.arg("-map").arg("1:a:0");
command.arg(&output_path).arg("-y");
#[cfg(target_os = "windows")]
{
// 隐藏窗口
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
execute_ffmpeg(command).await.wrap_err("执行FFmpeg失败")?;
std::fs::remove_file(&video_path)
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::remove_file(&audio_path)
.wrap_err(format!("删除音频文件`{}`失败", audio_path.display()))?;
std::fs::rename(&output_path, &video_path).wrap_err(format!(
"将`{}`重命名为`{}`失败",
output_path.display(),
video_path.display()
))?;
if let Some(metadata_path) = metadata_path {
std::fs::remove_file(&metadata_path).wrap_err(format!(
"删除章节元数据文件`{}`失败",
metadata_path.display()
))?;
}
download_task.update_progress(|p| p.video_process_task.completed = true);
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn merge(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let video_path = episode_dir.join(format!("{filename}.mp4"));
if !video_path.exists() {
download_task.update_progress(|p| p.video_process_task.completed = true);
return Ok(());
}
let audio_path = episode_dir.join(format!("{filename}.m4a"));
if !audio_path.exists() {
download_task.update_progress(|p| p.video_process_task.completed = true);
return Ok(());
}
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(&video_path);
command.arg("-i").arg(&audio_path);
command.arg("-c").arg("copy");
command.arg("-map").arg("0:v:0");
command.arg("-map").arg("1:a:0");
command.arg(&output_path).arg("-y");
#[cfg(target_os = "windows")]
{
// 隐藏窗口
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
execute_ffmpeg(command).await.wrap_err("执行FFmpeg失败")?;
std::fs::remove_file(&video_path)
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::remove_file(&audio_path)
.wrap_err(format!("删除音频文件`{}`失败", audio_path.display()))?;
std::fs::rename(&output_path, &video_path).wrap_err(format!(
"将`{}`重命名为`{}`失败",
output_path.display(),
video_path.display()
))?;
download_task.update_progress(|p| p.video_process_task.completed = true);
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn embed(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let video_path = episode_dir.join(format!("{filename}.mp4"));
if !video_path.exists() {
download_task.update_progress(|p| p.video_process_task.completed = true);
return Ok(());
}
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
let metadata_path = self
.create_chapter_metadata(&download_task.app, progress, player_info)
.await
.wrap_err("创建章节元数据失败")?;
let Some(metadata_path) = metadata_path else {
download_task.update_progress(|p| p.video_process_task.completed = true);
return Ok(());
};
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(&video_path);
command.arg("-i").arg(&metadata_path);
command.arg("-map_metadata").arg("1");
command.arg("-c").arg("copy");
command.arg(&output_path).arg("-y");
#[cfg(target_os = "windows")]
{
// 隐藏窗口
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
execute_ffmpeg(command).await.wrap_err("执行FFmpeg失败")?;
std::fs::remove_file(&video_path)
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::rename(&output_path, &video_path).wrap_err(format!(
"将`{}`重命名为`{}`失败",
output_path.display(),
video_path.display()
))?;
std::fs::remove_file(&metadata_path).wrap_err(format!(
"删除章节元数据文件`{}`失败",
metadata_path.display()
))?;
download_task.update_progress(|p| p.video_process_task.completed = true);
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn create_chapter_metadata(
&self,
app: &AppHandle,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> eyre::Result<Option<PathBuf>> {
let mut chapter_segments = ChapterSegments {
segments: Vec::new(),
};
if self.embed_chapter_selected {
let player_info = player_info.get_or_init(app, progress).await?;
let segments = player_info
.view_points
.iter()
.map(|vp| ChapterSegment {
title: vp.content.clone(),
start: vp.from,
end: vp.to,
})
.collect();
chapter_segments = ChapterSegments { segments };
}
if let (true, Some(bvid)) = (self.embed_skip_selected, &progress.bvid) {
let bili_client = app.get_bili_client();
let cid = Some(progress.cid);
let skip_segments = bili_client
.get_skip_segments(bvid, cid)
.await
.wrap_err("获取广告片段失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[标记广告]")?;
for segment in skip_segments.0 {
if let Some(chapter_segment) = segment.into_chapter_segment() {
chapter_segments.insert(chapter_segment);
}
}
}
if chapter_segments.segments.is_empty() {
return Ok(None);
}
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
std::fs::write(&metadata_path, metadata_content)
.wrap_err(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
Ok(Some(metadata_path))
}
}
#[instrument(level = "error", skip_all)]
async fn execute_ffmpeg(mut command: std::process::Command) -> eyre::Result<()> {
let span = tracing::Span::current();
tauri::async_runtime::spawn_blocking(move || {
let _enter = span.enter();
let program = command.get_program().to_string_lossy().into_owned();
let output = command.output().map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
eyre!(err).wrap_err(format!("找不到FFmpeg `{program}`"))
} else {
eyre!(err)
}
})?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(eyre!(format!("STDOUT: {stdout}")))
.wrap_err(format!("STDERR: {stderr}"))
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试")
})
.await?
}
+290 -62
View File
@@ -1,23 +1,36 @@
use std::cmp::Reverse;
use std::{
collections::HashMap,
fs::{File, OpenOptions},
sync::Arc,
};
use anyhow::anyhow;
use eyre::{OptionExt, WrapErr, eyre};
use fs4::fs_std::FileExt;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::AppHandle;
use tokio::task::JoinSet;
use tracing::{Instrument, instrument};
use crate::{
downloader::media_chunk::MediaChunk,
extensions::AppHandleExt,
config::FileExistAction,
downloader::{
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
download_task::DownloadTask, media_chunk::MediaChunk,
},
extensions::{AppHandleExt, EyreReportToMessage},
types::{
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
},
utils,
};
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct VideoTask {
pub selected: bool,
pub url: String,
@@ -26,14 +39,16 @@ pub struct VideoTask {
pub content_length: u64,
pub chunks: Vec<MediaChunk>,
pub completed: bool,
pub skipped: bool,
}
impl VideoTask {
#[instrument(level = "error", skip_all)]
pub async fn prepare_normal(
&mut self,
app: &AppHandle,
media_url: &NormalMediaUrl,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let mut join_set = JoinSet::new();
for media in &media_url.dash.video {
@@ -45,7 +60,7 @@ impl VideoTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
MediaForPrepare {
@@ -53,31 +68,56 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
for durl in &media_url.durl {
let app = app.clone();
let id = media_url.quality;
let codecid = media_url.video_codecid;
let mut urls = Vec::new();
urls.extend_from_slice(&durl.backup_url);
urls.push(durl.url.clone());
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
MediaForPrepare {
id,
url_with_content_length,
codecid,
}
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
let mut medias: Vec<MediaForPrepare> = Vec::new();
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取视频地址失败"));
}
self.prepare(app, medias);
self.prepare(app, &medias)?;
Ok(())
}
#[instrument(level = "error", skip_all)]
pub async fn prepare_bangumi(
&mut self,
app: &AppHandle,
media_url: &BangumiMediaUrl,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let mut medias: Vec<MediaForPrepare> = Vec::new();
let mut join_set = JoinSet::new();
@@ -92,7 +132,7 @@ impl VideoTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -101,7 +141,9 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -115,7 +157,7 @@ impl VideoTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -124,30 +166,33 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取视频地址失败"));
}
self.prepare(app, medias);
self.prepare(app, &medias)?;
Ok(())
}
#[instrument(level = "error", skip_all)]
pub async fn prepare_cheese(
&mut self,
app: &AppHandle,
media_url: &CheeseMediaUrl,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let mut medias: Vec<MediaForPrepare> = Vec::new();
let mut join_set = JoinSet::new();
@@ -162,7 +207,7 @@ impl VideoTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.base_url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -171,7 +216,9 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -185,7 +232,7 @@ impl VideoTask {
urls.extend_from_slice(&media.backup_url);
urls.push(media.url.clone());
join_set.spawn(async move {
let get_url_with_content_length_task = async move {
let bili_client = app.get_bili_client();
let url_with_content_length =
bili_client.get_url_with_content_length(urls).await;
@@ -194,57 +241,52 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
while let Some(Ok(media)) = join_set.join_next().await {
while let Some(join_result) = join_set.join_next().await {
let Ok(media) = join_result else {
continue;
};
if !media.url_with_content_length.is_empty() {
medias.push(media);
}
}
if medias.is_empty() {
return Err(anyhow!("获取视频地址失败"));
}
self.prepare(app, medias);
self.prepare(app, &medias)?;
Ok(())
}
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) {
medias.sort_by_key(|m| Reverse(m.id));
let best_quality_id = medias[0].id;
#[instrument(level = "error", skip_all)]
fn prepare(&mut self, app: &AppHandle, medias: &[MediaForPrepare]) -> eyre::Result<()> {
if medias.is_empty() {
return Err(eyre!("获取视频地址失败,medias为空"));
}
let (prefer_quality, prefer_codec_type) = {
let config = app.get_config().inner().read();
(config.prefer_video_quality, config.prefer_codec_type)
};
let video_quality_is_unknown = self.video_quality == VideoQuality::Unknown;
let codec_type_is_unknown = self.codec_type == CodecType::Unknown;
let prefer_quality_id: i64 = prefer_quality.into();
let prefer_codec_id: i64 = prefer_codec_type.into();
let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id);
let mut quality_filtered_medias: Vec<MediaForPrepare> = if prefer_quality_found {
// 如果用户指定质量存在,则使用用户指定的质量
medias
.into_iter()
.filter(|m| m.id == prefer_quality_id)
.collect()
if video_quality_is_unknown != codec_type_is_unknown {
return Err(eyre!(
"`video_quality`和`codec_type`必须同时为`Unknown`或同时不为`Unknown`"
));
}
// 如果`video_quality`和`codec_type`同时为`Unknown`,则更倾向于使用优先级选择
let prefer_select_by_priority = video_quality_is_unknown;
let selected_media = if prefer_select_by_priority {
select_media_by_priority(app, medias)
} else {
// 否则使用最高质量
medias
.into_iter()
.filter(|m| m.id == best_quality_id)
.collect()
select_exact_match_media(self, medias).or_else(|| select_media_by_priority(app, medias))
};
// 按照 AVC > HEVC > AV1 的顺序排列
quality_filtered_medias.sort_by_key(|m| m.codecid);
let media = quality_filtered_medias
.iter()
.find(|m| m.codecid == prefer_codec_id)
.unwrap_or(&quality_filtered_medias[0]);
let media = selected_media.ok_or_eyre("获取视频地址失败,medias为空")?;
self.video_quality = media.id.into();
self.codec_type = media.codecid.into();
@@ -276,6 +318,8 @@ impl VideoTask {
self.content_length = content_length;
self.chunks = chunks;
}
Ok(())
}
pub fn mark_uncompleted(&mut self) {
@@ -283,15 +327,199 @@ impl VideoTask {
self.chunks.iter_mut().for_each(|chunk| {
chunk.completed = false;
});
self.skipped = false;
}
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
pub async fn process(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let video_task = download_task.progress.read().video_task.clone();
let mp4_path = episode_dir.join(format!("{filename}.mp4"));
let file_exist_action = download_task.app.get_config().read().file_exist_action;
if file_exist_action == FileExistAction::Skip && mp4_path.exists() {
tracing::debug!("视频文件已存在,跳过下载");
download_task.update_progress(|p| {
p.video_task.skipped = true;
p.video_task.completed = true;
});
return Ok(());
}
let temp_file_path = episode_dir.join(format!(
"{filename}.mp4.com.lanyeeee.bilibili-video-downloader"
));
let should_reuse_temp_file = temp_file_path
.metadata()
.map(|m| m.len() == video_task.content_length)
.unwrap_or(false);
let file = if should_reuse_temp_file {
// 如果临时文件可以重用,则直接打开它
OpenOptions::new()
.read(true)
.write(true)
.open(&temp_file_path)?
} else {
// 如果临时文件不能重用,则创建个新的
let file = File::create(&temp_file_path)?;
file.allocate(video_task.content_length)?;
file
};
let file = Arc::new(Mutex::new(file));
let chunk_count = video_task.chunks.len();
let mut join_set = JoinSet::new();
for (chunk_index, chunk) in video_task.chunks.iter().enumerate() {
if chunk.completed {
continue;
}
let (start, end) = (chunk.start, chunk.end);
let download_chunk_task = DownloadChunkTask {
download_task: download_task.clone(),
start,
end,
url: video_task.url.clone(),
file: file.clone(),
chunk_index,
};
let chunk_order = chunk_index + 1;
let chunk_task = async move {
download_chunk_task.process().await.wrap_err(format!(
"分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})"
))
};
join_set.spawn(chunk_task.in_current_span());
}
while let Some(join_result) = join_set.join_next().await {
let Ok(download_video_result) = join_result else {
continue;
};
match download_video_result {
Ok(i) => download_task.update_progress(|p| p.video_task.chunks[i].completed = true),
Err(err) => {
let err_title = "视频的一个分片下载失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
// 检查视频是否已下载完成
let download_completed = download_task
.progress
.read()
.video_task
.chunks
.iter()
.all(|chunk| chunk.completed);
if !download_completed {
return Err(eyre!(
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
temp_file_path.display()
));
}
let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).wrap_err(format!(
"检查视频文件`{}`是否完整失败",
temp_file_path.display()
))?;
if !is_video_file_complete {
download_task.update_progress(|p| p.video_task.mark_uncompleted());
return Err(eyre!(
"视频文件`{}`不完整,[继续]会重新下载所有分片",
temp_file_path.display()
));
}
// 重命名临时文件
if mp4_path.exists() {
std::fs::remove_file(&mp4_path)
.wrap_err(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?;
}
std::fs::rename(&temp_file_path, &mp4_path).wrap_err(format!(
"将临时文件`{}`重命名为`{}`失败",
temp_file_path.display(),
mp4_path.display()
))?;
download_task.update_progress(|p| p.video_task.completed = true);
Ok(())
}
}
#[derive(Clone)]
struct MediaForPrepare {
pub id: i64,
pub url_with_content_length: Vec<(String, u64)>,
pub codecid: i64,
}
fn select_exact_match_media(
video_task: &VideoTask,
medias: &[MediaForPrepare],
) -> Option<MediaForPrepare> {
let media = medias.iter().find(|media| {
let quality: VideoQuality = media.id.into();
let codec_type: CodecType = media.codecid.into();
quality == video_task.video_quality && codec_type == video_task.codec_type
});
media.cloned()
}
fn select_media_by_priority(
app: &AppHandle,
medias: &[MediaForPrepare],
) -> Option<MediaForPrepare> {
let (video_quality_priority, codec_type_priority) = {
let config = app.get_config().inner().read();
(
config.video_quality_priority.clone(),
config.codec_type_priority.clone(),
)
};
// 构建索引表,这是为了在排序时能以 O(1) 查找到优先级,索引越小优先级越高
let video_priority_map: HashMap<&VideoQuality, usize> = video_quality_priority
.iter()
.enumerate()
.map(|(index, quality)| (quality, index))
.collect();
let codec_priority_map: HashMap<&CodecType, usize> = codec_type_priority
.iter()
.enumerate()
.map(|(index, codec_type)| (codec_type, index))
.collect();
let media = medias.iter().min_by_key(|media| {
let quality: VideoQuality = media.id.into();
let quality_index = video_priority_map.get(&quality).unwrap_or(&usize::MAX);
let codec_type: CodecType = media.codecid.into();
let codec_index = codec_priority_map.get(&codec_type).unwrap_or(&usize::MAX);
// Rust 的元组比较机制是从左到右依次比较
// 先比较quality_index(主排序键)
// 如果quality_index相同,则比较codec_index(次排序键)
(quality_index, codec_index)
});
media.cloned()
}
+67 -7
View File
@@ -1,26 +1,86 @@
use std::panic::Location;
use eyre::EyreHandler;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::extensions::AnyhowErrorToStringChain;
use tracing::instrument;
use tracing_error::SpanTrace;
pub type CommandResult<T> = Result<T, CommandError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct CommandError {
pub err_title: String,
pub err_message: String,
pub message: String,
}
impl CommandError {
pub fn from<E>(err_title: &str, err: E) -> Self
where
E: Into<anyhow::Error>,
E: Into<eyre::Report>,
{
let string_chain = err.into().to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = format!("{:?}", err.into());
tracing::error!(err_title, message);
Self {
err_title: err_title.to_string(),
err_message: string_chain,
message,
}
}
}
struct CustomEyreHandler {
span_trace: SpanTrace,
location: Option<&'static Location<'static>>,
}
impl EyreHandler for CustomEyreHandler {
fn debug(
&self,
error: &(dyn std::error::Error + 'static),
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
use std::fmt::Write;
let mut buf = String::new();
writeln!(&mut buf, "Error:")?;
writeln!(&mut buf, " 0: {error}")?;
let mut current = error.source();
let mut i = 1;
while let Some(cause) = current {
writeln!(&mut buf, " {i}: {cause}")?;
current = cause.source();
i += 1;
}
if let Some(loc) = self.location {
writeln!(&mut buf, "Location:")?;
writeln!(&mut buf, " at {}:{}", loc.file(), loc.line())?;
}
let span_trace = format!("{}", self.span_trace);
if !span_trace.is_empty() {
writeln!(&mut buf, "SpanTrace:")?;
writeln!(&mut buf, "{span_trace}")?;
}
write!(f, "{}", buf.trim_end())?;
Ok(())
}
fn track_caller(&mut self, location: &'static Location<'static>) {
self.location = Some(location);
}
}
#[instrument(level = "error", skip_all)]
pub fn install_custom_eyre_handler() -> eyre::Result<()> {
eyre::set_hook(Box::new(|_error| {
Box::new(CustomEyreHandler {
span_trace: SpanTrace::capture(),
location: None,
})
}))?;
Ok(())
}
+13 -12
View File
@@ -1,23 +1,16 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event;
use crate::{
downloader::{download_progress::DownloadProgress, download_task_state::DownloadTaskState},
types::log_level::LogLevel,
use crate::downloader::{
download_progress::DownloadProgress, download_task_state::DownloadTaskState,
};
use crate::types::plugin_info::PluginInfo;
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct LogEvent {
pub timestamp: String,
pub level: LogLevel,
pub fields: HashMap<String, serde_json::Value>,
pub target: String,
pub filename: String,
#[serde(rename = "line_number")]
pub line_number: i64,
pub json_raw: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
@@ -54,3 +47,11 @@ pub enum DownloadEvent {
progress: DownloadProgress,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(tag = "event", content = "data")]
pub enum PluginEvent {
Loaded { plugin_info: PluginInfo },
Update { plugin_info: PluginInfo },
Uninstall { plugin_path: String },
}
+53 -25
View File
@@ -1,45 +1,73 @@
use eyre::WrapErr;
use parking_lot::RwLock;
use tauri::{Manager, State};
use tauri::{AppHandle, Manager, State};
use tracing::instrument;
use crate::{
bili_client::BiliClient, config::Config, downloader::download_manager::DownloadManager,
bili_client::BiliClient,
config::Config,
downloader::{download_manager::DownloadManager, download_progress::DownloadProgress},
plugin::plugin_manager::PluginManager,
types::player_info::PlayerInfo,
};
pub trait AnyhowErrorToStringChain {
/// 将 `anyhow::Error` 转换为chain格式
/// # Example
/// 0: error message\
/// 1: error message\
/// 2: error message
fn to_string_chain(&self) -> String;
pub trait EyreReportToMessage {
fn to_message(&self) -> String;
}
impl AnyhowErrorToStringChain for anyhow::Error {
fn to_string_chain(&self) -> String {
use std::fmt::Write;
self.chain()
.enumerate()
.fold(String::new(), |mut output, (i, e)| {
let _ = writeln!(output, "{i}: {e}");
output
})
impl EyreReportToMessage for eyre::Report {
fn to_message(&self) -> String {
format!("{self:?}")
}
}
pub trait AppHandleExt {
fn get_config(&self) -> State<RwLock<Config>>;
fn get_bili_client(&self) -> State<BiliClient>;
fn get_download_manager(&self) -> State<DownloadManager>;
fn get_config(&self) -> State<'_, RwLock<Config>>;
fn get_bili_client(&self) -> State<'_, BiliClient>;
fn get_download_manager(&self) -> State<'_, DownloadManager>;
fn get_plugin_manager(&self) -> State<'_, PluginManager>;
}
impl AppHandleExt for tauri::AppHandle {
fn get_config(&self) -> State<RwLock<Config>> {
impl AppHandleExt for AppHandle {
fn get_config(&self) -> State<'_, RwLock<Config>> {
self.state::<RwLock<Config>>()
}
fn get_bili_client(&self) -> State<BiliClient> {
fn get_bili_client(&self) -> State<'_, BiliClient> {
self.state::<BiliClient>()
}
fn get_download_manager(&self) -> State<DownloadManager> {
fn get_download_manager(&self) -> State<'_, DownloadManager> {
self.state::<DownloadManager>()
}
fn get_plugin_manager(&self) -> State<'_, PluginManager> {
self.state::<PluginManager>()
}
}
pub trait GetOrInitPlayerInfo {
async fn get_or_init<'a>(
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> eyre::Result<&'a mut PlayerInfo>;
}
impl GetOrInitPlayerInfo for Option<PlayerInfo> {
#[instrument(level = "error", skip_all)]
async fn get_or_init<'a>(
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> eyre::Result<&'a mut PlayerInfo> {
if let Some(info) = self {
return Ok(info);
}
let bili_client = app.get_bili_client();
let info = bili_client
.get_player_info(progress.aid, progress.cid)
.await
.wrap_err("获取播放器信息失败")?;
Ok(self.insert(info))
}
}
+49 -7
View File
@@ -7,54 +7,86 @@ mod errors;
mod events;
mod extensions;
mod logger;
mod plugin;
mod types;
mod utils;
mod wbi;
#[allow(warnings)]
mod protobuf {
include!("./bilibili.community.service.dm.v1.rs");
}
use anyhow::Context;
use commands::*;
use config::Config;
use commands::{
add_plugin, create_download_tasks, delete_download_tasks, generate_qrcode,
get_available_media_formats, get_bangumi_follow_info, get_bangumi_info, get_config,
get_fav_folders, get_fav_info, get_history_info, get_logs_dir_size, get_normal_info,
get_plugin_infos, get_qrcode_status, get_skip_segments, get_user_info, get_user_video_info,
get_watch_later_info, pause_download_tasks, restart_download_task, restart_download_tasks,
restore_download_tasks, resume_download_tasks, save_config, search, set_plugin_enabled,
set_plugin_priority, show_path_in_file_manager, uninstall_plugin,
};
use eyre::WrapErr;
use parking_lot::RwLock;
use tauri::{Manager, Wry};
use crate::{
bili_client::BiliClient,
commands::open_log_file,
config::Config,
downloader::download_manager::DownloadManager,
events::{DownloadEvent, LogEvent},
errors::install_custom_eyre_handler,
events::{DownloadEvent, LogEvent, PluginEvent},
plugin::plugin_manager::PluginManager,
};
fn generate_context() -> tauri::Context<Wry> {
tauri::generate_context!()
}
#[allow(clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
install_custom_eyre_handler().unwrap();
let builder = tauri_specta::Builder::<Wry>::new()
.commands(tauri_specta::collect_commands![
get_config,
save_config,
get_plugin_infos,
generate_qrcode,
get_qrcode_status,
get_user_info,
get_normal_info,
get_bangumi_info,
get_user_video_info,
get_fav_folders,
get_fav_info,
get_watch_later_info,
get_bangumi_follow_info,
get_history_info,
create_download_tasks,
pause_download_tasks,
resume_download_tasks,
delete_download_tasks,
restart_download_tasks,
restart_download_task,
restore_download_tasks,
search,
get_logs_dir_size,
show_path_in_file_manager,
get_skip_segments,
get_available_media_formats,
open_log_file,
add_plugin,
uninstall_plugin,
set_plugin_enabled,
set_plugin_priority,
])
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]);
.events(tauri_specta::collect_events![
LogEvent,
DownloadEvent,
PluginEvent,
]);
#[cfg(debug_assertions)]
builder
@@ -67,7 +99,14 @@ pub fn run() {
)
.expect("Failed to export typescript bindings");
// 解决Ubuntu24.04窗口全白的问题
#[cfg(target_os = "linux")]
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init())
@@ -78,9 +117,9 @@ pub fn run() {
let app_data_dir = app
.path()
.app_data_dir()
.context("获取app_data_dir目录失败")?;
.wrap_err("获取app_data_dir目录失败")?;
std::fs::create_dir_all(&app_data_dir).context(format!(
std::fs::create_dir_all(&app_data_dir).wrap_err(format!(
"创建app_data_dir目录`{:?}`失败",
app_data_dir.display()
))?;
@@ -96,6 +135,9 @@ pub fn run() {
logger::init(app.handle())?;
let plugin_manager = PluginManager::new(app.handle())?;
app.manage(plugin_manager);
Ok(())
})
.run(generate_context())
+80 -62
View File
@@ -1,26 +1,26 @@
use std::{io::Write, sync::OnceLock};
use anyhow::Context;
use crate::{
events::LogEvent,
extensions::{AppHandleExt, EyreReportToMessage},
};
use eyre::{OptionExt, WrapErr};
use notify::{RecommendedWatcher, Watcher};
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tracing::{Level, Subscriber};
use tracing::{Instrument, Level, Subscriber, instrument};
use tracing_appender::{
non_blocking::WorkerGuard,
rolling::{RollingFileAppender, Rotation},
};
use tracing_error::ErrorLayer;
use tracing_subscriber::{
filter::{filter_fn, FilterExt, Targets},
fmt::{layer, time::LocalTime},
Layer, Registry,
filter::{FilterExt, Targets, filter_fn},
fmt::{MakeWriter, format::JsonFields, layer, time::LocalTime},
layer::SubscriberExt,
registry::LookupSpan,
util::SubscriberInitExt,
Layer, Registry,
};
use crate::{
events::LogEvent,
extensions::{AnyhowErrorToStringChain, AppHandleExt},
};
struct LogEventWriter {
@@ -29,17 +29,8 @@ struct LogEventWriter {
impl Write for LogEventWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let log_string = String::from_utf8_lossy(buf);
match serde_json::from_str::<LogEvent>(&log_string) {
Ok(log_event) => {
let _ = log_event.emit(&self.app);
}
Err(err) => {
let log_string = log_string.to_string();
let err_msg = err.to_string();
tracing::error!(log_string, err_msg, "将日志字符串解析为LogEvent失败");
}
}
let json_raw = String::from_utf8_lossy(buf).to_string();
let _ = LogEvent { json_raw }.emit(&self.app);
Ok(buf.len())
}
@@ -48,12 +39,27 @@ impl Write for LogEventWriter {
}
}
static RELOAD_FN: OnceLock<Box<dyn Fn() -> anyhow::Result<()> + Send + Sync>> = OnceLock::new();
struct LogEventWriterFactory {
app: AppHandle,
}
impl MakeWriter<'_> for LogEventWriterFactory {
type Writer = LogEventWriter;
fn make_writer(&self) -> Self::Writer {
LogEventWriter {
app: self.app.clone(),
}
}
}
static RELOAD_FN: OnceLock<Box<dyn Fn() -> eyre::Result<()> + Send + Sync>> = OnceLock::new();
static GUARD: OnceLock<parking_lot::Mutex<Option<WorkerGuard>>> = OnceLock::new();
pub fn init(app: &AppHandle) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
pub fn init(app: &AppHandle) -> eyre::Result<()> {
let lib_module_path = module_path!();
let lib_target = lib_module_path.split("::").next().context(format!(
let lib_target = lib_module_path.split("::").next().ok_or_eyre(format!(
"解析lib_target失败: lib_module_path={lib_module_path}"
))?;
// 过滤掉来自其他库的日志
@@ -66,11 +72,12 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
.with_writer(std::io::stdout)
.with_timer(LocalTime::rfc_3339())
.with_file(true)
.with_line_number(true);
.with_line_number(true)
.pretty();
// 发送到前端
let log_event_writer = std::sync::Mutex::new(LogEventWriter { app: app.clone() });
let log_event_factory = LogEventWriterFactory { app: app.clone() };
let log_event_layer = layer()
.with_writer(log_event_writer)
.with_writer(log_event_factory)
.with_timer(LocalTime::rfc_3339())
.with_file(true)
.with_line_number(true)
@@ -85,6 +92,7 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
.with(reloadable_file_layer)
.with(console_layer)
.with(log_event_layer)
.with(ErrorLayer::new(JsonFields::default()))
.init();
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
@@ -92,8 +100,8 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
let app = app.clone();
Box::new(move || {
let (file_layer, guard) = create_file_layer(&app)?;
reload_handle.reload(file_layer).context("reload失败")?;
*GUARD.get().context("GUARD未初始化")?.lock() = guard;
reload_handle.reload(file_layer).wrap_err("reload失败")?;
*GUARD.get().ok_or_eyre("GUARD未初始化")?.lock() = guard;
Ok(())
})
});
@@ -102,20 +110,23 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
Ok(())
}
pub fn reload_file_logger() -> anyhow::Result<()> {
RELOAD_FN.get().context("RELOAD_FN未初始化")?()
#[instrument(level = "error", skip_all)]
pub fn reload_file_logger() -> eyre::Result<()> {
RELOAD_FN.get().ok_or_eyre("RELOAD_FN未初始化")?()
}
pub fn disable_file_logger() -> anyhow::Result<()> {
if let Some(guard) = GUARD.get().context("GUARD未初始化")?.lock().take() {
#[instrument(level = "error", skip_all)]
pub fn disable_file_logger() -> eyre::Result<()> {
if let Some(guard) = GUARD.get().ok_or_eyre("GUARD未初始化")?.lock().take() {
drop(guard);
}
Ok(())
}
#[instrument(level = "error", skip_all)]
fn create_file_layer<S>(
app: &AppHandle,
) -> anyhow::Result<(Box<dyn Layer<S> + Send + Sync>, Option<WorkerGuard>)>
) -> eyre::Result<(Box<dyn Layer<S> + Send + Sync>, Option<WorkerGuard>)>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
@@ -127,47 +138,53 @@ where
.with_timer(LocalTime::rfc_3339())
.with_ansi(false)
.with_file(true)
.with_line_number(true);
.with_line_number(true)
.json();
return Ok((Box::new(sink_layer), None));
}
let logs_dir = logs_dir(app).context("获取日志目录失败")?;
let logs_dir = logs_dir(app).wrap_err("获取日志目录失败")?;
let file_appender = RollingFileAppender::builder()
.filename_prefix("bilibili-video-downloader")
.filename_suffix("log")
.rotation(Rotation::DAILY)
.build(&logs_dir)
.context("创建RollingFileAppender失败")?;
.wrap_err("创建RollingFileAppender失败")?;
let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = layer()
.with_writer(non_blocking_appender)
.with_timer(LocalTime::rfc_3339())
.with_ansi(false)
.with_file(true)
.with_line_number(true);
.with_line_number(true)
.json();
Ok((Box::new(file_layer), Some(guard)))
}
#[instrument(level = "error", skip_all)]
async fn file_log_watcher(app: AppHandle) {
let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
let event_handler_span = tracing::error_span!("file_log_watcher_event_handler");
let event_handler = move |res| {
tauri::async_runtime::block_on(async {
if let Err(err) = sender.send(res).await.map_err(anyhow::Error::from) {
let send_event_task = async {
if let Err(err) = sender.send(res).await.map_err(eyre::Report::from) {
let err_title = "发送日志文件watcher事件失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
}
});
};
tauri::async_runtime::block_on(send_event_task.instrument(event_handler_span.clone()));
};
let mut watcher = match RecommendedWatcher::new(event_handler, notify::Config::default())
.map_err(anyhow::Error::from)
.map_err(eyre::Report::from)
{
Ok(watcher) => watcher,
Err(err) => {
let err_title = "创建日志文件watcher失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
return;
}
};
@@ -176,46 +193,47 @@ async fn file_log_watcher(app: AppHandle) {
Ok(logs_dir) => logs_dir,
Err(err) => {
let err_title = "日志文件watcher获取日志目录失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
return;
}
};
if let Err(err) = watcher
.watch(&logs_dir, notify::RecursiveMode::NonRecursive)
.map_err(anyhow::Error::from)
.map_err(eyre::Report::from)
{
let err_title = "日志文件watcher监听日志目录失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
return;
}
while let Some(res) = receiver.recv().await {
match res.map_err(anyhow::Error::from) {
match res.map_err(eyre::Report::from) {
Ok(event) => {
if let notify::EventKind::Remove(_) = event.kind {
if let Err(err) = reload_file_logger() {
let err_title = "重置日志文件失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
}
if let notify::EventKind::Remove(_) = event.kind
&& let Err(err) = reload_file_logger()
{
let err_title = "重置日志文件失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
Err(err) => {
let err_title = "接收日志文件watcher事件失败";
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
}
pub fn logs_dir(app: &AppHandle) -> anyhow::Result<std::path::PathBuf> {
#[instrument(level = "error", skip_all)]
pub fn logs_dir(app: &AppHandle) -> eyre::Result<std::path::PathBuf> {
let app_data_dir = app
.path()
.app_data_dir()
.context("获取app_data_dir目录失败")?;
.wrap_err("获取app_data_dir目录失败")?;
Ok(app_data_dir.join("日志"))
}
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bilibili_video_downloader_lib::run()
bilibili_video_downloader_lib::run();
}
+6
View File
@@ -0,0 +1,6 @@
pub mod hook_context;
pub mod host_api;
pub mod plugin_executor;
pub mod plugin_loader;
pub mod plugin_manager;
pub mod plugin_types;
+189
View File
@@ -0,0 +1,189 @@
use bilibili_video_downloader_plugin_api::v1::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, DownloadProgressV1, HookInputV1,
HookOutputV1, HookPayloadV1, HookPointV1, HookReadonlyMetaV1, OnCompletedPayloadV1,
};
use eyre::{WrapErr, eyre};
use serde::{Serialize, de::DeserializeOwned};
use crate::downloader::download_progress::DownloadProgress;
pub struct BeforeVideoProcessContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> BeforeVideoProcessContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<BeforeVideoProcessPayloadV1> {
Ok(BeforeVideoProcessPayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: BeforeVideoProcessPayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub struct OnCompletedContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> OnCompletedContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<OnCompletedPayloadV1> {
Ok(OnCompletedPayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: OnCompletedPayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub struct AfterPrepareContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> AfterPrepareContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<AfterPreparePayloadV1> {
Ok(AfterPreparePayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: AfterPreparePayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub enum HookContext<'a> {
BeforeVideoProcess(BeforeVideoProcessContext<'a>),
AfterPrepare(AfterPrepareContext<'a>),
OnCompleted(OnCompletedContext<'a>),
}
impl HookContext<'_> {
pub fn hook_point(&self) -> HookPointV1 {
match self {
HookContext::BeforeVideoProcess(_) => HookPointV1::BeforeVideoProcess,
HookContext::AfterPrepare(_) => HookPointV1::AfterPrepare,
HookContext::OnCompleted(_) => HookPointV1::OnCompleted,
}
}
pub fn to_input(&self, app_version: &str) -> eyre::Result<HookInputV1> {
let hook_point = self.hook_point();
let payload = match self {
HookContext::BeforeVideoProcess(context) => {
HookPayloadV1::BeforeVideoProcess(context.to_payload()?)
}
HookContext::AfterPrepare(context) => {
HookPayloadV1::AfterPrepare(context.to_payload()?)
}
HookContext::OnCompleted(context) => HookPayloadV1::OnCompleted(context.to_payload()?),
};
let input = HookInputV1 {
hook_point,
payload,
readonly_meta: HookReadonlyMetaV1 {
app_version: app_version.to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
process_id: std::process::id(),
},
};
Ok(input)
}
pub fn apply_output(&mut self, output: HookOutputV1) -> eyre::Result<()> {
let context_hook_point = self.hook_point();
match (self, output.payload) {
(
HookContext::BeforeVideoProcess(context),
HookPayloadV1::BeforeVideoProcess(payload),
) => context.apply_payload(payload),
(HookContext::AfterPrepare(context), HookPayloadV1::AfterPrepare(payload)) => {
context.apply_payload(payload)
}
(HookContext::OnCompleted(context), HookPayloadV1::OnCompleted(payload)) => {
context.apply_payload(payload)
}
(_, payload) => Err(eyre!(
"hook_point 与 payload 不匹配: hook_point={context_hook_point:?}, payload={payload:?}"
)),
}
}
}
fn validate_task_id_unchanged(
current_progress: &DownloadProgress,
next_progress: &DownloadProgressV1,
) -> eyre::Result<()> {
if current_progress.task_id != next_progress.task_id {
return Err(eyre!("task_id 不可修改"));
}
Ok(())
}
fn host_to_api_progress(progress: &DownloadProgress) -> eyre::Result<DownloadProgressV1> {
convert_via_json(
progress,
"序列化宿主 DownloadProgress 失败",
"反序列化为插件 DownloadProgressV1 失败",
)
}
fn api_to_host_progress(progress: DownloadProgressV1) -> eyre::Result<DownloadProgress> {
convert_via_json(
progress,
"序列化插件 DownloadProgressV1 失败",
"反序列化为宿主 DownloadProgress 失败",
)
}
fn convert_via_json<TSrc, TDst>(
source: TSrc,
serialize_err: &str,
deserialize_err: &str,
) -> eyre::Result<TDst>
where
TSrc: Serialize,
TDst: DeserializeOwned,
{
let value = serde_json::to_value(source).wrap_err_with(|| serialize_err.to_string())?;
serde_json::from_value(value).wrap_err_with(|| deserialize_err.to_string())
}
+67
View File
@@ -0,0 +1,67 @@
use std::sync::OnceLock;
use bilibili_video_downloader_plugin_api::v1::{HostApiV1, HostConfigV1};
use eyre::WrapErr;
use tauri::AppHandle;
use crate::{config::Config, extensions::AppHandleExt};
static HOST_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
pub fn init(app: &AppHandle) {
HOST_APP_HANDLE.get_or_init(|| app.clone());
}
pub fn build_host_api_v1() -> HostApiV1 {
HostApiV1 {
get_config_json: host_get_config_json_v1,
free_buffer: host_free_buffer_v1,
}
}
unsafe extern "C" fn host_get_config_json_v1(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32 {
if out_ptr.is_null() || out_len.is_null() {
return 1;
}
let Some(app) = HOST_APP_HANDLE.get() else {
return 2;
};
let host_config = app.get_config().read().clone();
let Ok(host_config_v1) = to_host_config_v1(&host_config) else {
return 3;
};
let Ok(output_bytes) = serde_json::to_vec(&host_config_v1) else {
return 3;
};
let boxed = output_bytes.into_boxed_slice();
let len = boxed.len();
let ptr = Box::into_raw(boxed).cast::<u8>();
unsafe {
*out_ptr = ptr;
*out_len = len;
}
0
}
unsafe extern "C" fn host_free_buffer_v1(ptr: *mut u8, len: usize) {
if ptr.is_null() || len == 0 {
return;
}
let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len);
unsafe {
drop(Box::from_raw(raw_slice));
}
}
fn to_host_config_v1(config: &Config) -> eyre::Result<HostConfigV1> {
let value = serde_json::to_value(config).wrap_err("序列化宿主 Config 失败")?;
let host_config = serde_json::from_value(value).wrap_err("反序列化为插件 HostConfigV1 失败")?;
Ok(host_config)
}
+66
View File
@@ -0,0 +1,66 @@
use std::{ffi::CStr, sync::Arc};
use bilibili_video_downloader_plugin_api::v1::{HookInputV1, HookOutputV1};
use dlopen2::wrapper::Container;
use eyre::eyre;
use tracing::instrument;
use crate::plugin::plugin_types::{PluginDylibApi, PluginRuntime};
#[instrument(level = "error", skip_all, fields(plugin_name = plugin.display_name(), hook_point = ?input.hook_point))]
pub async fn execute_hook(
plugin: &PluginRuntime,
input: &HookInputV1,
) -> eyre::Result<HookOutputV1> {
let input_bytes = serde_json::to_vec(input)?;
let api = plugin.api.clone();
let (tx, rx) = tokio::sync::oneshot::channel::<eyre::Result<Vec<u8>>>();
tauri::async_runtime::spawn_blocking(move || {
let result = call_on_hook_blocking(api, &input_bytes);
let _ = tx.send(result);
});
let output_bytes = rx.await??;
let output: HookOutputV1 = serde_json::from_slice(&output_bytes)?;
Ok(output)
}
#[instrument(level = "error", skip_all)]
#[allow(clippy::needless_pass_by_value)]
fn call_on_hook_blocking(
api: Arc<Container<PluginDylibApi>>,
input_bytes: &[u8],
) -> eyre::Result<Vec<u8>> {
let mut output_ptr: *mut u8 = std::ptr::null_mut();
let mut output_len: usize = 0;
let rc = unsafe {
api.on_hook(
input_bytes.as_ptr(),
input_bytes.len(),
&raw mut output_ptr,
&raw mut output_len,
)
};
if rc != 0 {
let detail = get_last_error(&api);
return Err(eyre!("插件返回错误码: code={rc}, detail={detail}"));
}
if output_ptr.is_null() {
return Err(eyre!("插件返回空输出缓冲区"));
}
let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec();
unsafe { api.free_buffer(output_ptr, output_len) };
Ok(output_bytes)
}
fn get_last_error(api: &Arc<Container<PluginDylibApi>>) -> String {
let error_ptr = unsafe { api.last_error() };
if error_ptr.is_null() {
return "获取错误信息失败,error_ptr为null".to_string();
}
let error_cstr = unsafe { CStr::from_ptr(error_ptr) };
error_cstr.to_string_lossy().to_string()
}
+78
View File
@@ -0,0 +1,78 @@
use std::{ffi::CStr, path::Path, sync::Arc};
use bilibili_video_downloader_plugin_api::{SDK_API_VERSION_V1, v1::PluginDescriptorV1};
use dlopen2::wrapper::Container;
use eyre::{WrapErr, eyre};
use tracing::instrument;
use crate::plugin::{
host_api,
plugin_types::{PluginDylibApi, PluginRuntime},
};
#[instrument(level = "error", skip_all, fields(plugin_path = %plugin_path.display(), priority = priority, enabled = enabled))]
pub fn load_plugin_from_path(
plugin_path: &Path,
priority: i32,
enabled: bool,
) -> eyre::Result<PluginRuntime> {
if !plugin_path.is_absolute() {
return Err(eyre!("插件路径必须是绝对路径: `{}`", plugin_path.display()));
}
if !plugin_path.exists() {
return Err(eyre!("插件动态库文件`{}`不存在", plugin_path.display()));
}
let api = unsafe { Container::<PluginDylibApi>::load(plugin_path) }
.wrap_err(format!("加载插件动态库文件`{}`失败", plugin_path.display()))?;
let descriptor_json = get_descriptor_json(&api).wrap_err("读取插件描述失败")?;
let descriptor: PluginDescriptorV1 = serde_json::from_str(&descriptor_json)
.wrap_err(format!("解析插件描述失败: {descriptor_json}"))?;
if descriptor.sdk_api_version != SDK_API_VERSION_V1 {
return Err(eyre!(
"插件SDK版本不匹配: 期望版本={}, 实际版本={}",
SDK_API_VERSION_V1,
descriptor.sdk_api_version
));
}
if descriptor.id.trim().is_empty() {
return Err(eyre!("descriptor.id 为空"));
}
if descriptor.hooks.is_empty() {
return Err(eyre!("插件未声明任何可执行 Hook"));
}
let host_api = host_api::build_host_api_v1();
let rc = unsafe { api.set_host_api(&raw const host_api) };
if rc != 0 {
return Err(eyre!(
"注册宿主 Host API 失败: plugin_id={}, rc={rc}",
descriptor.id
));
}
Ok(PluginRuntime {
descriptor,
plugin_path: plugin_path.to_path_buf(),
enabled,
priority,
api: Arc::new(api),
})
}
#[instrument(level = "error", skip_all)]
fn get_descriptor_json(api: &Container<PluginDylibApi>) -> eyre::Result<String> {
let descriptor_ptr = unsafe { api.descriptor() };
if descriptor_ptr.is_null() {
return Err(eyre!("descriptor 指针为空"));
}
let descriptor_cstr = unsafe { CStr::from_ptr(descriptor_ptr).to_str() }
.wrap_err("descriptor 非 UTF-8 字符串")?;
Ok(descriptor_cstr.to_string())
}
+351
View File
@@ -0,0 +1,351 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use eyre::eyre;
use parking_lot::RwLock;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tracing::instrument;
use crate::{
events::PluginEvent,
extensions::EyreReportToMessage,
types::plugin_info::{PluginDescriptorInfo, PluginInfo, PluginMetadata, PluginRuntimeStatus},
};
use super::{
hook_context::HookContext, host_api, plugin_executor, plugin_loader,
plugin_types::PluginRuntime,
};
pub struct PluginManager {
app: AppHandle,
infos: RwLock<HashMap<String, PluginInfo>>,
runtimes: RwLock<Vec<PluginRuntime>>,
}
impl PluginManager {
#[instrument(level = "error", skip_all)]
pub fn new(app: &AppHandle) -> eyre::Result<PluginManager> {
host_api::init(app);
let app_data_dir = app.path().app_data_dir()?;
let plugin_json_path = app_data_dir.join("plugin.json");
let mut infos = HashMap::new();
if plugin_json_path.exists() {
let json_string = std::fs::read_to_string(&plugin_json_path)?;
let metadata_map: HashMap<String, PluginMetadata> =
serde_json::from_str(&json_string).unwrap_or_default();
for (plugin_path, metadata) in metadata_map {
let status = PluginRuntimeStatus::Unknown;
let info = PluginInfo::from_metadata(metadata, status);
infos.insert(plugin_path, info);
}
}
let mut runtimes = Vec::new();
for info in infos.values_mut() {
if !info.enabled {
info.runtime_status = PluginRuntimeStatus::Disabled;
continue;
}
match plugin_loader::load_plugin_from_path(&info.path, info.priority, true) {
Ok(runtime) => {
tracing::info!(
"插件加载成功: plugin_name={}, plugin_path={}",
runtime.display_name(),
runtime.plugin_path.display()
);
info.runtime_status = PluginRuntimeStatus::Loaded;
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
insert_runtime_by_priority(&mut runtimes, runtime);
}
Err(err) => {
let err_title = "某个插件加载失败,已跳过";
let message = err.to_message();
tracing::error!(err_title, message);
info.runtime_status = PluginRuntimeStatus::LoadFailed;
}
}
}
let plugin_manager = Self {
app: app.clone(),
infos: RwLock::new(infos),
runtimes: RwLock::new(runtimes),
};
plugin_manager.save_metadata()?;
Ok(plugin_manager)
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn add_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
let runtime = plugin_loader::load_plugin_from_path(&PathBuf::from(plugin_path), 0, true)?;
let plugin_info = {
let mut infos = self.infos.write();
if infos.contains_key(plugin_path) {
return Err(eyre!("插件已存在: {plugin_path}"));
}
let status = PluginRuntimeStatus::Loaded;
let metadata = PluginMetadata::from_plugin_runtime(&runtime);
let info = PluginInfo::from_metadata(metadata, status);
infos.insert(plugin_path.to_string(), info.clone());
info
};
{
let mut runtimes = self.runtimes.write();
insert_runtime_by_priority(&mut runtimes, runtime);
}
self.save_metadata()?;
let _ = PluginEvent::Loaded { plugin_info }.emit(&self.app);
Ok(())
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn uninstall_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
{
let mut infos = self.infos.write();
if !infos.contains_key(plugin_path) {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
}
infos.remove(plugin_path);
}
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
}
let _ = PluginEvent::Uninstall {
plugin_path: plugin_path.to_string(),
}
.emit(&self.app);
self.save_metadata()?;
Ok(())
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))]
pub fn set_plugin_enabled(&self, plugin_path: &str, enabled: bool) -> eyre::Result<()> {
if !enabled {
let plugin_info = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.enabled == enabled {
return Ok(());
}
info.enabled = false;
info.runtime_status = PluginRuntimeStatus::Disabled;
info.clone()
};
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
}
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
return Ok(());
}
let (plugin_file_path, priority) = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.enabled == enabled {
return Ok(());
}
info.enabled = true;
(info.path.clone(), info.priority)
};
let plugin_info =
match plugin_loader::load_plugin_from_path(&plugin_file_path, priority, true) {
Ok(runtime) => {
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
insert_runtime_by_priority(&mut runtimes, runtime.clone());
}
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
info.runtime_status = PluginRuntimeStatus::Loaded;
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
info.clone()
}
Err(err) => {
let err_title = "启用插件时加载失败";
let message = err.to_message();
tracing::error!(err_title, message);
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
}
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
info.runtime_status = PluginRuntimeStatus::LoadFailed;
info.clone()
}
};
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
Ok(())
}
#[instrument(
level = "error",
skip_all,
fields(plugin_path = plugin_path, priority = priority)
)]
pub fn set_plugin_priority(&self, plugin_path: &str, priority: i32) -> eyre::Result<()> {
let plugin_info = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.priority == priority {
return Ok(());
}
info.priority = priority;
info.clone()
};
{
let mut runtimes = self.runtimes.write();
if let Some(mut runtime) = remove_runtime_by_path(&mut runtimes, Path::new(plugin_path))
{
runtime.priority = priority;
insert_runtime_by_priority(&mut runtimes, runtime);
}
}
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
Ok(())
}
pub fn get_plugin_infos(&self) -> Vec<PluginInfo> {
self.infos.read().values().cloned().collect()
}
#[instrument(level = "error", skip_all)]
pub async fn run_hook(&self, mut context: HookContext<'_>) -> eyre::Result<()> {
let hook_point = context.hook_point();
let runtimes = self.runtimes.read().clone();
if runtimes.is_empty() {
return Ok(());
}
let app_version = self.app.package_info().version.to_string();
for runtime in &runtimes {
if !runtime.enabled || !runtime.should_run_hook(hook_point) {
continue;
}
let input = context.to_input(&app_version)?;
let output = match plugin_executor::execute_hook(runtime, &input).await {
Ok(output) => output,
Err(err) => match runtime.descriptor.failure_policy {
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
let err_title = "插件执行出错,按照 FailOpen 继续其他任务";
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
let err = err.wrap_err("插件执行出错,按照 FailClosed 中断任务");
return Err(err);
}
},
};
if let Err(err) = context.apply_output(output) {
match runtime.descriptor.failure_policy {
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
let err_title = "插件输出无效,按照 FailOpen 继续其他任务";
let message = err.to_message();
tracing::error!(err_title, message);
}
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
let err = err.wrap_err("插件输出无效,按照 FailClosed 中断任务");
return Err(err);
}
}
}
}
Ok(())
}
#[instrument(level = "error", skip_all)]
fn save_metadata(&self) -> eyre::Result<()> {
let app_data_dir = self.app.path().app_data_dir()?;
let plugin_json_path = app_data_dir.join("plugin.json");
let metadata_by_path: HashMap<String, PluginMetadata> = self
.infos
.read()
.clone()
.into_iter()
.map(|(plugin_path, info)| (plugin_path, info.into_metadata()))
.collect();
let json_string = serde_json::to_string_pretty(&metadata_by_path)?;
std::fs::write(plugin_json_path, json_string)?;
Ok(())
}
}
fn insert_runtime_by_priority(runtimes: &mut Vec<PluginRuntime>, runtime: PluginRuntime) {
let insert_idx = runtimes
.iter()
.position(|existing| existing.priority < runtime.priority)
.unwrap_or(runtimes.len());
runtimes.insert(insert_idx, runtime);
}
fn remove_runtime_by_path(
runtimes: &mut Vec<PluginRuntime>,
plugin_path: &Path,
) -> Option<PluginRuntime> {
let remove_idx = runtimes
.iter()
.position(|runtime| runtime.plugin_path == plugin_path)?;
Some(runtimes.remove(remove_idx))
}
+45
View File
@@ -0,0 +1,45 @@
use std::{ffi::c_char, path::PathBuf, sync::Arc};
use bilibili_video_downloader_plugin_api::v1::{HookPointV1, HostApiV1, PluginDescriptorV1};
use dlopen2::wrapper::{Container, WrapperApi};
#[derive(WrapperApi)]
pub struct PluginDylibApi {
#[dlopen2_name = "bilibili_video_downloader_plugin_descriptor_v1"]
descriptor: unsafe extern "C" fn() -> *const c_char,
#[dlopen2_name = "bilibili_video_downloader_plugin_on_hook_v1"]
on_hook: unsafe extern "C" fn(
input_ptr: *const u8,
input_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32,
#[dlopen2_name = "bilibili_video_downloader_plugin_free_buffer_v1"]
free_buffer: unsafe extern "C" fn(ptr: *mut u8, len: usize),
#[dlopen2_name = "bilibili_video_downloader_plugin_last_error_v1"]
last_error: unsafe extern "C" fn() -> *const c_char,
#[dlopen2_name = "bilibili_video_downloader_plugin_set_host_api_v1"]
set_host_api: unsafe extern "C" fn(api: *const HostApiV1) -> i32,
}
#[derive(Clone)]
pub struct PluginRuntime {
pub descriptor: PluginDescriptorV1,
pub plugin_path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub api: Arc<Container<PluginDylibApi>>,
}
impl PluginRuntime {
pub fn display_name(&self) -> String {
format!(
"{} ({}, v{})",
self.descriptor.name, self.descriptor.id, self.descriptor.version
)
}
pub fn should_run_hook(&self, hook: HookPointV1) -> bool {
self.descriptor.hooks.contains(&hook)
}
}
@@ -1,28 +1,49 @@
use serde::{Deserialize, Deserializer};
pub mod audio_quality;
pub mod available_media_formats;
pub mod bangumi_follow_info;
pub mod bangumi_info;
pub mod bangumi_media_url;
pub mod bangumi_media_url_v2;
pub mod cheese_info;
pub mod cheese_media_url;
pub mod codec_type;
pub mod create_download_task_params;
pub mod fav_folders;
pub mod fav_info;
pub mod get_available_media_formats_params;
pub mod get_bangumi_follow_info_params;
pub mod get_bangumi_info_params;
pub mod get_cheese_info_params;
pub mod get_fav_info_params;
pub mod get_history_info_params;
pub mod get_normal_info_params;
pub mod get_user_video_info_params;
pub mod log_level;
pub mod history_info;
pub mod log_metadata;
pub mod normal_info;
pub mod normal_media_url;
pub mod player_info;
pub mod plugin_info;
pub mod qrcode_data;
pub mod qrcode_status;
pub mod restart_download_task_params;
pub mod search_params;
pub mod search_result;
pub mod skip_segments;
pub mod subtitle;
pub mod tags;
pub mod user_info;
pub mod user_video_info;
pub mod video_quality;
pub mod watch_later_info;
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
T: Default + Deserialize<'de>,
D: Deserializer<'de>,
{
let opt = Option::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
+5
View File
@@ -7,6 +7,8 @@ use specta::Type;
Debug,
Clone,
Copy,
Hash,
Eq,
PartialEq,
Serialize,
Deserialize,
@@ -20,10 +22,13 @@ pub enum AudioQuality {
Unknown = -1,
#[serde(rename = "64K")]
#[num_enum(alternatives = [100008])]
Audio64K = 30216,
#[serde(rename = "132K")]
#[num_enum(alternatives = [100009])]
Audio132K = 30232,
#[serde(rename = "192K")]
#[num_enum(alternatives = [100010])]
Audio192K = 30280,
#[serde(rename = "Dolby")]
AudioDolby = 30250,
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::{
audio_quality::AudioQuality, codec_type::CodecType, video_quality::VideoQuality,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct AvailableMediaFormats {
pub video_qualities_and_codec_types: Vec<VideoQualityAndCodecType>,
pub audio_qualities: Vec<AudioQuality>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct VideoQualityAndCodecType {
pub video_quality: VideoQuality,
pub codec_type: CodecType,
}
+209
View File
@@ -0,0 +1,209 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct BangumiFollowInfo {
pub list: Vec<EpInBangumiFollow>,
pub pn: i64,
pub ps: i64,
pub total: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct EpInBangumiFollow {
pub season_id: i64,
pub media_id: i64,
pub season_type: i64,
pub season_type_name: String,
pub title: String,
pub cover: String,
pub total_count: i64,
pub is_finish: i64,
pub is_started: i64,
pub is_play: i64,
pub badge: String,
pub badge_type: i64,
pub rights: RightsInBangumiFollow,
pub stat: StatInBangumiFollow,
pub new_ep: NewEpInBangumiFollow,
pub rating: Option<RatingInBangumiFollow>,
pub square_cover: String,
pub season_status: i64,
pub season_title: String,
pub badge_ep: String,
pub media_attr: i64,
pub season_attr: i64,
pub evaluate: String,
pub areas: Vec<AreaInBangumiFollow>,
pub subtitle: String,
pub first_ep: i64,
pub can_watch: i64,
pub release_date_show: Option<String>,
pub series: SeriesInBangumiFollow,
pub publish: PublishInBangumiFollow,
pub mode: i64,
pub section: Vec<SectionInBangumiFollow>,
pub url: String,
pub badge_info: BadgeInfoInBangumiFollow,
pub renewal_time: Option<String>,
pub first_ep_info: FirstEpInfo,
pub formal_ep_count: Option<i64>,
pub short_url: String,
pub badge_infos: Option<BadgeInfos>,
pub season_version: Option<String>,
pub horizontal_cover_16_9: Option<String>,
pub horizontal_cover_16_10: Option<String>,
pub subtitle_14: Option<String>,
pub viewable_crowd_type: i64,
#[serde(default)]
pub producers: Vec<Producer>,
pub summary: String,
#[serde(default)]
pub styles: Vec<String>,
pub follow_status: i64,
pub is_new: i64,
pub progress: String,
pub both_follow: bool,
pub subtitle_25: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct RightsInBangumiFollow {
pub allow_review: Option<i64>,
pub allow_preview: Option<i64>,
pub is_selection: i64,
pub selection_style: i64,
pub is_rcmd: Option<i64>,
pub allow_bp_rank: Option<i64>,
pub allow_bp: Option<i64>,
pub allow_download: Option<i64>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct StatInBangumiFollow {
pub follow: i64,
pub view: i64,
pub danmaku: i64,
pub reply: i64,
pub coin: i64,
pub series_follow: Option<i64>,
pub series_view: Option<i64>,
pub likes: i64,
pub favorite: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct NewEpInBangumiFollow {
pub id: Option<i64>,
pub index_show: Option<String>,
pub cover: Option<String>,
pub title: Option<String>,
pub long_title: Option<String>,
pub pub_time: Option<String>,
pub duration: Option<i64>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct RatingInBangumiFollow {
pub score: f64,
pub count: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct AreaInBangumiFollow {
pub id: i64,
pub name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct SeriesInBangumiFollow {
pub series_id: Option<i64>,
pub title: Option<String>,
pub season_count: Option<i64>,
pub new_season_id: Option<i64>,
pub series_ord: Option<i64>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct PublishInBangumiFollow {
pub pub_time: String,
pub pub_time_show: String,
pub release_date: String,
pub release_date_show: String,
pub pub_time_show_db: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct SectionInBangumiFollow {
pub section_id: i64,
pub season_id: i64,
pub limit_group: i64,
pub watch_platform: i64,
pub copyright: String,
pub ban_area_show: i64,
pub episode_ids: Vec<i64>,
#[serde(rename = "type")]
pub type_field: Option<i64>,
pub title: Option<String>,
pub attr: Option<i64>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct BadgeInfoInBangumiFollow {
pub text: Option<String>,
pub bg_color: String,
pub bg_color_night: String,
pub img: Option<String>,
pub multi_img: MultiImg,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct MultiImg {
pub color: String,
pub medium_remind: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct FirstEpInfo {
pub id: i64,
pub cover: String,
pub title: String,
pub long_title: Option<String>,
pub pub_time: String,
pub duration: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct BadgeInfos {
pub vip_or_pay: Option<VipOrPay>,
pub content_attr: Option<ContentAttr>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct VipOrPay {
pub text: String,
pub bg_color: String,
pub bg_color_night: String,
pub img: String,
pub multi_img: MultiImg,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct ContentAttr {
pub text: String,
pub bg_color: String,
pub bg_color_night: String,
pub img: String,
pub multi_img: MultiImg,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Producer {
pub mid: i64,
#[serde(rename = "type")]
pub type_field: i64,
pub is_contribute: Option<i64>,
pub title: String,
}
+46 -16
View File
@@ -1,13 +1,15 @@
use anyhow::{anyhow, Context};
use eyre::{OptionExt, eyre};
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct BangumiInfo {
pub activity: Activity,
pub actors: String,
pub alias: String,
pub areas: Vec<Area>,
pub areas: Vec<AreaInBangumi>,
pub bkg_cover: String,
pub cover: String,
pub delivery_fragment_video: bool,
@@ -24,15 +26,15 @@ pub struct BangumiInfo {
pub payment: Option<PaymentInBangumi>,
pub play_strategy: Option<PlayStrategy>,
pub positive: Positive,
pub publish: Publish,
pub rating: Option<Rating>,
pub publish: PublishInBangumi,
pub rating: Option<RatingInBangumi>,
pub record: String,
pub rights: RightsInBangumi,
pub season_id: i64,
pub season_title: String,
pub seasons: Vec<Season>,
pub section: Option<Vec<SectionInBangumi>>,
pub series: Series,
pub series: SeriesInBangumi,
pub share_copy: String,
pub share_sub_title: String,
pub share_url: String,
@@ -54,7 +56,8 @@ pub struct BangumiInfo {
impl BangumiInfo {
#[allow(clippy::cast_possible_wrap)]
pub fn get_episode_with_order(&self, ep_id: i64) -> anyhow::Result<(&EpInBangumi, i64)> {
#[instrument(level = "error", skip_all)]
pub fn get_episode_with_order(&self, ep_id: i64) -> eyre::Result<(&EpInBangumi, i64)> {
let episode_with_order = self
.episodes
.iter()
@@ -68,19 +71,19 @@ impl BangumiInfo {
} else {
// 如果在正片中没有找到对应的ep_id,则在section中查找
let Some(sections) = &self.section else {
return Err(anyhow!("找不到对应的ep_id为`{ep_id}`的番剧"));
return Err(eyre!("section为None"));
};
let section_index = sections
.iter()
.position(|s| s.episodes.iter().any(|e| e.id == ep_id))
.context(format!("找不到含有ep_id为`{ep_id}`的ep的section"))?;
.ok_or_eyre("找不到含有对应ep_id的section")?;
sections[section_index]
.episodes
.iter()
.enumerate()
.map(|(i, e)| (e, i as i64 + 1))
.find(|(e, _)| e.id == ep_id)
.context(format!("在section中找不到ep_id为`{ep_id}`的ep"))?
.ok_or_eyre("在section中找不到ep_id对应的ep")?
};
Ok(episode_with_order)
@@ -88,6 +91,7 @@ impl BangumiInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Activity {
pub head_bg_url: String,
pub id: i64,
@@ -95,17 +99,19 @@ pub struct Activity {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Area {
#[serde(default)]
pub struct AreaInBangumi {
pub id: i64,
pub name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct EpInBangumi {
pub aid: i64,
pub badge: String,
pub badge_info: BadgeInfo,
pub badge_info: BadgeInfoInBangumi,
pub badge_type: Option<i64>,
pub bvid: Option<String>,
pub cid: i64,
@@ -140,13 +146,15 @@ pub struct EpInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct BadgeInfo {
#[serde(default)]
pub struct BadgeInfoInBangumi {
pub bg_color: String,
pub bg_color_night: String,
pub text: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DimensionInBangumi {
pub height: i64,
pub rotate: i64,
@@ -154,6 +162,7 @@ pub struct DimensionInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct RightsInBangumiEp {
pub allow_dm: i64,
pub allow_download: i64,
@@ -161,30 +170,35 @@ pub struct RightsInBangumiEp {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Skip {
pub ed: Ed,
pub op: Op,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Ed {
pub end: i64,
pub start: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Op {
pub end: i64,
pub start: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct IconFont {
pub name: String,
pub text: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct NewEp {
pub desc: String,
pub id: i64,
@@ -193,6 +207,7 @@ pub struct NewEp {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PaymentInBangumi {
pub discount: i64,
pub pay_type: PayType,
@@ -207,6 +222,7 @@ pub struct PaymentInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PayType {
pub allow_discount: i64,
pub allow_pack: i64,
@@ -217,18 +233,21 @@ pub struct PayType {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PlayStrategy {
pub strategies: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Positive {
pub id: i64,
pub title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Publish {
#[serde(default)]
pub struct PublishInBangumi {
pub is_finish: i64,
pub is_started: i64,
pub pub_time: String,
@@ -238,12 +257,14 @@ pub struct Publish {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Rating {
#[serde(default)]
pub struct RatingInBangumi {
pub count: i64,
pub score: f64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct RightsInBangumi {
pub allow_bp: i64,
pub allow_bp_rank: i64,
@@ -263,10 +284,11 @@ pub struct RightsInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct Season {
pub badge: String,
pub badge_info: BadgeInfo,
pub badge_info: BadgeInfoInBangumi,
pub badge_type: i64,
pub cover: String,
pub enable_vt: bool,
@@ -282,6 +304,7 @@ pub struct Season {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct NewEpInSeason {
pub cover: String,
pub id: i64,
@@ -289,6 +312,7 @@ pub struct NewEpInSeason {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInSeason {
pub favorites: i64,
pub series_follow: i64,
@@ -297,19 +321,22 @@ pub struct StatInSeason {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct Series {
pub struct SeriesInBangumi {
pub display_type: i64,
pub series_id: i64,
pub series_title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Show {
pub wide_screen: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInBangumi {
pub coins: i64,
pub danmakus: i64,
@@ -324,6 +351,7 @@ pub struct StatInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UpInfoInBangumi {
pub avatar: String,
pub mid: i64,
@@ -331,6 +359,7 @@ pub struct UpInfoInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UserStatusInBangumi {
pub area_limit: i64,
pub ban_area_show: i64,
@@ -343,6 +372,7 @@ pub struct UserStatusInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SectionInBangumi {
pub attr: i64,
pub episodes: Vec<EpInBangumi>,
+54
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::{
audio_quality::AudioQuality,
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct BangumiMediaUrl {
pub accept_format: String,
pub code: i64,
@@ -38,12 +44,14 @@ pub struct BangumiMediaUrl {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct RecordInfo {
pub record_icon: String,
pub record: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SupportFormatInBangumi {
pub display_desc: String,
pub has_preview: bool,
@@ -60,6 +68,7 @@ pub struct SupportFormatInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DashInBangumi {
pub duration: u64,
pub min_buffer_time: f64,
@@ -68,6 +77,7 @@ pub struct DashInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct MediaInBangumi {
pub start_with_sap: i64,
pub bandwidth: i64,
@@ -87,12 +97,14 @@ pub struct MediaInBangumi {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SegmentBaseInBangumi {
pub initialization: String,
pub index_range: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct ClipInfoList {
#[serde(rename = "materialNo")]
pub material_no: i64,
@@ -105,12 +117,14 @@ pub struct ClipInfoList {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DurlInBangumi {
pub durl: Vec<DurlDetailInBangumi>,
pub quality: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DurlDetailInBangumi {
pub size: i64,
pub ahead: String,
@@ -121,3 +135,43 @@ pub struct DurlDetailInBangumi {
pub order: i64,
pub md5: String,
}
impl BangumiMediaUrl {
pub fn to_get_available_media_formats_result(&self) -> AvailableMediaFormats {
let mut video_qualities_and_codec_types: Vec<VideoQualityAndCodecType> = Vec::new();
let mut audio_qualities: Vec<AudioQuality> = Vec::new();
if let Some(dash) = &self.dash {
for media in &dash.video {
let video_qualities_and_codec_type = VideoQualityAndCodecType {
video_quality: media.id.into(),
codec_type: media.codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec_type);
}
}
for durl in &self.durls {
if !durl.durl.is_empty() {
let video_qualities_and_codec_type = VideoQualityAndCodecType {
video_quality: durl.quality.into(),
codec_type: self.video_codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec_type);
}
}
if let Some(medias) = self.dash.as_ref().and_then(|dash| dash.audio.as_ref()) {
for media in medias {
audio_qualities.push(media.id.into());
}
}
AvailableMediaFormats {
video_qualities_and_codec_types,
audio_qualities,
}
}
}
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::bangumi_media_url::BangumiMediaUrl;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct BangumiMediaUrlV2 {
pub play_view_business_info: PlayViewBusinessInfo,
pub video_info: BangumiMediaUrl,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PlayViewBusinessInfo {
pub episode_info: EpisodeInfoInBangumi,
pub season_info: SeasonInfoInBangumi,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct EpisodeInfoInBangumi {
pub aid: i64,
pub bvid: String,
pub cid: i64,
pub delivery_business_fragment_video: bool,
pub delivery_fragment_video: bool,
pub ep_id: i64,
pub ep_status: i64,
pub interaction: Interaction,
pub long_title: String,
pub title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Interaction {
pub interaction: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SeasonInfoInBangumi {
pub season_id: i64,
pub season_type: i64,
}
+25 -1
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct CheeseInfo {
pub abtest_info: AbtestInfo,
@@ -54,11 +55,13 @@ pub struct CheeseInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct AbtestInfo {
pub style_abtest: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Brief {
pub content: String,
pub img: Vec<Img>,
@@ -68,23 +71,27 @@ pub struct Brief {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Img {
pub aspect_ratio: f64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Consulting {
pub consulting_flag: bool,
pub consulting_url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Cooperation {
pub link: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct EpPage {
pub next: bool,
pub num: i64,
@@ -93,6 +100,7 @@ pub struct EpPage {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct EpTag {
pub part_preview_tag: String,
@@ -101,6 +109,7 @@ pub struct EpTag {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::struct_field_names)]
pub struct EpInCheese {
@@ -130,6 +139,7 @@ pub struct EpInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Faq {
pub content: String,
pub link: String,
@@ -137,28 +147,32 @@ pub struct Faq {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Faq1 {
pub items: Vec<Faq1Item>,
pub title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Faq1Item {
pub answer: String,
pub question: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PaidJump {
pub jump_url_for_app: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Payment {
pub bp_enough: i64,
pub desc: String,
pub my_bp: i64,
pub my_bp: f64,
pub pay_shade: String,
pub price: f64,
pub price_format: String,
@@ -168,6 +182,7 @@ pub struct Payment {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PreviewedPurchaseNote {
pub long_watch_text: String,
pub pay_text: String,
@@ -177,6 +192,7 @@ pub struct PreviewedPurchaseNote {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PurchaseFormatNote {
pub content_list: Vec<ContentList>,
pub link: String,
@@ -184,6 +200,7 @@ pub struct PurchaseFormatNote {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct ContentList {
pub bold: bool,
pub content: String,
@@ -191,6 +208,7 @@ pub struct ContentList {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PurchaseNote {
pub content: String,
pub link: String,
@@ -198,12 +216,14 @@ pub struct PurchaseNote {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PurchaseProtocol {
pub link: String,
pub title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct RecommendSeason {
pub cover: String,
pub ep_count: String,
@@ -215,6 +235,7 @@ pub struct RecommendSeason {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInCheese {
pub play: i64,
pub play_desc: String,
@@ -222,6 +243,7 @@ pub struct StatInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UpInfoInCheese {
pub avatar: String,
pub brief: String,
@@ -236,6 +258,7 @@ pub struct UpInfoInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PendantInCheese {
pub image: String,
pub name: String,
@@ -243,6 +266,7 @@ pub struct PendantInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UserStatusInCheese {
pub bp: i64,
pub expire_at: i64,
+56
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::{
audio_quality::AudioQuality,
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CheeseMediaUrl {
pub accept_format: String,
pub code: i64,
@@ -17,6 +23,7 @@ pub struct CheeseMediaUrl {
pub seek_type: String,
pub from: String,
pub video_codecid: i64,
pub is_drm: bool,
pub no_rexcode: i64,
pub format: String,
pub support_formats: Vec<SupportFormatInCheese>,
@@ -32,16 +39,19 @@ pub struct CheeseMediaUrl {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PlayViewBusinessInfo {
pub user_status: UserStatusInCheeseUrl,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UserStatusInCheeseUrl {
pub watch_progress: WatchProgress,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct WatchProgress {
pub current_watch_progress: i64,
@@ -51,6 +61,7 @@ pub struct WatchProgress {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SupportFormatInCheese {
pub display_desc: String,
pub superscript: String,
@@ -63,6 +74,7 @@ pub struct SupportFormatInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DashInCheese {
pub duration: u64,
pub min_buffer_time: f64,
@@ -71,6 +83,7 @@ pub struct DashInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct MediaInCheese {
pub start_with_sap: i64,
pub bandwidth: i64,
@@ -90,18 +103,21 @@ pub struct MediaInCheese {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SegmentBaseInCheese {
pub initialization: String,
pub index_range: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DurlInCheese {
pub durl: Vec<DurlDetailInCheese>,
pub quality: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DurlDetailInCheese {
pub size: i64,
pub ahead: String,
@@ -112,3 +128,43 @@ pub struct DurlDetailInCheese {
pub order: i64,
pub md5: String,
}
impl CheeseMediaUrl {
pub fn to_get_available_media_formats_result(&self) -> AvailableMediaFormats {
let mut video_qualities_and_codec_types: Vec<VideoQualityAndCodecType> = Vec::new();
let mut audio_qualities: Vec<AudioQuality> = Vec::new();
if let Some(dash) = &self.dash {
for media in &dash.video {
let video_qualities_and_codec_type = VideoQualityAndCodecType {
video_quality: media.id.into(),
codec_type: media.codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec_type);
}
}
for durl in &self.durls {
if !durl.durl.is_empty() {
let video_qualities_and_codec_type = VideoQualityAndCodecType {
video_quality: durl.quality.into(),
codec_type: self.video_codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec_type);
}
}
if let Some(medias) = self.dash.as_ref().and_then(|dash| dash.audio.as_ref()) {
for media in medias {
audio_qualities.push(media.id.into());
}
}
AvailableMediaFormats {
video_qualities_and_codec_types,
audio_qualities,
}
}
}
+3
View File
@@ -7,6 +7,8 @@ use specta::Type;
Debug,
Clone,
Copy,
Hash,
Eq,
PartialEq,
Serialize,
Deserialize,
@@ -19,6 +21,7 @@ use specta::Type;
pub enum CodecType {
#[default]
Unknown = -1,
Audio = 0,
AVC = 7,
HEVC = 12,
@@ -11,18 +11,21 @@ pub enum CreateDownloadTaskParams {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CreateNormalDownloadTaskParams {
pub info: NormalInfo,
pub aid_cid_pairs: Vec<(i64, Option<i64>)>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CreateBangumiDownloadTaskParams {
pub ep_ids: Vec<i64>,
pub info: BangumiInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CreateCheeseDownloadTaskParams {
pub ep_ids: Vec<i64>,
pub info: CheeseInfo,
+2
View File
@@ -2,12 +2,14 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct FavFolders {
pub count: i64,
pub list: Vec<Folder>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Folder {
pub id: i64,
pub fid: i64,
+8
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct FavInfo {
pub info: Info,
pub medias: Option<Vec<MediaInFav>>,
@@ -10,6 +11,7 @@ pub struct FavInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct Info {
pub id: i64,
@@ -34,6 +36,7 @@ pub struct Info {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Upper {
pub mid: i64,
pub name: String,
@@ -44,6 +47,7 @@ pub struct Upper {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CntInfo {
pub collect: i64,
pub play: i64,
@@ -52,6 +56,7 @@ pub struct CntInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct MediaInFav {
pub id: i64,
@@ -76,6 +81,7 @@ pub struct MediaInFav {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UpperInMedia {
pub mid: i64,
pub name: String,
@@ -84,6 +90,7 @@ pub struct UpperInMedia {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CntInfoInMedia {
pub collect: i64,
pub play: i64,
@@ -95,6 +102,7 @@ pub struct CntInfoInMedia {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Ugc {
pub first_cid: i64,
}
@@ -0,0 +1,25 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub enum GetAvailableMediaFormatsParams {
Normal(GetNormalAvailableMediaFormatsParams),
Bangumi(GetBangumiAvailableMediaFormatsParams),
Cheese(GetCheeseAvailableMediaFormatsParams),
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetNormalAvailableMediaFormatsParams {
pub bvid: String,
pub cid: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetBangumiAvailableMediaFormatsParams {
pub cid: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetCheeseAvailableMediaFormatsParams {
pub ep_id: i64,
}
@@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetBangumiFollowInfoParams {
pub vmid: i64,
/// 1: 番剧 2: 电视剧或电影
#[serde(rename = "type")]
pub type_field: i64,
pub pn: i64,
// 0: 全部 1: 想看 2: 在看 3: 看过
pub follow_status: i64,
}
@@ -6,3 +6,19 @@ pub enum GetBangumiInfoParams {
EpId(i64),
SeasonId(i64),
}
impl GetBangumiInfoParams {
pub fn get_ep_id(&self) -> Option<i64> {
match self {
Self::EpId(ep_id) => Some(*ep_id),
Self::SeasonId(_) => None,
}
}
pub fn get_season_id(&self) -> Option<i64> {
match self {
Self::EpId(_) => None,
Self::SeasonId(season_id) => Some(*season_id),
}
}
}
@@ -6,3 +6,19 @@ pub enum GetCheeseInfoParams {
EpId(i64),
SeasonId(i64),
}
impl GetCheeseInfoParams {
pub fn get_ep_id(&self) -> Option<i64> {
match self {
Self::EpId(ep_id) => Some(*ep_id),
Self::SeasonId(_) => None,
}
}
pub fn get_season_id(&self) -> Option<i64> {
match self {
Self::EpId(_) => None,
Self::SeasonId(season_id) => Some(*season_id),
}
}
}
@@ -0,0 +1,38 @@
use num_enum::{FromPrimitive, IntoPrimitive};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetHistoryInfoParams {
pub pn: i64,
pub keyword: String,
pub add_time_start: i64,
pub add_time_end: i64,
pub arc_max_duration: i64,
pub arc_min_duration: i64,
pub device_type: DeviceType,
}
#[derive(
Default,
Debug,
Clone,
Copy,
Hash,
Eq,
PartialEq,
Serialize,
Deserialize,
Type,
IntoPrimitive,
FromPrimitive,
)]
#[repr(i64)]
pub enum DeviceType {
#[default]
All = 0,
PC = 1,
Mobile = 2,
Pad = 3,
TV = 4,
}
@@ -6,3 +6,19 @@ pub enum GetNormalInfoParams {
Bvid(String),
Aid(i64),
}
impl GetNormalInfoParams {
pub fn get_bvid(&self) -> Option<String> {
match self {
Self::Bvid(bvid) => Some(bvid.clone()),
Self::Aid(_) => None,
}
}
pub fn get_aid(&self) -> Option<i64> {
match self {
Self::Bvid(_) => None,
Self::Aid(aid) => Some(*aid),
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct HistoryInfo {
pub has_more: bool,
pub page: PageInHistory,
pub list: Option<Vec<HistoryDetail>>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PageInHistory {
pub pn: i64,
pub total: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct HistoryDetail {
pub title: String,
pub long_title: String,
pub cover: String,
pub uri: String,
pub history: History,
pub videos: i64,
pub author_name: String,
pub author_face: String,
pub author_mid: i64,
pub view_at: i64,
pub progress: i64,
pub badge: String,
pub show_title: String,
pub duration: i64,
pub total: i64,
pub new_desc: String,
pub is_finish: i64,
pub is_fav: i64,
pub kid: i64,
pub tag_name: String,
pub live_status: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct History {
pub oid: i64,
pub epid: i64,
pub bvid: String,
pub page: i64,
pub cid: i64,
pub part: String,
pub business: String,
pub dt: i64,
}
-16
View File
@@ -1,16 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub enum LogLevel {
#[serde(rename = "TRACE")]
Trace,
#[serde(rename = "DEBUG")]
Debug,
#[serde(rename = "INFO")]
Info,
#[serde(rename = "WARN")]
Warn,
#[serde(rename = "ERROR")]
Error,
}
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct LogMetadata {
pub timestamp: String,
pub level: LogLevel,
pub fields: HashMap<String, serde_json::Value>,
pub target: String,
pub filename: String,
pub line_number: i64,
#[serde(default)]
pub span: serde_json::Value,
#[serde(default)]
pub spans: Vec<LogSpan>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct LogSpan {
pub name: String,
#[serde(flatten)]
pub other_fields: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub enum LogLevel {
#[serde(rename = "TRACE")]
Trace,
#[serde(rename = "DEBUG")]
Debug,
#[serde(rename = "INFO")]
Info,
#[serde(rename = "WARN")]
Warn,
#[serde(rename = "ERROR")]
Error,
}
+23
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct NormalInfo {
pub bvid: String,
@@ -52,6 +53,7 @@ pub struct NormalInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DescV2 {
pub raw_text: String,
#[serde(rename = "type")]
@@ -60,6 +62,7 @@ pub struct DescV2 {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Rights {
pub bp: i64,
pub elec: i64,
@@ -82,6 +85,7 @@ pub struct Rights {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct OwnerInNormal {
pub mid: i64,
pub name: String,
@@ -89,6 +93,7 @@ pub struct OwnerInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInNormal {
pub aid: i64,
pub view: i64,
@@ -106,6 +111,7 @@ pub struct StatInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct ArgueInfo {
pub argue_msg: String,
@@ -114,6 +120,7 @@ pub struct ArgueInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Dimension {
pub width: i64,
pub height: i64,
@@ -121,6 +128,7 @@ pub struct Dimension {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct PageInNormal {
pub cid: i64,
@@ -135,12 +143,14 @@ pub struct PageInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SubtitleInNormal {
pub allow_submit: bool,
pub list: Vec<SubtitleDetailInNormal>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SubtitleDetailInNormal {
pub id: i64,
pub lan: String,
@@ -155,16 +165,19 @@ pub struct SubtitleDetailInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UserGarb {
pub url_image_ani_cut: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct HonorReply {
pub honor: Option<Vec<Honor>>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Honor {
pub aid: i64,
#[serde(rename = "type")]
@@ -174,6 +187,7 @@ pub struct Honor {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UgcSeason {
pub id: i64,
pub title: String,
@@ -191,6 +205,7 @@ pub struct UgcSeason {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SectionInNormal {
pub season_id: i64,
pub id: i64,
@@ -201,6 +216,7 @@ pub struct SectionInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct EpInNormal {
pub season_id: i64,
pub section_id: i64,
@@ -216,6 +232,7 @@ pub struct EpInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Arc {
pub aid: i64,
pub videos: i64,
@@ -244,6 +261,7 @@ pub struct Arc {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Author {
pub mid: i64,
pub name: String,
@@ -251,6 +269,7 @@ pub struct Author {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInNormalEp {
pub aid: i64,
pub view: i64,
@@ -270,6 +289,7 @@ pub struct StatInNormalEp {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct StatInNormalSeason {
pub season_id: i64,
pub view: i64,
@@ -286,6 +306,7 @@ pub struct StatInNormalSeason {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PageInNormalEp {
pub cid: i64,
pub page: i64,
@@ -298,6 +319,7 @@ pub struct PageInNormalEp {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct RightsInNormalEp {
pub bp: i64,
pub elec: i64,
@@ -315,6 +337,7 @@ pub struct RightsInNormalEp {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Staff {
pub mid: i64,
pub title: String,
+74 -1
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::{
audio_quality::AudioQuality,
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct NormalMediaUrl {
pub from: String,
pub result: String,
@@ -15,6 +21,7 @@ pub struct NormalMediaUrl {
pub video_codecid: i64,
pub seek_param: String,
pub seek_type: String,
pub durl: Vec<DurlInNormal>,
pub dash: DashInNormal,
pub support_formats: Vec<SupportFormatInNormal>,
pub last_play_time: i64,
@@ -23,6 +30,7 @@ pub struct NormalMediaUrl {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DashInNormal {
pub duration: u64,
pub min_buffer_time: f64,
@@ -33,12 +41,26 @@ pub struct DashInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct DurlInNormal {
pub order: i64,
pub length: i64,
pub size: i64,
pub ahead: String,
pub vhead: String,
pub url: String,
pub backup_url: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Flac {
pub display: bool,
pub audio: Option<MediaInNormal>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct MediaInNormal {
pub id: i64,
pub start_with_sap: i64,
@@ -56,12 +78,14 @@ pub struct MediaInNormal {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SegmentBaseInNormal {
pub initialization: String,
pub index_range: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Dolby {
#[serde(rename = "type")]
pub type_field: i64,
@@ -69,16 +93,65 @@ pub struct Dolby {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SupportFormatInNormal {
pub quality: i64,
pub format: String,
pub new_description: String,
pub display_desc: String,
pub superscript: String,
pub codecs: Vec<String>,
pub codecs: Option<Vec<String>>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct PlayConf {
pub is_new_description: bool,
}
impl NormalMediaUrl {
pub fn to_get_available_media_formats_result(&self) -> AvailableMediaFormats {
let mut video_qualities_and_codec_types: Vec<VideoQualityAndCodecType> = Vec::new();
let mut audio_qualities: Vec<AudioQuality> = Vec::new();
for media in &self.dash.video {
let video_qualities_and_codec = VideoQualityAndCodecType {
video_quality: media.id.into(),
codec_type: media.codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec);
}
if !self.durl.is_empty() {
let video_qualities_and_codec = VideoQualityAndCodecType {
video_quality: self.quality.into(),
codec_type: self.video_codecid.into(),
};
video_qualities_and_codec_types.push(video_qualities_and_codec);
}
if let Some(medias) = &self.dash.audio {
for media in medias {
audio_qualities.push(media.id.into());
}
}
if let Some(medias) = &self.dash.dolby.audio {
for media in medias {
audio_qualities.push(media.id.into());
}
}
let flac = self.dash.flac.as_ref();
if let Some(media) = flac.and_then(|flac| flac.audio.as_ref()) {
audio_qualities.push(media.id.into());
}
AvailableMediaFormats {
video_qualities_and_codec_types,
audio_qualities,
}
}
}
+18
View File
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use super::deserialize_null_default;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct PlayerInfo {
pub aid: i64,
@@ -29,6 +32,7 @@ pub struct PlayerInfo {
pub online_count: i64,
pub need_login_subtitle: bool,
pub subtitle: SubtitleInPlayerInfo,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub view_points: Vec<ViewPoint>,
pub preview_toast: String,
pub options: Options,
@@ -45,6 +49,7 @@ pub struct PlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct IpInfo {
pub ip: String,
pub zone_ip: String,
@@ -55,6 +60,7 @@ pub struct IpInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct LevelInfoInPlayerInfo {
pub current_level: i64,
pub current_min: i64,
@@ -64,6 +70,7 @@ pub struct LevelInfoInPlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct VipInPlayerInfo {
#[serde(rename = "type")]
@@ -84,6 +91,7 @@ pub struct VipInPlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_field_names)]
pub struct LabelInPlayerInfo {
pub path: String,
@@ -101,14 +109,17 @@ pub struct LabelInPlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct AvatarIcon {
pub icon_resource: IconResource,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct IconResource {}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SubtitleInPlayerInfo {
pub allow_submit: bool,
pub lan: String,
@@ -117,6 +128,7 @@ pub struct SubtitleInPlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SubtitleDetailInPlayerInfo {
pub id: i64,
pub lan: String,
@@ -131,6 +143,7 @@ pub struct SubtitleDetailInPlayerInfo {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct ViewPoint {
#[serde(rename = "type")]
pub type_field: i64,
@@ -144,12 +157,14 @@ pub struct ViewPoint {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Options {
pub is_360: bool,
pub without_vip: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct OnlineSwitch {
pub enable_gray_dash_playback: String,
pub new_broadcast: String,
@@ -158,17 +173,20 @@ pub struct OnlineSwitch {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Fawkes {
pub config_version: i64,
pub ff_version: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct ShowSwitch {
pub long_progress: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct ElecHighLevel {
pub privilege_type: i64,
pub title: String,
+131
View File
@@ -0,0 +1,131 @@
use std::path::PathBuf;
use bilibili_video_downloader_plugin_api::v1::{
HookPointV1, PluginDescriptorV1, PluginFailurePolicy,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::plugin::plugin_types::PluginRuntime;
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginHookPoint {
#[default]
BeforeVideoProcess,
AfterPrepare,
OnCompleted,
}
impl From<HookPointV1> for PluginHookPoint {
fn from(value: HookPointV1) -> Self {
match value {
HookPointV1::BeforeVideoProcess => Self::BeforeVideoProcess,
HookPointV1::AfterPrepare => Self::AfterPrepare,
HookPointV1::OnCompleted => Self::OnCompleted,
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginFailurePolicyInfo {
#[default]
FailOpen,
FailClosed,
}
impl From<PluginFailurePolicy> for PluginFailurePolicyInfo {
fn from(value: PluginFailurePolicy) -> Self {
match value {
PluginFailurePolicy::FailOpen => Self::FailOpen,
PluginFailurePolicy::FailClosed => Self::FailClosed,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginDescriptorInfo {
pub sdk_api_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub hooks: Vec<PluginHookPoint>,
pub failure_policy: PluginFailurePolicyInfo,
pub description: String,
}
impl PluginDescriptorInfo {
pub fn from_descriptor(descriptor: &PluginDescriptorV1) -> Self {
Self {
sdk_api_version: descriptor.sdk_api_version,
id: descriptor.id.clone(),
name: descriptor.name.clone(),
version: descriptor.version.clone(),
hooks: descriptor
.hooks
.iter()
.copied()
.map(PluginHookPoint::from)
.collect(),
failure_policy: descriptor.failure_policy.into(),
description: descriptor.description.clone(),
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginMetadata {
pub path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub descriptor: PluginDescriptorInfo,
}
impl PluginMetadata {
pub fn from_plugin_runtime(runtime: &PluginRuntime) -> Self {
Self {
path: runtime.plugin_path.clone(),
enabled: runtime.enabled,
priority: runtime.priority,
descriptor: PluginDescriptorInfo::from_descriptor(&runtime.descriptor),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginRuntimeStatus {
#[default]
Unknown,
Loaded,
Disabled,
LoadFailed,
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginInfo {
pub path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub descriptor: PluginDescriptorInfo,
pub runtime_status: PluginRuntimeStatus,
}
impl PluginInfo {
pub fn from_metadata(metadata: PluginMetadata, runtime_status: PluginRuntimeStatus) -> Self {
Self {
path: metadata.path,
enabled: metadata.enabled,
priority: metadata.priority,
descriptor: metadata.descriptor,
runtime_status,
}
}
pub fn into_metadata(self) -> PluginMetadata {
PluginMetadata {
path: self.path,
enabled: self.enabled,
priority: self.priority,
descriptor: self.descriptor,
}
}
}
+1
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, Type)]
#[serde(default)]
pub struct QrcodeData {
pub url: String,
pub qrcode_key: String,
+2
View File
@@ -2,8 +2,10 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct QrcodeStatus {
pub url: String,
pub sessdata: String,
pub refresh_token: String,
pub timestamp: i64,
pub code: i64,
@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::types::{
audio_quality::AudioQuality, codec_type::CodecType, video_quality::VideoQuality,
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct RestartDownloadTaskParams {
pub task_id: String,
pub video_task_selected: bool,
pub audio_task_selected: bool,
pub merge_selected: bool,
pub embed_chapter_selected: bool,
pub embed_skip_selected: bool,
pub subtitle_task_selected: bool,
pub xml_danmaku_selected: bool,
pub ass_danmaku_selected: bool,
pub json_danmaku_selected: bool,
pub cover_task_selected: bool,
pub nfo_task_selected: bool,
pub json_task_selected: bool,
pub video_quality: VideoQuality,
pub codec_type: CodecType,
pub audio_quality: AudioQuality,
}
+6
View File
@@ -9,6 +9,7 @@ use super::{
user_video_info::UserVideoInfo,
};
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub enum SearchResult {
Normal(NormalSearchResult),
@@ -19,22 +20,27 @@ pub enum SearchResult {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct NormalSearchResult(pub NormalInfo);
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct BangumiSearchResult {
pub ep: Option<bangumi_info::EpInBangumi>,
pub info: BangumiInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CheeseSearchResult {
pub ep: Option<cheese_info::EpInCheese>,
pub info: CheeseInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct UserVideoSearchResult(pub UserVideoInfo);
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct FavSearchResult(pub FavInfo);
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::downloader::chapter_segments::ChapterSegment;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SkipSegments(pub Vec<SkipSegment>);
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct SkipSegment {
pub cid: String,
pub category: String,
#[serde(rename = "actionType")]
pub action_type: String,
pub segment: Vec<f64>,
#[serde(rename = "UUID")]
pub uuid: String,
#[serde(rename = "videoDuration")]
pub video_duration: f64,
pub locked: i64,
pub votes: i64,
pub description: String,
}
impl SkipSegment {
fn get_title(&self) -> Option<String> {
match self.category.as_str() {
"sponsor" => Some("广告".to_string()),
"selfpromo" => Some("无偿/自我推广".to_string()),
"exclusive_access" => Some("柔性推广/品牌合作".to_string()),
"interaction" => Some("三连/订阅提醒".to_string()),
"poi_highlight" => Some("精彩时刻/重点".to_string()),
"intro" => Some("过场/开场动画".to_string()),
"outro" => Some("鸣谢/结束画面".to_string()),
"preview" => Some("回顾/概要".to_string()),
_ => None,
}
}
#[allow(clippy::cast_possible_truncation)]
pub fn into_chapter_segment(self) -> Option<ChapterSegment> {
if self.segment.len() < 2 {
return None; // 确保 segment 包含开始和结束时间
}
Some(ChapterSegment {
title: self.get_title()?,
start: self.segment[0] as i64,
end: self.segment[1] as i64,
})
}
}
+2
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Subtitle {
pub font_size: f64,
pub font_color: String,
@@ -13,6 +14,7 @@ pub struct Subtitle {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct Body {
pub from: f64,
pub to: f64,

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