67 Commits

Author SHA1 Message Date
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
19df52b4bc Merge pull request #9 from lanyeeee/develop
Develop
2025-09-03 07:02:02 +08:00
128 changed files with 8180 additions and 1876 deletions

View File

@@ -20,7 +20,7 @@ body:
id: reproduction
attributes:
label: 复现步骤
description: 这是整个issue中**最重要**的部分
description: 这是整个issue中**最重要**的部分,请参考[这个issue](https://github.com/lanyeeee/bilibili-video-downloader/issues/1)认真填写否则开发者会假装看不见甚至直接关闭issue
placeholder: |
复现步骤是影响issue处理效率的最大因素
没有详细的复现步骤将导致问题难以被定位,开发者需要花费大量时间来回沟通以定位问题

17
.github/pull_request_template.md vendored Normal file
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
.github/workflows/check-backend.yml vendored Normal file
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
.github/workflows/check-format.yml vendored Normal file
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
.github/workflows/check-frontend.yml vendored Normal file
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

35
.prettierignore Normal file
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/

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

144
README.md
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
auto-imports.d.ts vendored
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')
}

53
components.d.ts vendored
View File

@@ -1,53 +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']
IconButton: typeof import('./src/components/IconButton.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']
NDatePicker: typeof import('naive-ui')['NDatePicker']
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']
NPopover: typeof import('naive-ui')['NPopover']
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']
}
}

View File

@@ -20,4 +20,7 @@ export default defineConfig([
},
},
},
{
ignores: ['src/bindings.ts', 'src/vite-env.d.ts'],
},
])

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,10 +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",
"virtua": "^0.48.6",
"vue": "^3.5.13",
"vue-draggable-plus": "^0.6.0"
"vue-draggable-plus": "^0.6.0",
"z-vue-scan": "^0.0.35"
},
"devDependencies": {
"@eslint/js": "^9.30.1",

181
pnpm-lock.yaml generated
View File

@@ -35,18 +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
@@ -1190,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}
@@ -1246,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'}
@@ -1448,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
@@ -1717,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==}
@@ -1764,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'}
@@ -1825,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'}
@@ -1845,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
@@ -1895,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:
@@ -1969,6 +1940,17 @@ 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:
@@ -2008,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'}
@@ -2035,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':
@@ -3198,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))
@@ -3282,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: {}
@@ -3448,8 +3430,6 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -3725,8 +3705,6 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
scule@1.3.0: {}
seemly@0.3.10: {}
semver@6.3.1: {}
@@ -3755,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
@@ -3813,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)):
@@ -3860,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
@@ -3913,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)
@@ -3983,6 +3913,10 @@ 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
@@ -4030,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
@@ -4045,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
src-plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
target/

215
src-plugin/Cargo.lock generated Normal file
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
src-plugin/Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["plugin-api", "plugin-sdk"]
resolver = "3"

1508
src-plugin/examples/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
[workspace]
members = ["basic-example"]
resolver = "3"
[profile.release]
strip = true
lto = true
codegen-units = 1

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);
```

View File

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

View File

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

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"] }

View File

@@ -0,0 +1,349 @@
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 = "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_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,
}
}

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

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

View File

@@ -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 未注册"));
}

52
src-tauri/Cargo.lock generated
View File

@@ -285,11 +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",
@@ -317,11 +319,19 @@ dependencies = [
"tokio",
"tracing",
"tracing-appender",
"tracing-error",
"tracing-subscriber",
"uuid",
"yaserde",
]
[[package]]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -860,6 +870,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"
@@ -1007,6 +1029,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"
@@ -1850,6 +1882,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"
@@ -4169,7 +4207,7 @@ dependencies = [
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
"dlopen2 0.7.0",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
@@ -4874,6 +4912,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"

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,6 +22,8 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
dlopen2 = { version = "0.8.2" }
bilibili-video-downloader-plugin-api = { path = "../src-plugin/plugin-api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -35,11 +37,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" }

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,13 @@
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,
@@ -9,11 +15,13 @@ use crate::{
extensions::AppHandleExt,
logger,
types::{
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,
@@ -22,9 +30,12 @@ use crate::{
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,
@@ -40,6 +51,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()
}
@@ -47,19 +60,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,
)
};
{
// 包裹在大括号中,以便自动释放写锁
@@ -91,6 +106,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
@@ -103,6 +119,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
@@ -114,6 +131,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
@@ -125,6 +143,7 @@ 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,
@@ -139,6 +158,7 @@ pub async fn get_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,
@@ -153,6 +173,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,
@@ -167,6 +188,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
@@ -178,6 +200,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
@@ -189,6 +212,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
@@ -200,6 +224,7 @@ pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<Wa
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_bangumi_follow_info(
app: AppHandle,
params: GetBangumiFollowInfoParams,
@@ -214,6 +239,7 @@ pub async fn get_bangumi_follow_info(
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub async fn get_history_info(
app: AppHandle,
params: GetHistoryInfoParams,
@@ -229,15 +255,16 @@ pub async fn get_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);
tracing::debug!("下载任务创建成功");
}
#[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);
@@ -246,6 +273,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);
@@ -254,6 +282,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);
@@ -262,6 +291,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);
@@ -270,6 +300,16 @@ pub fn restart_download_tasks(app: AppHandle, task_ids: Vec<String>) {
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[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.restart_download_task(&params);
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
let download_manager = app.get_download_manager();
download_manager
@@ -281,6 +321,7 @@ pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
#[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();
@@ -353,12 +394,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())
@@ -371,16 +413,18 @@ 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,
@@ -393,3 +437,142 @@ pub async fn get_skip_segments(
.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(())
}

View File

@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager};
use tracing::instrument;
use crate::{
danmaku_xml_to_ass::canvas::CanvasConfig,
@@ -42,10 +43,13 @@ 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");
@@ -65,7 +69,8 @@ impl Config {
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)?;
@@ -151,6 +156,8 @@ impl Config {
chunk_concurrency: 16,
chunk_download_interval_sec: 0,
danmaku_config: CanvasConfig::default(),
file_exist_action: FileExistAction::Overwrite,
auto_start_download_task: true,
}
}
}
@@ -162,3 +169,10 @@ pub enum ProxyMode {
System,
Custom,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)]
pub enum FileExistAction {
#[default]
Overwrite,
Skip,
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ use std::{
use parking_lot::Mutex;
use tokio::{sync::SemaphorePermit, time::sleep};
use tracing::instrument;
use crate::{
downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState},
@@ -23,7 +24,17 @@ pub struct DownloadChunkTask {
}
impl DownloadChunkTask {
pub async fn process(self) -> anyhow::Result<usize> {
#[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);
@@ -53,10 +64,13 @@ impl DownloadChunkTask {
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),
@@ -64,7 +78,8 @@ impl DownloadChunkTask {
}
}
async fn download_chunk(&self) -> anyhow::Result<usize> {
#[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)
@@ -94,10 +109,11 @@ impl DownloadChunkTask {
Ok(self.chunk_index)
}
#[instrument(level = "error", skip_all)]
async fn acquire_chunk_permit<'a>(
&'a self,
permit: &mut Option<SemaphorePermit<'a>>,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
*permit = match permit.take() {
// 如果有permit则直接用
Some(permit) => Some(permit),

View File

@@ -2,22 +2,26 @@ 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 tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tokio::sync::Semaphore;
use tracing::instrument;
use crate::{
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::{
@@ -56,10 +60,11 @@ impl DownloadManager {
manager
}
pub fn restore_download_tasks(&self) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
pub fn restore_download_tasks(&self) -> eyre::Result<()> {
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();
for entry in std::fs::read_dir(&task_dir)?.filter_map(Result::ok) {
@@ -101,87 +106,155 @@ 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`");
tracing::debug!("已将ID对应的下载任务状态设置为`Paused`");
}
}
#[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`");
tracing::debug!("已将ID对应的下载任务状态设置为`Pending`");
}
}
#[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;
};
// TODO: 应该先发删除新号再删文件
// 因为发信号失败会把任务重新塞回去
// 目前先删文件会导致发信号失败时出现 任务还在但文件没了的情况
if let Err(err) = self.delete_progress_file(task_id) {
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) {
if let Err(err) = task.delete_sender.send(()).map_err(eyre::Report::from) {
let err = err.wrap_err("通知ID对应的下载任务删除失败");
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);
tasks.insert(task_id.clone(), task);
continue;
}
tracing::debug!("已通知ID为`{task_id}`的下载任务删除");
tracing::debug!("已通知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_sender.send(()).map_err(eyre::Report::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);
let err = err.wrap_err("通知ID对应的下载任务重来失败");
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
tracing::debug!("已通知ID为`{task_id}`的下载任务重来");
tracing::debug!("已通知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;
};
// TODO: 把这块代码变成DownloadProgress的mark_restart函数
{
let mut progress = task.progress.write();
progress.video_task.selected = params.video_task_selected;
progress.audio_task.selected = params.audio_task_selected;
progress.video_process_task.merge_selected = params.merge_selected;
progress.video_process_task.embed_chapter_selected = params.embed_chapter_selected;
progress.video_process_task.embed_skip_selected = params.embed_skip_selected;
progress.subtitle_task.selected = params.subtitle_task_selected;
progress.danmaku_task.xml_selected = params.xml_danmaku_selected;
progress.danmaku_task.ass_selected = params.ass_danmaku_selected;
progress.danmaku_task.json_selected = params.json_danmaku_selected;
progress.cover_task.selected = params.cover_task_selected;
progress.nfo_task.selected = params.nfo_task_selected;
progress.json_task.selected = params.json_task_selected;
progress.video_task.video_quality = params.video_quality;
progress.video_task.codec_type = params.codec_type;
progress.audio_task.audio_quality = params.audio_quality;
}
if let Err(err) = task.restart_sender.send(()).map_err(eyre::Report::from) {
let err_title = "重来下载任务失败";
let err = err.wrap_err("通知ID对应的下载任务重来失败");
let message = err.to_message();
tracing::error!(err_title, message);
return;
}
tracing::debug!("已通知ID对应的下载任务重来");
}
async fn emit_download_speed_loop(app: AppHandle, byte_per_sec: Arc<AtomicU64>) {
let mut interval = tokio::time::interval(Duration::from_secs(1));
@@ -195,13 +268,15 @@ 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<()> {
#[instrument(level = "error", skip_all, fields(task_id = task_id))]
fn delete_progress_file(&self, task_id: &str) -> eyre::Result<()> {
let task_dir = self.get_task_dir()?;
let task_file = task_dir.join(format!("{task_id}.json"));
if task_file.exists() {

View File

@@ -1,22 +1,32 @@
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, nfo_task::NfoTask, subtitle_task::SubtitleTask,
video_process_task::VideoProcessTask, 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,
@@ -60,15 +70,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 +92,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 +104,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 +116,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,
@@ -132,21 +145,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 +166,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,
@@ -183,23 +195,153 @@ 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("准备下载失败")?;
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());
}
self.completed_ts = None; // 重置完成时间戳
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!("音频下载任务完成");
}
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));
}
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;
if (!video_selected && !audio_selected) || (video_completed && audio_completed) {
// 如果视频和音频都没有选中,或者都已经完成,则不需要准备
// 如果视频和音频都没有选中,或者都已经完成,则更新需要格式化的字段就返回
self.update_fmt_fields(app)
.wrap_err("更新需要格式化的字段失败")?;
return Ok(());
}
@@ -208,12 +350,14 @@ 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 +366,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 +384,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 +406,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;
@@ -294,10 +448,14 @@ impl DownloadProgress {
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, allow_create: bool) -> 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);
@@ -330,29 +488,22 @@ impl DownloadProgress {
pub fn mark_uncompleted(&mut self) {
self.video_task.mark_uncompleted();
self.audio_task.mark_uncompleted();
self.video_process_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();
@@ -360,9 +511,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,
@@ -391,18 +542,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,
@@ -431,18 +580,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,
@@ -471,30 +618,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
@@ -503,7 +649,7 @@ 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);
@@ -512,9 +658,9 @@ fn create_normal_progresses_for_season(
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,
@@ -543,18 +689,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,
@@ -583,19 +727,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,
@@ -624,12 +766,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)
@@ -656,6 +796,7 @@ impl Tasks {
content_length: 0,
chunks: Vec::new(),
completed: false,
skipped: false,
};
let audio = AudioTask {
@@ -665,6 +806,7 @@ impl Tasks {
content_length: 0,
chunks: Vec::new(),
completed: false,
skipped: false,
};
let video_process = VideoProcessTask {
@@ -672,6 +814,7 @@ impl Tasks {
embed_chapter_selected: config.embed_chapter,
embed_skip_selected: config.embed_skip,
completed: false,
skipped: false,
};
let danmaku = DanmakuTask {
@@ -679,6 +822,7 @@ impl Tasks {
ass_selected: config.download_ass_danmaku,
json_selected: config.download_json_danmaku,
completed: false,
skipped: false,
};
let subtitle = SubtitleTask {
@@ -695,6 +839,7 @@ impl Tasks {
let nfo = NfoTask {
selected: config.download_nfo,
completed: false,
skipped: false,
};
let json = JsonTask {

View File

@@ -1,20 +1,19 @@
use std::{
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use std::{sync::Arc, time::Duration};
use anyhow::Context;
use eyre::WrapErr;
use parking_lot::RwLock;
use tauri::AppHandle;
use tauri_specta::Event;
use tokio::{
sync::{watch, SemaphorePermit},
sync::{SemaphorePermit, watch},
time::sleep,
};
use tracing::instrument;
use crate::{
downloader::episode_type::EpisodeType,
events::DownloadEvent,
extensions::{AnyhowErrorToStringChain, AppHandleExt},
extensions::{AppHandleExt, EyreReportToMessage},
types::create_download_task_params::CreateDownloadTaskParams,
};
@@ -27,10 +26,13 @@ pub struct DownloadTask {
pub cancel_sender: watch::Sender<()>,
pub delete_sender: watch::Sender<()>,
pub task_id: String,
pub trace_fields: DownloadTaskTraceFields,
pub progress: RwLock<DownloadProgress>,
}
impl DownloadTask {
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
pub fn from_params(app: &AppHandle, params: &CreateDownloadTaskParams) -> Vec<Arc<Self>> {
use CreateDownloadTaskParams::{Bangumi, Cheese, Normal};
@@ -38,15 +40,24 @@ impl DownloadTask {
match params {
Normal(params) => {
for &(aid, cid) in &params.aid_cid_pairs {
let span = tracing::error_span!(
"from_params_normal",
aid = aid,
bvid = params.info.bvid,
cid = cid,
collection_title = params.info.title,
up_name = params.info.owner.name,
up_uid = params.info.owner.mid,
);
let _enter = span.enter();
let progress = match DownloadProgress::from_normal(app, &params.info, aid, cid)
{
Ok(progress) => progress,
Err(err) => {
let cid = cid.map_or("None".to_string(), |id| id.to_string());
let ids_string = format!("aid: {aid}, cid: {cid}");
let err_title = format!("{ids_string} 创建普通视频的下载进度失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "创建普通视频的下载进度失败";
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
};
@@ -56,13 +67,21 @@ impl DownloadTask {
}
Bangumi(params) => {
for ep_id in &params.ep_ids {
let span = tracing::error_span!(
"from_params_bangumi",
ep_id = ep_id,
collection_title = params.info.title,
up_name = params.info.up_info.as_ref().map(|up_info| &up_info.uname),
up_uid = params.info.up_info.as_ref().map(|up_info| up_info.mid),
);
let _enter = span.enter();
let progress = match DownloadProgress::from_bangumi(app, &params.info, *ep_id) {
Ok(progress) => progress,
Err(err) => {
let ids_string = format!("ep_id: {ep_id}");
let err_title = format!("{ids_string} 创建番剧的下载进度失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "创建番剧的下载进度失败";
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
};
@@ -72,13 +91,21 @@ impl DownloadTask {
}
Cheese(params) => {
for ep_id in &params.ep_ids {
let span = tracing::error_span!(
"from_params_cheese",
ep_id = ep_id,
collection_title = params.info.title,
up_name = params.info.up_info.uname,
up_uid = params.info.up_info.mid,
);
let _enter = span.enter();
let progress = match DownloadProgress::from_cheese(app, &params.info, *ep_id) {
Ok(progress) => progress,
Err(err) => {
let ids_string = format!("ep_id: {ep_id}");
let err_title = format!("{ids_string} 创建课程的下载进度失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "创建课程的下载进度失败";
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
};
@@ -90,15 +117,38 @@ impl DownloadTask {
let mut tasks = Vec::new();
for progress in progresses {
let span = tracing::error_span!(
"create_tasks",
task_id = progress.task_id,
episode_type = ?progress.episode_type,
aid = progress.aid,
bvid = progress.bvid,
cid = progress.cid,
ep_id = progress.ep_id,
collection_title = progress.collection_title,
episode_title = progress.episode_title,
episode_order = progress.episode_order,
part_title = progress.part_title,
part_order = progress.part_order,
up_name = progress.up_name,
up_uid = progress.up_uid,
);
let _enter = span.enter();
if let Err(err) = progress.save(app, true) {
let ids_string = progress.get_ids_string();
let episode_title = &progress.episode_title;
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "保存下载进度到文件失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
let (state_sender, _) = watch::channel(DownloadTaskState::Pending);
let auto_start = app.get_config().read().auto_start_download_task;
let init_state = if auto_start {
DownloadTaskState::Pending
} else {
DownloadTaskState::Paused
};
let (state_sender, _) = watch::channel(init_state);
let (restart_sender, _) = watch::channel(());
let (cancel_sender, _) = watch::channel(());
let (delete_sender, _) = watch::channel(());
@@ -110,6 +160,7 @@ impl DownloadTask {
cancel_sender,
delete_sender,
task_id: progress.task_id.clone(),
trace_fields: DownloadTaskTraceFields::from(&progress),
progress: RwLock::new(progress),
});
@@ -139,6 +190,7 @@ impl DownloadTask {
cancel_sender,
delete_sender,
task_id: progress.task_id.clone(),
trace_fields: DownloadTaskTraceFields::from(&progress),
progress: RwLock::new(progress),
});
@@ -147,8 +199,26 @@ impl DownloadTask {
task
}
#[instrument(
level = "error",
skip_all,
fields(
task_id = self.trace_fields.task_id,
episode_type = ?self.trace_fields.episode_type,
aid = self.trace_fields.aid,
bvid = self.trace_fields.bvid,
cid = self.trace_fields.cid,
ep_id = self.trace_fields.ep_id,
collection_title = self.trace_fields.collection_title,
episode_title = self.trace_fields.episode_title,
episode_order = self.trace_fields.episode_order,
part_title = self.trace_fields.part_title,
part_order = self.trace_fields.part_order,
up_name = self.trace_fields.up_name,
up_uid = self.trace_fields.up_uid,
)
)]
async fn process(self: Arc<Self>) {
let task_id = &self.task_id;
let state = *self.state_sender.borrow();
let progress = self.progress.read().clone();
let _ = DownloadEvent::TaskCreate { state, progress }.emit(&self.app);
@@ -179,7 +249,7 @@ impl DownloadTask {
download_task_option = None;
if let Some(permit) = permit.take() {
drop(permit);
};
}
}
() = self.acquire_task_permit(&mut permit), if state_is_pending => {},
@@ -190,7 +260,7 @@ impl DownloadTask {
_ = restart_receiver.changed() => {
self.handle_restart_notify();
tracing::debug!("ID为`{task_id}`的下载任务已重来");
tracing::debug!("下载任务已重来");
download_task_option = None;
}
@@ -208,52 +278,32 @@ impl DownloadTask {
sleep(Duration::from_millis(100)).await;
}
tracing::debug!("ID为`{task_id}`的下载任务已删除");
tracing::debug!("下载任务已删除");
return;
}
}
}
}
#[instrument(level = "error", skip_all)]
async fn download(self: &Arc<Self>) {
let mut progress = self.progress.read().clone();
let ids_string = progress.get_ids_string();
let episode_title = progress.episode_title.clone();
if progress.is_completed() {
tracing::info!("{ids_string} 跳过`{episode_title}`的下载,因为它已经完成");
tracing::info!("跳过下载,因为下载任务已完成");
self.set_state(DownloadTaskState::Completed);
return;
}
tracing::debug!("{ids_string} 开始准备`{episode_title}`的下载");
let _ = DownloadEvent::ProgressPreparing {
task_id: self.task_id.clone(),
}
.emit(&self.app);
if let Err(err) = progress.prepare(&self.app).await {
let err_title = format!("{ids_string} `{episode_title}`准备下载失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
self.set_state(DownloadTaskState::Failed);
return;
}
progress.completed_ts = None; // 重置完成时间戳
self.update_progress(|p| *p = progress.clone());
tracing::debug!("{ids_string} 开始下载`{episode_title}`");
if let Err(err) = self
.handle_progress(progress)
tracing::debug!("开始下载");
if let Err(err) = progress
.process(self)
.await
.context("[继续]失败的任务可以断点续传")
.wrap_err("[继续]失败的任务可以断点续传")
{
let err_title = format!("{ids_string} `{episode_title}`下载失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "下载失败";
let message = err.to_message();
tracing::error!(err_title, message);
self.set_state(DownloadTaskState::Failed);
@@ -263,103 +313,7 @@ impl DownloadTask {
self.sleep_between_task().await;
self.set_state(DownloadTaskState::Completed);
tracing::info!("{ids_string} `{episode_title}`下载完成");
}
async fn handle_progress(self: &Arc<Self>, progress: DownloadProgress) -> anyhow::Result<()> {
let ids_string = progress.get_ids_string();
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
std::fs::create_dir_all(episode_dir).context(format!(
"{ids_string} 创建目录`{}`失败",
episode_dir.display()
))?;
let video_task = &progress.video_task;
let audio_task = &progress.audio_task;
let video_process_task = &progress.video_process_task;
let danmaku_task = &progress.danmaku_task;
let subtitle_task = &progress.subtitle_task;
let cover_task = &progress.cover_task;
let nfo_task = &progress.nfo_task;
let json_task = &progress.json_task;
let mut player_info = None;
let mut episode_info = None;
if !video_task.is_completed() && video_task.content_length != 0 {
video_task
.process(self, &progress)
.await
.context(format!("{ids_string} `{filename}`下载视频文件失败"))?;
tracing::debug!("{ids_string} `{filename}`视频下载完成");
}
if !audio_task.is_completed() && audio_task.content_length != 0 {
audio_task
.process(self, &progress)
.await
.context(format!("{ids_string} `{filename}`下载音频文件失败"))?;
tracing::debug!("{ids_string} `{filename}`音频下载完成");
}
if !video_process_task.is_completed() {
video_process_task
.process(self, &progress, &mut player_info)
.await
.context(format!("{ids_string} `{filename}`视频处理失败"))?;
tracing::debug!("{ids_string} `{filename}`视频处理完成");
}
if !danmaku_task.is_completed() {
danmaku_task
.process(self, &progress)
.await
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
}
if !subtitle_task.is_completed() {
subtitle_task
.process(self, &progress, &mut player_info)
.await
.context(format!("{ids_string} `{filename}`下载字幕失败"))?;
tracing::debug!("{ids_string} `{filename}`字幕下载完成");
}
if !cover_task.is_completed() {
cover_task
.process(self, &progress)
.await
.context(format!("{ids_string} `{filename}`下载封面失败"))?;
tracing::debug!("{ids_string} `{filename}`封面下载完成");
}
if !nfo_task.is_completed() {
nfo_task
.process(self, &progress, &mut episode_info)
.await
.context(format!("{ids_string} `{filename}`下载NFO失败"))?;
tracing::debug!("{ids_string} `{filename}`NFO下载完成");
}
if !json_task.is_completed() {
json_task
.process(self, &progress, &mut episode_info)
.await
.context(format!("{ids_string} `{filename}`下载JSON元数据失败"))?;
tracing::debug!("{ids_string} `{filename}`JSON元数据下载完成");
}
let completed_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.ok();
if completed_ts.is_some() {
self.update_progress(|p| p.completed_ts = completed_ts);
}
Ok(())
tracing::info!("下载成功");
}
async fn sleep_between_task(&self) {
@@ -377,12 +331,8 @@ impl DownloadTask {
}
}
#[instrument(level = "error", skip_all)]
async fn acquire_task_permit<'a>(&'a self, permit: &mut Option<SemaphorePermit<'a>>) {
let (episode_title, ids_string) = {
let progress = self.progress.read();
(progress.episode_title.clone(), progress.get_ids_string())
};
*permit = match permit.take() {
// 如果有permit则直接用
Some(permit) => Some(permit),
@@ -394,14 +344,13 @@ impl DownloadTask {
.task_sem
.acquire()
.await
.map_err(anyhow::Error::from)
.map_err(eyre::Report::from)
{
Ok(permit) => Some(permit),
Err(err) => {
let err_title =
format!("{ids_string} `{episode_title}`获取下载任务的permit失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "获取下载任务的permit失败";
let message = err.to_message();
tracing::error!(err_title, message);
self.set_state(DownloadTaskState::Failed);
@@ -417,16 +366,17 @@ impl DownloadTask {
if let Err(err) = self
.state_sender
.send(DownloadTaskState::Downloading)
.map_err(anyhow::Error::from)
.map_err(eyre::Report::from)
{
let err_title = format!("{ids_string} `{episode_title}`发送状态`Downloading`失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "发送状态`Downloading`失败";
let message = err.to_message();
tracing::error!(err_title, message);
self.set_state(DownloadTaskState::Failed);
}
}
#[instrument(level = "error", skip_all)]
async fn handle_state_change<'a>(
&'a self,
permit: &mut Option<SemaphorePermit<'a>>,
@@ -440,14 +390,14 @@ impl DownloadTask {
// 稍微等一下再释放permit
// 避免大批量暂停时本应暂停的任务因拿到permit而稍微下载一小段(虽然最终会被暂停)
sleep(Duration::from_millis(100)).await;
let task_id = &self.task_id;
tracing::debug!("ID为`{task_id}`的下载任务已暂停");
tracing::debug!("下载任务已暂停");
if let Some(permit) = permit.take() {
drop(permit);
};
}
}
}
#[instrument(level = "error", skip_all)]
fn handle_restart_notify(&self) {
self.update_progress(|p| {
p.mark_uncompleted();
@@ -455,24 +405,43 @@ impl DownloadTask {
self.set_state(DownloadTaskState::Pending);
}
#[instrument(
level = "error",
skip_all,
fields(
task_id = self.trace_fields.task_id,
episode_type = ?self.trace_fields.episode_type,
aid = self.trace_fields.aid,
bvid = self.trace_fields.bvid,
cid = self.trace_fields.cid,
ep_id = self.trace_fields.ep_id,
collection_title = self.trace_fields.collection_title,
episode_title = self.trace_fields.episode_title,
episode_order = self.trace_fields.episode_order,
part_title = self.trace_fields.part_title,
part_order = self.trace_fields.part_order,
up_name = self.trace_fields.up_name,
up_uid = self.trace_fields.up_uid,
)
)]
pub fn set_state(&self, state: DownloadTaskState) {
let (episode_title, ids_string) = {
let progress = self.progress.read();
(progress.episode_title.clone(), progress.get_ids_string())
};
if let Err(err) = self.state_sender.send(state).map_err(anyhow::Error::from) {
let err_title = format!("{ids_string} `{episode_title}`发送状态`{state:?}`失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
if let Err(err) = self.state_sender.send(state).map_err(eyre::Report::from) {
let err_title = format!("发送状态`{state:?}`失败");
let message = err.to_message();
tracing::error!(err_title, message);
}
}
#[instrument(level = "error", skip_all)]
pub fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) {
// 修改数据
let updated_progress = {
let mut progress = self.progress.write();
update_fn(&mut progress);
// TODO: 这里应该返回 progress.clone()
// 专门用一个 {} 框出来就是为了避免在emit和save期间仍持有写锁
// 然而这里弄错了progress的类型
// 错把progress当成了DownloadProgress实则类型为RwLockWriteGuard
progress
};
// 发送更新事件并保存到文件
@@ -482,11 +451,45 @@ impl DownloadTask {
.emit(&self.app);
if let Err(err) = updated_progress.save(&self.app, false) {
let ids_string = updated_progress.get_ids_string();
let episode_title = &updated_progress.episode_title;
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "保存下载进度到文件失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
pub struct DownloadTaskTraceFields {
pub task_id: String,
pub episode_type: EpisodeType,
pub aid: i64,
pub bvid: Option<String>,
pub cid: i64,
pub ep_id: Option<i64>,
pub collection_title: String,
pub episode_title: 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>,
}
impl From<&DownloadProgress> for DownloadTaskTraceFields {
fn from(progress: &DownloadProgress) -> Self {
Self {
task_id: progress.task_id.clone(),
episode_type: progress.episode_type,
aid: progress.aid,
bvid: progress.bvid.clone(),
cid: progress.cid,
ep_id: progress.ep_id,
collection_title: progress.collection_title.clone(),
episode_title: progress.episode_title.clone(),
episode_order: progress.episode_order,
part_title: progress.part_title.clone(),
part_order: progress.part_order,
up_name: progress.up_name.clone(),
up_uid: progress.up_uid,
}
}
}

View File

@@ -1,5 +1,6 @@
use anyhow::Context;
use eyre::{OptionExt, WrapErr};
use tauri::AppHandle;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, episode_type::EpisodeType},
@@ -23,15 +24,16 @@ pub trait GetOrInitEpisodeInfo {
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> anyhow::Result<&'a mut EpisodeInfo>;
) -> 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,
) -> anyhow::Result<&'a mut EpisodeInfo> {
) -> eyre::Result<&'a mut EpisodeInfo> {
if let Some(info) = self {
return Ok(info);
}
@@ -44,23 +46,23 @@ impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
let info = bili_client
.get_normal_info(GetNormalInfoParams::Aid(aid))
.await
.context("获取普通视频信息失败")?;
.wrap_err("获取普通视频信息失败")?;
EpisodeInfo::Normal(info)
}
EpisodeType::Bangumi => {
let ep_id = ep_id.context("ep_id为None")?;
let ep_id = ep_id.ok_or_eyre("ep_id为None")?;
let info = bili_client
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
.await
.context("获取番剧信息失败")?;
.wrap_err("获取番剧信息失败")?;
EpisodeInfo::Bangumi(info, ep_id)
}
EpisodeType::Cheese => {
let ep_id = ep_id.context("ep_id为None")?;
let ep_id = ep_id.ok_or_eyre("ep_id为None")?;
let info = bili_client
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
.await
.context("获取课程信息失败")?;
.wrap_err("获取课程信息失败")?;
EpisodeInfo::Cheese(info, ep_id)
}
};

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;
@@ -26,21 +31,22 @@ pub struct FmtParams {
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 +73,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 +81,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 +94,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));
}
}

View File

@@ -4,20 +4,22 @@ use std::{
sync::Arc,
};
use anyhow::{anyhow, Context};
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::{
config::FileExistAction,
downloader::{
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
download_task::DownloadTask, media_chunk::MediaChunk,
},
extensions::{AnyhowErrorToStringChain, AppHandleExt},
extensions::{AppHandleExt, EyreReportToMessage},
types::{
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
@@ -28,6 +30,7 @@ use crate::{
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,
@@ -35,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 {
@@ -54,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;
@@ -62,7 +62,9 @@ impl AudioTask {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -75,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;
@@ -83,7 +85,9 @@ impl AudioTask {
id,
url_with_content_length,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -96,50 +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);
}
}
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();
@@ -152,50 +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);
}
}
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();
@@ -208,46 +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);
}
}
self.prepare(app, medias)?;
Ok(())
self.prepare(app, &medias);
}
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) -> anyhow::Result<()> {
fn prepare(&mut self, app: &AppHandle, medias: &[MediaForPrepare]) {
if medias.is_empty() {
return Err(anyhow!("获取音频地址失败"));
self.completed = true;
return;
}
let quality_priority = app.get_config().read().audio_quality_priority.clone();
let priority_map: HashMap<&AudioQuality, usize> = quality_priority
.iter()
.enumerate()
.map(|(index, quality)| (quality, index))
.collect();
medias.sort_by_key(|media| {
let quality: AudioQuality = media.id.into();
priority_map.get(&quality).unwrap_or(&usize::MAX)
});
// 如果`audio_quality`为`Unknown`,则更倾向于使用优先级选择
let prefer_select_by_priority = self.audio_quality == AudioQuality::Unknown;
let media = &medias[0];
let selected_media = if prefer_select_by_priority {
select_media_by_priority(app, medias)
} else {
select_exact_match_media(self, medias).or_else(|| select_media_by_priority(app, medias))
};
let Some(media) = selected_media else {
self.completed = true;
return;
};
self.audio_quality = media.id.into();
@@ -278,8 +288,6 @@ impl AudioTask {
self.content_length = content_length;
self.chunks = chunks;
}
Ok(())
}
pub fn mark_uncompleted(&mut self) {
@@ -287,38 +295,51 @@ 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,
) -> anyhow::Result<()> {
) -> 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 (audio_task, episode_title, ids_string) = {
(
progress.audio_task.clone(),
progress.episode_title.clone(),
progress.get_ids_string(),
)
};
let file = if temp_file_path.exists() {
// 如果文件已存在,则打开它
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
@@ -339,25 +360,31 @@ impl AudioTask {
download_task: download_task.clone(),
start,
end,
url: audio_task.url.to_string(),
url: audio_task.url.clone(),
file: file.clone(),
chunk_index,
};
join_set.spawn(async move {
download_chunk_task.process().await.context(format!(
"分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})"
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(Ok(download_video_result)) = join_set.join_next().await {
match download_video_result {
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 = format!("{ids_string} `{episode_title}`音频的一个分片下载失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "音频的一个分片下载失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
@@ -370,32 +397,31 @@ impl AudioTask {
.iter()
.all(|chunk| chunk.completed);
if !download_completed {
return Err(anyhow!(
return Err(eyre!(
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
temp_file_path.display()
));
}
let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
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.video_task.mark_uncompleted());
return Err(anyhow!(
download_task.update_progress(|p| p.audio_task.mark_uncompleted());
return Err(eyre!(
"音频文件`{}`不完整,[继续]会重新下载所有分片",
temp_file_path.display()
));
}
// 重命名临时文件
let m4a_path = episode_dir.join(format!("{filename}.m4a"));
if m4a_path.exists() {
std::fs::remove_file(&m4a_path)
.context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?;
.wrap_err(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?;
}
std::fs::rename(&temp_file_path, &m4a_path).context(format!(
std::fs::rename(&temp_file_path, &m4a_path).wrap_err(format!(
"将临时文件`{}`重命名为`{}`失败",
temp_file_path.display(),
m4a_path.display()
@@ -412,3 +438,35 @@ struct MediaForPrepare {
pub id: i64,
pub url_with_content_length: Vec<(String, u64)>,
}
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()
}
fn select_media_by_priority(
app: &AppHandle,
medias: &[MediaForPrepare],
) -> Option<MediaForPrepare> {
let quality_priority = app.get_config().read().audio_quality_priority.clone();
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()
}

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use anyhow::Context;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
@@ -10,6 +11,7 @@ use crate::{
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(default)]
pub struct CoverTask {
pub selected: bool,
pub url: String,
@@ -17,26 +19,31 @@ 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,
) -> anyhow::Result<()> {
) -> 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
.context("获取封面失败")?;
.wrap_err("获取封面失败")?;
let save_path = episode_dir.join(format!("{filename}.{ext}"));
std::fs::write(&save_path, cover_data)
.context(format!("保存封面到`{}`失败", save_path.display()))?;
.wrap_err(format!("保存封面到`{}`失败", save_path.display()))?;
download_task.update_progress(|p| p.cover_task.completed = true);

View File

@@ -1,10 +1,12 @@
use std::{fs::File, sync::Arc};
use anyhow::Context;
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,
@@ -12,57 +14,82 @@ use crate::{
};
#[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,
) -> anyhow::Result<()> {
) -> 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
.context("获取弹幕失败")?;
.wrap_err("获取弹幕失败")?;
let xml = replies
.to_xml(progress.cid)
.context("将弹幕转换为XML失败")?;
.wrap_err("将弹幕转换为XML失败")?;
if danmaku_task.xml_selected {
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
std::fs::write(&xml_path, &xml)
.context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
.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_path = episode_dir.join(format!("{filename}.弹幕.ass"));
let ass_file = File::create(&ass_path)
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
let title = filename.to_string();
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
.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_path = episode_dir.join(format!("{filename}.弹幕.json"));
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
let json_string = serde_json::to_string(&replies).wrap_err("将弹幕转换为JSON失败")?;
std::fs::write(&json_path, json_string)
.context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
.wrap_err(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
}
download_task.update_progress(|p| p.danmaku_task.completed = true);

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use anyhow::Context;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::downloader::{
download_progress::DownloadProgress,
@@ -11,22 +12,28 @@ use crate::downloader::{
};
#[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>,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let episode_info = episode_info
@@ -36,17 +43,17 @@ impl JsonTask {
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
let json_string = match episode_info {
EpisodeInfo::Normal(info) => {
serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")?
serde_json::to_string(&info).wrap_err("将普通视频信息转换为JSON失败")?
}
EpisodeInfo::Bangumi(info, _ep_id) => {
serde_json::to_string(&info).context("将番剧信息转换为JSON失败")?
serde_json::to_string(&info).wrap_err("将番剧信息转换为JSON失败")?
}
EpisodeInfo::Cheese(info, _ep_id) => {
serde_json::to_string(&info).context("将课程信息转换为JSON失败")?
serde_json::to_string(&info).wrap_err("将课程信息转换为JSON失败")?
}
};
std::fs::write(&json_path, json_string)
.context(format!("保存JSON到`{}`失败", json_path.display()))?;
.wrap_err(format!("保存JSON到`{}`失败", json_path.display()))?;
download_task.update_progress(|p| p.json_task.completed = true);

View File

@@ -1,12 +1,14 @@
use std::sync::Arc;
use anyhow::{anyhow, Context};
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::{
config::FileExistAction,
downloader::{
download_progress::DownloadProgress,
download_task::DownloadTask,
@@ -19,121 +21,212 @@ use crate::{
};
#[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>,
) -> anyhow::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
) -> eyre::Result<()> {
let episode_info = episode_info
.get_or_init(&download_task.app, progress)
.await?;
let bili_client = download_task.app.get_bili_client();
match episode_info {
EpisodeInfo::Normal(info) => {
let tags = bili_client
.get_tags(progress.aid)
.await
.context("获取视频标签失败")?;
let movie_nfo = info
.to_movie_nfo(tags)
.context("将普通视频信息转换为movie NFO失败")?;
let nfo_path = episode_dir.join(format!("{filename}.nfo"));
std::fs::write(&nfo_path, movie_nfo)
.context(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
.context("获取普通视频合集封面失败")?;
let cover_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&cover_path, cover_data).context(format!(
"保存普通视频合集封面到`{}`失败",
cover_path.display()
))?;
}
self.process_normal(download_task, progress, info).await?;
}
EpisodeInfo::Bangumi(info, ep_id) => {
let tvshow_nfo = info
.to_tvshow_nfo()
.context("将番剧信息转换为tvshow NFO失败")?;
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
.context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?;
let episode_details_nfo = info
.to_episode_details_nfo(*ep_id)
.context("将番剧信息转换为episodedetail NFO失败")?;
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(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
.context("获取番剧封面失败")?;
let poster_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&poster_path, poster_data)
.context(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
.context("获取番剧封面失败")?;
let fanart_path = episode_dir.join(format!("fanart.{ext}"));
std::fs::write(&fanart_path, fanart_data)
.context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?;
}
self.process_bangumi(download_task, progress, info, ep_id)
.await?;
}
EpisodeInfo::Cheese(info, ep_id) => {
let tvshow_nfo = info
.to_tvshow_nfo()
.context("将课程信息转换为tvshow NFO失败")?;
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
.context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?;
let episode_details_nfo = info
.to_episode_details_nfo(*ep_id)
.context("将课程信息转换为episodedetail NFO失败")?;
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(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
.context("获取课程封面失败")?;
let poster_path = episode_dir.join(format!("poster.{ext}"));
std::fs::write(&poster_path, poster_data)
.context(format!("保存课程封面到`{}`失败", poster_path.display()))?;
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(())
@@ -208,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(),
@@ -223,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 {
@@ -266,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}"),
)?;
@@ -303,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,
@@ -349,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)
}
@@ -398,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() {
@@ -429,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 {
@@ -467,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)
}

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use anyhow::Context;
use eyre::WrapErr;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::instrument;
use crate::{
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
@@ -12,22 +13,28 @@ use crate::{
};
#[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>,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
use std::fmt::Write;
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
@@ -43,7 +50,7 @@ impl SubtitleTask {
let subtitle = bili_client
.get_subtitle(&url)
.await
.context("获取字幕失败")?;
.wrap_err("获取字幕失败")?;
let mut srt_content = String::new();
for (i, b) in subtitle.body.iter().enumerate() {

View File

@@ -1,9 +1,10 @@
use std::{path::PathBuf, sync::Arc};
use anyhow::{anyhow, Context};
use eyre::{WrapErr, eyre};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::AppHandle;
use tracing::instrument;
use crate::{
downloader::{
@@ -17,55 +18,63 @@ use crate::{
};
#[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>,
) -> anyhow::Result<()> {
) -> 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
.context("自动合并+嵌入章节元数据失败")?;
.wrap_err("自动合并+嵌入章节元数据失败")?;
} else if self.merge_selected {
println!("merge1");
self.merge(download_task, progress)
.await
.context("自动合并失败")?;
.wrap_err("自动合并失败")?;
} else if embed_selected {
self.embed(download_task, progress, player_info)
.await
.context("嵌入章节元数据失败")?;
.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>,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let video_path = episode_dir.join(format!("{filename}.mp4"));
if !video_path.exists() {
@@ -78,14 +87,14 @@ impl VideoProcessTask {
// 如果音频文件不存在,则只嵌入章节元数据
self.embed(download_task, progress, player_info)
.await
.context("嵌入章节元数据失败")?;
.wrap_err("嵌入章节元数据失败")?;
return Ok(());
}
let metadata_path = self
.create_chapter_metadata(&download_task.app, progress, player_info)
.await
.context("创建章节元数据失败")?;
.wrap_err("创建章节元数据失败")?;
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
@@ -95,7 +104,10 @@ impl VideoProcessTask {
let metadata_path_clone = metadata_path.clone();
let output_path_clone = output_path.clone();
tokio::spawn(async move {
let current_span = tracing::Span::current();
tauri::async_runtime::spawn_blocking(move || {
let _enter = current_span.enter();
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(video_path_clone);
@@ -128,24 +140,24 @@ impl VideoProcessTask {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let err = anyhow!(format!("STDOUT: {stdout}"))
.context(format!("STDERR: {stderr}"))
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
let err = eyre!(format!("STDOUT: {stdout}"))
.wrap_err(format!("STDERR: {stderr}"))
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
return Err(err);
}
std::fs::remove_file(&video_path)
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::remove_file(&audio_path)
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
std::fs::rename(&output_path, &video_path).context(format!(
.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).context(format!(
std::fs::remove_file(&metadata_path).wrap_err(format!(
"删除章节元数据文件`{}`失败",
metadata_path.display()
))?;
@@ -156,11 +168,12 @@ impl VideoProcessTask {
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn merge(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let video_path = episode_dir.join(format!("{filename}.mp4"));
@@ -177,14 +190,17 @@ impl VideoProcessTask {
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let (tx, rx) = tokio::sync::oneshot::channel();
let video_path_clone = video_path.clone();
let audio_path_clone = audio_path.clone();
let output_path_clone = output_path.clone();
let current_span = tracing::Span::current();
tauri::async_runtime::spawn_blocking(move || {
let _enter = current_span.enter();
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(video_path_clone);
@@ -213,17 +229,17 @@ impl VideoProcessTask {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let err = anyhow!(format!("STDOUT: {stdout}"))
.context(format!("STDERR: {stderr}"))
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
let err = eyre!(format!("STDOUT: {stdout}"))
.wrap_err(format!("STDERR: {stderr}"))
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
return Err(err);
}
std::fs::remove_file(&video_path)
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::remove_file(&audio_path)
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
std::fs::rename(&output_path, &video_path).context(format!(
.wrap_err(format!("删除音频文件`{}`失败", audio_path.display()))?;
std::fs::rename(&output_path, &video_path).wrap_err(format!(
"将`{}`重命名为`{}`失败",
output_path.display(),
video_path.display()
@@ -234,15 +250,16 @@ impl VideoProcessTask {
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn embed(
&self,
download_task: &Arc<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> anyhow::Result<()> {
) -> eyre::Result<()> {
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
let ffmpeg_program = utils::get_ffmpeg_program().wrap_err("获取FFmpeg程序路径失败")?;
let video_path = episode_dir.join(format!("{filename}.mp4"));
if !video_path.exists() {
@@ -255,7 +272,7 @@ impl VideoProcessTask {
let metadata_path = self
.create_chapter_metadata(&download_task.app, progress, player_info)
.await
.context("创建章节元数据失败")?;
.wrap_err("创建章节元数据失败")?;
let Some(metadata_path) = metadata_path else {
download_task.update_progress(|p| p.video_process_task.completed = true);
@@ -267,7 +284,10 @@ impl VideoProcessTask {
let metadata_path_clone = metadata_path.clone();
let output_path_clone = output_path.clone();
let current_span = tracing::Span::current();
tauri::async_runtime::spawn_blocking(move || {
let _enter = current_span.enter();
let mut command = std::process::Command::new(ffmpeg_program);
command.arg("-i").arg(video_path_clone);
@@ -295,20 +315,20 @@ impl VideoProcessTask {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let err = anyhow!(format!("STDOUT: {stdout}"))
.context(format!("STDERR: {stderr}"))
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
let err = eyre!(format!("STDOUT: {stdout}"))
.wrap_err(format!("STDERR: {stderr}"))
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
return Err(err);
}
std::fs::remove_file(&video_path)
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
std::fs::rename(&output_path, &video_path).context(format!(
.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).context(format!(
std::fs::remove_file(&metadata_path).wrap_err(format!(
"删除章节元数据文件`{}`失败",
metadata_path.display()
))?;
@@ -318,12 +338,13 @@ impl VideoProcessTask {
Ok(())
}
#[instrument(level = "error", skip_all)]
async fn create_chapter_metadata(
&self,
app: &AppHandle,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> anyhow::Result<Option<PathBuf>> {
) -> eyre::Result<Option<PathBuf>> {
let mut chapter_segments = ChapterSegments {
segments: Vec::new(),
};
@@ -362,7 +383,7 @@ impl VideoProcessTask {
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)
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
.wrap_err(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
Ok(Some(metadata_path))
}

View File

@@ -4,20 +4,22 @@ use std::{
sync::Arc,
};
use anyhow::{anyhow, Context};
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::{
config::FileExistAction,
downloader::{
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
download_task::DownloadTask, media_chunk::MediaChunk,
},
extensions::{AnyhowErrorToStringChain, AppHandleExt},
extensions::{AppHandleExt, EyreReportToMessage},
types::{
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
@@ -28,6 +30,7 @@ use crate::{
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,
@@ -36,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 {
@@ -55,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 {
@@ -63,27 +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);
}
}
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();
@@ -98,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;
@@ -107,7 +141,9 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -121,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;
@@ -130,26 +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);
}
}
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();
@@ -164,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;
@@ -173,7 +216,9 @@ impl VideoTask {
url_with_content_length,
codecid,
}
});
};
join_set.spawn(get_url_with_content_length_task.in_current_span());
}
}
@@ -187,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;
@@ -196,59 +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);
}
}
self.prepare(app, medias)?;
self.prepare(app, &medias)?;
Ok(())
}
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) -> anyhow::Result<()> {
#[instrument(level = "error", skip_all)]
fn prepare(&mut self, app: &AppHandle, medias: &[MediaForPrepare]) -> eyre::Result<()> {
if medias.is_empty() {
return Err(anyhow!("获取频地址失败"));
return Err(eyre!("获取频地址失败medias为空"));
}
let (video_quality_priority, codec_type_priority) = {
let config = app.get_config().inner().read();
(
config.video_quality_priority.clone(),
config.codec_type_priority.clone(),
)
let video_quality_is_unknown = self.video_quality == VideoQuality::Unknown;
let codec_type_is_unknown = self.codec_type == CodecType::Unknown;
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 {
select_exact_match_media(self, medias).or_else(|| select_media_by_priority(app, medias))
};
let video_priority_map: HashMap<&VideoQuality, usize> = video_quality_priority
.iter()
.enumerate()
.map(|(index, quality)| (quality, index))
.collect();
medias.sort_by_key(|media| {
let quality: VideoQuality = media.id.into();
video_priority_map.get(&quality).unwrap_or(&usize::MAX)
});
let retain_id = medias[0].id;
medias.retain(|m| m.id == retain_id);
let codec_priority_map: HashMap<&CodecType, usize> = codec_type_priority
.iter()
.enumerate()
.map(|(index, codec_type)| (codec_type, index))
.collect();
medias.sort_by_key(|m| {
let codec_type: CodecType = m.codecid.into();
codec_priority_map.get(&codec_type).unwrap_or(&usize::MAX)
});
let media = &medias[0];
let media = selected_media.ok_or_eyre("获取视频地址失败medias为空")?;
self.video_quality = media.id.into();
self.codec_type = media.codecid.into();
@@ -289,40 +327,51 @@ 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,
) -> anyhow::Result<()> {
) -> 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 (video_task, episode_title, ids_string) = {
let progress = download_task.progress.read();
(
progress.video_task.clone(),
progress.episode_title.clone(),
progress.get_ids_string(),
)
};
let should_reuse_temp_file = temp_file_path
.metadata()
.map(|m| m.len() == video_task.content_length)
.unwrap_or(false);
let file = if temp_file_path.exists() {
// 如果临时文件已存在,则打开它
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
@@ -332,7 +381,7 @@ impl VideoTask {
let chunk_count = video_task.chunks.len();
let mut join_set = JoinSet::new();
for (i, chunk) in video_task.chunks.iter().enumerate() {
for (chunk_index, chunk) in video_task.chunks.iter().enumerate() {
if chunk.completed {
continue;
}
@@ -343,27 +392,31 @@ impl VideoTask {
download_task: download_task.clone(),
start,
end,
url: video_task.url.to_string(),
url: video_task.url.clone(),
file: file.clone(),
chunk_index: i,
chunk_index,
};
let chunk_order = i + 1;
join_set.spawn(async move {
download_chunk_task.process().await.context(format!(
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(Ok(download_video_result)) = join_set.join_next().await {
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 = format!("{ids_string} `{episode_title}`视频的一个分片下载失败");
let string_chain = err.to_string_chain();
tracing::error!(err_title, message = string_chain);
let err_title = "视频的一个分片下载失败";
let message = err.to_message();
tracing::error!(err_title, message);
}
}
}
@@ -376,32 +429,31 @@ impl VideoTask {
.iter()
.all(|chunk| chunk.completed);
if !download_completed {
return Err(anyhow!(
return Err(eyre!(
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
temp_file_path.display()
));
}
let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
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(anyhow!(
return Err(eyre!(
"视频文件`{}`不完整,[继续]会重新下载所有分片",
temp_file_path.display()
));
}
// 重命名临时文件
let mp4_path = episode_dir.join(format!("{filename}.mp4"));
if mp4_path.exists() {
std::fs::remove_file(&mp4_path)
.context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?;
.wrap_err(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?;
}
std::fs::rename(&temp_file_path, &mp4_path).context(format!(
std::fs::rename(&temp_file_path, &mp4_path).wrap_err(format!(
"将临时文件`{}`重命名为`{}`失败",
temp_file_path.display(),
mp4_path.display()
@@ -413,8 +465,61 @@ impl VideoTask {
}
}
#[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()
}

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

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

View File

@@ -1,51 +1,46 @@
use anyhow::Context;
use eyre::WrapErr;
use parking_lot::RwLock;
use tauri::{AppHandle, Manager, State};
use tracing::instrument;
use crate::{
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 {
@@ -53,15 +48,16 @@ pub trait GetOrInitPlayerInfo {
&'a mut self,
app: &AppHandle,
progress: &DownloadProgress,
) -> anyhow::Result<&'a mut PlayerInfo>;
) -> 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,
) -> anyhow::Result<&'a mut PlayerInfo> {
) -> eyre::Result<&'a mut PlayerInfo> {
if let Some(info) = self {
return Ok(info);
}
@@ -70,7 +66,7 @@ impl GetOrInitPlayerInfo for Option<PlayerInfo> {
let info = bili_client
.get_player_info(progress.aid, progress.cid)
.await
.context("获取播放器信息失败")?;
.wrap_err("获取播放器信息失败")?;
Ok(self.insert(info))
}

View File

@@ -7,35 +7,52 @@ 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,
@@ -52,13 +69,24 @@ pub fn run() {
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
@@ -73,7 +101,9 @@ pub fn run() {
// 解决Ubuntu24.04窗口全白的问题
#[cfg(target_os = "linux")]
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -86,9 +116,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()
))?;
@@ -104,6 +134,9 @@ pub fn run() {
logger::init(app.handle())?;
let plugin_manager = PluginManager::new(app.handle())?;
app.manage(plugin_manager);
Ok(())
})
.run(generate_context())

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("日志"))
}

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
src-tauri/src/plugin.rs Normal file
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;

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

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

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

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

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

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

View File

@@ -1,13 +1,16 @@
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;
@@ -16,12 +19,14 @@ pub mod get_history_info_params;
pub mod get_normal_info_params;
pub mod get_user_video_info_params;
pub mod history_info;
pub mod log_level;
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;

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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)]
@@ -55,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()
@@ -69,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)

View File

@@ -1,6 +1,11 @@
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 {
@@ -130,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,
}
}
}

View File

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

View File

@@ -1,6 +1,11 @@
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 {
@@ -18,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>,
@@ -122,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,
}
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,6 +1,11 @@
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 {
@@ -16,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,
@@ -34,6 +40,18 @@ pub struct DashInNormal {
pub flac: Option<Flac>,
}
#[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 {
@@ -82,7 +100,7 @@ pub struct SupportFormatInNormal {
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)]
@@ -90,3 +108,50 @@ pub struct SupportFormatInNormal {
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,
}
}
}

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

View File

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

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

View File

@@ -136,10 +136,6 @@ pub struct LabelInUserInfo {
pub img_label_uri_hant_static: String,
}
#[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 Wallet {

View File

@@ -4,8 +4,9 @@ use std::{
path::{Path, PathBuf},
};
use anyhow::{anyhow, Context};
use byteorder::{BigEndian, ReadBytesExt};
use eyre::{OptionExt, WrapErr, eyre};
use tracing::instrument;
use crate::{
danmaku_xml_to_ass::{DamakuXmlDTag, DanmakuXmlITag},
@@ -48,11 +49,12 @@ impl From<u32> for BoxSizeField {
}
}
pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
let file = File::open(file_path).context(format!("打开文件`{}`失败", file_path.display()))?;
#[instrument(level = "error", skip_all, fields(file_path = ?file_path))]
pub fn is_mp4_complete(file_path: &Path) -> eyre::Result<bool> {
let file = File::open(file_path).wrap_err(format!("打开文件`{}`失败", file_path.display()))?;
let real_size = file
.metadata()
.context(format!("获取文件`{}`元数据失败", file_path.display()))?
.wrap_err(format!("获取文件`{}`元数据失败", file_path.display()))?
.len();
let mut reader = BufReader::new(file);
let mut total_size: u64 = 0;
@@ -65,7 +67,7 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
let box_size_field: BoxSizeField = match reader.read_u32::<BigEndian>() {
Ok(s) => s.into(),
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // 正常结束
Err(e) => return Err(anyhow!(e)),
Err(e) => return Err(eyre!(e)),
};
// 读取Box类型字段
let mut box_type_bytes = [0u8; 4];
@@ -74,7 +76,7 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
return Ok(false);
}
return Err(anyhow!(e));
return Err(eyre!(e));
}
// 如果是第一个Box检查是否是 'ftyp' Box
if is_first_box {
@@ -131,11 +133,12 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
}
pub trait ToXml {
fn to_xml(&self, cid: i64) -> anyhow::Result<String>;
fn to_xml(&self, cid: i64) -> eyre::Result<String>;
}
impl ToXml for Vec<DmSegMobileReply> {
fn to_xml(&self, cid: i64) -> anyhow::Result<String> {
#[instrument(level = "error", skip_all, fields(cid = cid))]
fn to_xml(&self, cid: i64) -> eyre::Result<String> {
let elems = self
.iter()
.flat_map(|reply| &reply.elems)
@@ -157,7 +160,7 @@ impl ToXml for Vec<DmSegMobileReply> {
let i_tag = DanmakuXmlITag { chatid: cid, elems };
let xml = yaserde::ser::to_string(&i_tag).map_err(|e| anyhow!(e))?;
let xml = yaserde::ser::to_string(&i_tag).map_err(|e| eyre!(e))?;
Ok(xml)
}
@@ -177,11 +180,12 @@ pub fn seconds_to_srt_time(seconds: f64) -> String {
format!("{h:02}:{m:02}:{s:02},{ms:03}")
}
pub fn get_ffmpeg_program() -> anyhow::Result<PathBuf> {
#[instrument(level = "error", skip_all)]
pub fn get_ffmpeg_program() -> eyre::Result<PathBuf> {
let ffmpeg_program = std::env::current_exe()
.context("获取当前可执行文件路径失败")?
.wrap_err("获取当前可执行文件路径失败")?
.parent()
.context("获取当前可执行文件所在目录失败")?
.ok_or_eyre("获取当前可执行文件所在目录失败")?
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
Ok(ffmpeg_program)

View File

@@ -1,8 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context};
use eyre::{OptionExt, WrapErr, eyre};
use md5::{Digest, Md5};
use serde::Deserialize;
use tracing::instrument;
use crate::bili_client::{BiliClient, BiliResp};
@@ -25,8 +26,9 @@ struct WeiRespData {
impl BiliClient {
// 为请求参数进行 wbi 签名
pub(crate) async fn wbi(&self, params: &mut Vec<(&str, String)>) -> anyhow::Result<()> {
let (img_key, sub_key) = self.get_wbi_keys().await.context("获取wbi keys失败")?;
#[instrument(level = "error", skip_all)]
pub async fn wbi(&self, params: &mut Vec<(&str, String)>) -> eyre::Result<()> {
let (img_key, sub_key) = self.get_wbi_keys().await.wrap_err("获取wbi keys失败")?;
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
@@ -46,7 +48,8 @@ impl BiliClient {
Ok(())
}
async fn get_wbi_keys(&self) -> anyhow::Result<(String, String)> {
#[instrument(level = "error", skip_all)]
async fn get_wbi_keys(&self) -> eyre::Result<(String, String)> {
let request = self
.api_client
.read()
@@ -58,27 +61,27 @@ impl BiliClient {
let status = http_resp.status();
let body = http_resp.text().await?;
if status != reqwest::StatusCode::OK {
return Err(anyhow!("预料之外的状态码({status}): {body}"));
return Err(eyre!("预料之外的状态码({status}): {body}"));
}
// 尝试将body解析为BiliResp
let bili_resp: BiliResp =
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
serde_json::from_str(&body).wrap_err(format!("将body解析为BiliResp失败: {body}"))?;
// 检查BiliResp的data是否存在
let Some(data) = bili_resp.data else {
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
return Err(eyre!("BiliResp中不存在data字段: {bili_resp:?}"));
};
// 尝试将data解析为Data
let data_str = data.to_string();
let wei_resp_data: WeiRespData =
serde_json::from_str(&data_str).context(format!("将data解析为Data失败: {data_str}"))?;
let wei_resp_data: WeiRespData = serde_json::from_str(&data_str)
.wrap_err(format!("将data解析为Data失败: {data_str}"))?;
let img_url = wei_resp_data.wbi_img.img_url;
let sub_url = wei_resp_data.wbi_img.sub_url;
let img_filename =
take_filename(&img_url).context(format!("从img_url中提取文件名失败: {img_url}"))?;
take_filename(&img_url).ok_or_eyre(format!("从img_url中提取文件名失败: {img_url}"))?;
let sub_filename =
take_filename(&sub_url).context(format!("从sub_url中提取文件名失败: {sub_url}"))?;
take_filename(&sub_url).ok_or_eyre(format!("从sub_url中提取文件名失败: {sub_url}"))?;
Ok((img_filename, sub_filename))
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "bilibili-video-downloader",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.lanyeeee.bilibili-video-downloader",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -16,7 +16,13 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": [
"nsis",
"app",
"dmg",
"deb",
"rpm"
],
"licenseFile": "../LICENSE",
"icon": [
"icons/32x32.png",

View File

@@ -1,6 +1,15 @@
<script setup lang="ts">
import AppContent from './AppContent.vue'
import { GlobalThemeOverrides, zhCN, dateZhCN } from 'naive-ui'
import {
GlobalThemeOverrides,
zhCN,
dateZhCN,
NConfigProvider,
NDialogProvider,
NModalProvider,
NNotificationProvider,
NMessageProvider,
} from 'naive-ui'
const themeOverrides: GlobalThemeOverrides = {
common: {

View File

@@ -1,5 +1,5 @@
<script setup lang="tsx">
import { onMounted, ref, provide } from 'vue'
import { onMounted, ref, provide, useTemplateRef } from 'vue'
import { useStore } from './store.ts'
import LogDialog from './dialogs/LogDialog.vue'
import {
@@ -24,6 +24,7 @@ import DownloadPane from './panes/DownloadPane/DownloadPane.vue'
import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts'
import BangumiFollowPane from './panes/BangumiFollow/BangumiFollowPane.vue'
import HistoryPane from './panes/HistoryPane/HistoryPane.vue'
import { NBadge, NButton, NIcon, NTooltip } from 'naive-ui'
export type CurrentNavName = 'search' | 'fav' | 'history' | 'bangumi_follow' | 'watch_later' | 'download'
@@ -35,8 +36,8 @@ const logDialogShowing = ref<boolean>(false)
const aboutDialogShowing = ref<boolean>(false)
const settingsDialogShowing = ref<boolean>(false)
const searchPaneRef = ref<InstanceType<typeof SearchPane>>()
const downloadButtonRef = ref<HTMLDivElement>()
const searchPaneRef = useTemplateRef('searchPaneRef')
const downloadButtonRef = useTemplateRef('downloadButtonRef')
provide(searchPaneRefKey, searchPaneRef)
provide(navDownloadButtonRefKey, downloadButtonRef)

View File

@@ -16,6 +16,9 @@ async saveConfig(config: Config) : Promise<Result<null, CommandError>> {
else return { status: "error", error: e as any };
}
},
async getPluginInfos() : Promise<PluginInfo[]> {
return await TAURI_INVOKE("get_plugin_infos");
},
async generateQrcode() : Promise<Result<QrcodeData, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("generate_qrcode") };
@@ -119,6 +122,9 @@ async deleteDownloadTasks(taskIds: string[]) : Promise<void> {
async restartDownloadTasks(taskIds: string[]) : Promise<void> {
await TAURI_INVOKE("restart_download_tasks", { taskIds });
},
async restartDownloadTask(params: RestartDownloadTaskParams) : Promise<void> {
await TAURI_INVOKE("restart_download_task", { params });
},
async restoreDownloadTasks() : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("restore_download_tasks") };
@@ -158,6 +164,54 @@ async getSkipSegments(bvid: string, cid: number | null) : Promise<Result<SkipSeg
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAvailableMediaFormats(params: GetAvailableMediaFormatsParams) : Promise<Result<AvailableMediaFormats, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_media_formats", { params }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openLogFile(path: string) : Promise<Result<LogMetadata[], CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_log_file", { path }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async addPlugin(pluginPath: string) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("add_plugin", { pluginPath }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async uninstallPlugin(pluginPath: string) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("uninstall_plugin", { pluginPath }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPluginEnabled(pluginPath: string, enabled: boolean) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_plugin_enabled", { pluginPath, enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPluginPriority(pluginPath: string, priority: number) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_plugin_priority", { pluginPath, priority }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
@@ -166,10 +220,12 @@ async getSkipSegments(bvid: string, cid: number | null) : Promise<Result<SkipSeg
export const events = __makeEvents__<{
downloadEvent: DownloadEvent,
logEvent: LogEvent
logEvent: LogEvent,
pluginEvent: PluginEvent
}>({
downloadEvent: "download-event",
logEvent: "log-event"
logEvent: "log-event",
pluginEvent: "plugin-event"
})
/** user-defined constants **/
@@ -185,8 +241,9 @@ export type AreaInBangumi = { id: number; name: string }
export type AreaInBangumiFollow = { id: number; name: string }
export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string }
export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean }
export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean; skipped: boolean }
export type Author = { mid: number; name: string; face: string }
export type AvailableMediaFormats = { video_qualities_and_codec_types: VideoQualityAndCodecType[]; audio_qualities: AudioQuality[] }
export type BadgeInfoInBangumi = { bg_color: string; bg_color_night: string; text: string }
export type BadgeInfoInBangumiFollow = { text: string | null; bg_color: string; bg_color_night: string; img: string | null; multi_img: MultiImg }
export type BadgeInfos = { vip_or_pay: VipOrPay | null; content_attr: ContentAttr | null }
@@ -254,8 +311,8 @@ export type CheeseSearchResult = { ep: EpInCheese | null; info: CheeseInfo }
export type CntInfo = { collect: number; play: number; thumb_up: number; share: number }
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
export type CommandError = { err_title: string; err_message: string }
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; video_quality_priority: VideoQuality[]; codec_type_priority: CodecType[]; audio_quality_priority: AudioQuality[]; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; embed_skip: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig }
export type CommandError = { err_title: string; message: string }
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; video_quality_priority: VideoQuality[]; codec_type_priority: CodecType[]; audio_quality_priority: AudioQuality[]; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; embed_skip: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig; file_exist_action: FileExistAction; auto_start_download_task: boolean }
export type Consulting = { consulting_flag: boolean; consulting_url: string }
export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
export type ContentList = { bold: boolean; content: string; number: string }
@@ -265,14 +322,14 @@ export type CreateBangumiDownloadTaskParams = { ep_ids: number[]; info: BangumiI
export type CreateCheeseDownloadTaskParams = { ep_ids: number[]; info: CheeseInfo }
export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams }
export type CreateNormalDownloadTaskParams = { info: NormalInfo; aid_cid_pairs: ([number, number | null])[] }
export type DanmakuTask = { xml_selected: boolean; ass_selected: boolean; json_selected: boolean; completed: boolean }
export type DanmakuTask = { xml_selected: boolean; ass_selected: boolean; json_selected: boolean; completed: boolean; skipped: boolean }
export type DescV2 = { raw_text: string; type: number; biz_id: number }
export type DeviceType = "All" | "PC" | "Mobile" | "Pad" | "TV"
export type Dimension = { width: number; height: number; rotate: number }
export type DimensionInBangumi = { height: number; rotate: number; width: number }
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; video_process_task: VideoProcessTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null }
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; video_process_task: VideoProcessTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null; is_drm: boolean; is_preview: boolean }
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
export type Ed = { end: number; start: number }
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfoInBangumi; badge_type: number | null; bvid: string | null; cid: number; cover: string; dimension: DimensionInBangumi | null; duration: number | null; enable_vt: boolean; ep_id: number; from: string | null; id: number; is_view_hide: boolean; link: string; link_type: string | null; long_title: string | null; pub_time: number; pv: number; release_date: string | null; rights: RightsInBangumiEp | null; section_type: number; share_copy: string | null; share_url: string | null; short_link: string | null; showDrmLoginDialog: boolean; show_title: string | null; skip: Skip | null; status: number; subtitle: string | null; title: string; vid: string | null; icon_font: IconFont | null }
@@ -289,17 +346,22 @@ export type Faq1Item = { answer: string; question: string }
export type FavFolders = { count: number; list: Folder[] }
export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number }
export type FavSearchResult = FavInfo
export type FileExistAction = "Overwrite" | "Skip"
export type FirstEpInfo = { id: number; cover: string; title: string; long_title: string | null; pub_time: string; duration: number }
export type Folder = { id: number; fid: number; mid: number; attr: number; title: string; fav_state: number; media_count: number }
export type GetAvailableMediaFormatsParams = { Normal: GetNormalAvailableMediaFormatsParams } | { Bangumi: GetBangumiAvailableMediaFormatsParams } | { Cheese: GetCheeseAvailableMediaFormatsParams }
export type GetBangumiAvailableMediaFormatsParams = { cid: number }
export type GetBangumiFollowInfoParams = { vmid: number;
/**
* 1: 番剧 2: 电视剧或电影
*/
type: number; pn: number; follow_status: number }
export type GetBangumiInfoParams = { EpId: number } | { SeasonId: number }
export type GetCheeseAvailableMediaFormatsParams = { ep_id: number }
export type GetCheeseInfoParams = { EpId: number } | { SeasonId: number }
export type GetFavInfoParams = { media_list_id: number; pn: number }
export type GetHistoryInfoParams = { pn: number; keyword: string; add_time_start: number; add_time_end: number; arc_max_duration: number; arc_min_duration: number; device_type: DeviceType }
export type GetNormalAvailableMediaFormatsParams = { bvid: string; cid: number }
export type GetNormalInfoParams = { Bvid: string } | { Aid: number }
export type GetUserVideoInfoParams = { pn: number; mid: number }
export type History = { oid: number; epid: number; bvid: string; page: number; cid: number; part: string; business: string; dt: number }
@@ -314,8 +376,10 @@ export type JsonTask = { selected: boolean; completed: boolean }
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue }
export type LabelInUserInfo = { path: string; text: string; label_theme: string; text_color: string; bg_style: number; bg_color: string; border_color: string; use_img_label: boolean; img_label_uri_hans: string; img_label_uri_hant: string; img_label_uri_hans_static: string; img_label_uri_hant_static: string }
export type LevelInfoInUserInfo = { current_level: number; current_min: number; current_exp: number }
export type LogEvent = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number }
export type LogEvent = { jsonRaw: string }
export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"
export type LogMetadata = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number; span?: JsonValue; spans?: LogSpan[] }
export type LogSpan = ({ [key in string]: null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } }) & { name: string }
export type MediaChunk = { start: number; end: number; completed: boolean }
export type MediaInFav = { id: number; type: number; title: string; cover: string; intro: string; page: number; duration: number; upper: UpperInMedia; attr: number; cnt_info: CntInfoInMedia; link: string; ctime: number; pubtime: number; fav_time: number; bv_id: string; bvid: string; ugc: Ugc | null; media_list_link: string }
export type MediaInWatchLater = { aid: number; videos: number; tid: number; tname: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; redirect_url: string | null; mission_id: number | null; rights: RightsInWatchLater; owner: OwnerInWatchLater; stat: StatInWatchLater; dynamic: string; dimension: DimensionInWatchLater; short_link_v2: string; up_from_v2: number | null; first_frame: string | null; pub_location: string | null; cover43: string; tidv2: number; tnamev2: string; pid_v2: number; pid_name_v2: string; page: PageInWatchLater; count: number; cid: number; progress: number; add_at: number; bvid: string; uri: string; enable_vt: number; view_text_1: string; card_type: number; left_icon_type: number; left_text: string; right_icon_type: number; right_text: string; arc_state: number; pgc_label: string; show_up: boolean; forbid_fav: boolean; forbid_sort: boolean; season_title: string; long_title: string; index_title: string; c_source: string; season_id: number | null }
@@ -324,7 +388,7 @@ export type MultiImg = { color: string; medium_remind: string }
export type NewEp = { desc: string; id: number; is_new: number; title: string }
export type NewEpInBangumiFollow = { id: number | null; index_show: string | null; cover: string | null; title: string | null; long_title: string | null; pub_time: string | null; duration: number | null }
export type NewEpInSeason = { cover: string; id: number; index_show: string }
export type NfoTask = { selected: boolean; completed: boolean }
export type NfoTask = { selected: boolean; completed: boolean; skipped: boolean }
export type NormalInfo = { bvid: string; aid: number; videos: number; tid: number; tid_v2: number; tname: string; tname_v2: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; desc_v2: DescV2[] | null; state: number; duration: number; rights: Rights; owner: OwnerInNormal; stat: StatInNormal; argue_info: ArgueInfo; dynamic: string; cid: number; dimension: Dimension; teenage_mode: number; is_chargeable_season: boolean; is_story: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_upower_preview: boolean; enable_vt: number; vt_display: string; is_upower_exclusive_with_qa: boolean; no_cache: boolean; pages: PageInNormal[]; subtitle: SubtitleInNormal; staff: Staff[] | null; ugc_season: UgcSeason | null; is_season_display: boolean; user_garb: UserGarb; honor_reply: HonorReply; like_icon: string; need_jump_bv: boolean; disable_show_up_info: boolean; is_story_play: number; is_view_self: boolean }
export type NormalSearchResult = NormalInfo
export type Official = { role: number; title: string; desc: string; type: number }
@@ -344,6 +408,12 @@ export type PaymentInBangumi = { discount: number; pay_type: PayType; price: str
export type PendantInCheese = { image: string; name: string; pid: number }
export type PendantInUserInfo = { pid: number; name: string; image: string; expire: number; image_enhance: string; image_enhance_frame: string; n_pid: number }
export type PlayStrategy = { strategies: string[] }
export type PluginDescriptorInfo = { sdk_api_version: number; id: string; name: string; version: string; hooks: PluginHookPoint[]; failure_policy: PluginFailurePolicyInfo; description: string }
export type PluginEvent = { event: "Loaded"; data: { plugin_info: PluginInfo } } | { event: "Update"; data: { plugin_info: PluginInfo } } | { event: "Uninstall"; data: { plugin_path: string } }
export type PluginFailurePolicyInfo = "FailOpen" | "FailClosed"
export type PluginHookPoint = "BeforeVideoProcess" | "AfterPrepare" | "OnCompleted"
export type PluginInfo = { path: string; enabled: boolean; priority: number; descriptor: PluginDescriptorInfo; runtime_status: PluginRuntimeStatus }
export type PluginRuntimeStatus = "Unknown" | "Loaded" | "Disabled" | "LoadFailed"
export type Positive = { id: number; title: string }
export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string }
export type Producer = { mid: number; type: number; is_contribute: number | null; title: string }
@@ -358,6 +428,7 @@ export type QrcodeStatus = { url: string; refresh_token: string; timestamp: numb
export type RatingInBangumi = { count: number; score: number }
export type RatingInBangumiFollow = { score: number; count: number }
export type RecommendSeason = { cover: string; ep_count: string; id: number; season_url: string; subtitle: string; title: string; view: number }
export type RestartDownloadTaskParams = { task_id: string; video_task_selected: boolean; audio_task_selected: boolean; merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; subtitle_task_selected: boolean; xml_danmaku_selected: boolean; ass_danmaku_selected: boolean; json_danmaku_selected: boolean; cover_task_selected: boolean; nfo_task_selected: boolean; json_task_selected: boolean; video_quality: VideoQuality; codec_type: CodecType; audio_quality: AudioQuality }
export type Rights = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; no_background: number; clean_mode: number; is_stein_gate: number; is_360: number; no_share: number; arc_pay: number; free_watch: number }
export type RightsInBangumi = { allow_bp: number; allow_bp_rank: number; allow_download: number; allow_review: number; area_limit: number; ban_area_show: number; can_watch: number; copyright: string; forbid_pre: number; freya_white: number; is_cover_show: number; is_preview: number; only_vip_download: number; resource: string; watch_platform: number }
export type RightsInBangumiEp = { allow_dm: number; allow_download: number; area_limit: number }
@@ -402,9 +473,10 @@ export type UserStatusInCheese = { bp: number; expire_at: number; favored: numbe
export type UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
export type UserVideoList = { vlist: EpInUserVideo[] }
export type UserVideoSearchResult = UserVideoInfo
export type VideoProcessTask = { merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; completed: boolean }
export type VideoProcessTask = { merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; completed: boolean; skipped: boolean }
export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean }
export type VideoQualityAndCodecType = { video_quality: VideoQuality; codec_type: CodecType }
export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean; skipped: boolean }
export type VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number }
export type VipLabel = { path: string; text: string; label_theme: string; text_color: string; bg_style: number; bg_color: string; border_color: string; use_img_label: boolean; img_label_uri_hans: string; img_label_uri_hant: string; img_label_uri_hans_static: string; img_label_uri_hant_static: string }
export type VipOrPay = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { InputInst, InputProps } from 'naive-ui'
import { computed, ref, useTemplateRef } from 'vue'
import { InputProps, NInput, NEl } from 'naive-ui'
const props = withDefaults(
defineProps<{
@@ -19,7 +19,7 @@ const props = withDefaults(
const value = defineModel<InputProps['value']>('value', { required: true })
const focused = ref(false)
const NInputRef = ref<InputInst>()
const NInputRef = useTemplateRef('NInputRef')
const floating = computed(() => value.value !== '' || focused.value)

View File

@@ -2,6 +2,7 @@
import { getVersion } from '@tauri-apps/api/app'
import { ref, onMounted } from 'vue'
import icon from '../../src-tauri/icons/128x128.png'
import { NA, NDialog, NModal } from 'naive-ui'
const showing = defineModel<boolean>('showing', { required: true })
const version = ref('')
@@ -40,7 +41,7 @@ onMounted(async () => {
</div>
<div class="flex flex-col text-xs items-center text-gray-400">
<div>
Copyright © 2025
Copyright © 2025-2026
<n-a href="https://github.com/lanyeeee" target="_blank">lanyeeee</n-a>
</div>
<div>

View File

@@ -1,13 +1,54 @@
<script setup lang="tsx">
import { LogEvent, LogLevel, events, commands } from '../bindings.ts'
import { useNotification } from 'naive-ui'
import { onMounted, ref, watch, computed } from 'vue'
import { appDataDir } from '@tauri-apps/api/path'
import { commands, events, JsonValue, LogLevel, LogMetadata } from '../bindings.ts'
import {
NButton,
NCheckbox,
NDialog,
NInput,
NInputGroup,
NModal,
NSelect,
NTag,
useNotification,
NIcon,
} from 'naive-ui'
import {
computed,
defineComponent,
nextTick,
onMounted,
onUnmounted,
PropType,
ref,
shallowRef,
triggerRef,
useTemplateRef,
watch,
} from 'vue'
import { appDataDir, basename } from '@tauri-apps/api/path'
import { path } from '@tauri-apps/api'
import { useStore } from '../store.ts'
import { darkTheme } from 'naive-ui'
import { open } from '@tauri-apps/plugin-dialog'
import { VList } from 'virtua/vue'
import { PhArrowDown, PhArrowUp } from '@phosphor-icons/vue'
type LogRecord = LogEvent & { id: number; formatedLog: string }
export type LogField = {
key: string
value: string
}
type LogRecord = LogMetadata & {
id: number
textForFilter: string
renderData: {
message: string
extraFields: LogField[]
spanLines?: Array<{
name: string
args: LogField[]
}>
}
}
const store = useStore()
@@ -17,39 +58,28 @@ const showing = defineModel<boolean>('showing', { required: true })
let nextLogRecordId = 1
const logRecords = ref<LogRecord[]>([])
const searchText = ref<string>('')
const logLevelOptions = [
{ value: 'TRACE', label: 'TRACE' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'INFO', label: 'INFO' },
{ value: 'WARN', label: 'WARN' },
{ value: 'ERROR', label: 'ERROR' },
]
const vListRef = useTemplateRef('vListRef')
const isAtTop = ref<boolean>(false)
const isAtBottom = ref<boolean>(false)
const viewMode = ref<'live' | 'file'>('live')
const currentFileName = ref<string>('')
const liveLogRecords = shallowRef<LogRecord[]>([])
const fileLogRecords = shallowRef<LogRecord[]>([])
const filterText = ref<string>('')
const selectedLevel = ref<LogLevel>('INFO')
const logsDirSize = ref<number>(0)
onMounted(async () => {
const result = await commands.getLogsDirSize()
if (result.status === 'error') {
console.error(result.error)
return
}
// 检查日志目录大小
if (result.data > 50 * 1024 * 1024) {
notification.warning({
title: '日志目录大小超过50MB请及时清理日志文件',
description: () => (
<>
<div>
点击左下角的 <span class="bg-gray-2 px-1">日志</span> 按钮
</div>
<div>
里边有 <span class="bg-gray-2 px-1">打开日志目录</span> 按钮
</div>
<div>
你也可以在里边取消勾选 <span class="bg-gray-2 px-1">输出文件日志</span>
</div>
<div>这样将不再产生文件日志</div>
</>
),
})
}
})
const formatedLogsDirSize = computed<string>(() => {
const units = ['B', 'KB', 'MB']
let size = logsDirSize.value
@@ -63,9 +93,12 @@ const formatedLogsDirSize = computed<string>(() => {
// 保留两位小数
return `${size.toFixed(2)} ${units[unitIndex]}`
})
const filteredLogs = computed<LogRecord[]>(() => {
return logRecords.value.filter(({ level, formatedLog }) => {
// 定义日志等级的优先级顺序
// 根据模式选择数据源
const sourceRecords = viewMode.value === 'live' ? liveLogRecords.value : fileLogRecords.value
return sourceRecords.filter(({ level, textForFilter }) => {
const logLevelPriority = {
TRACE: 0,
DEBUG: 1,
@@ -77,15 +110,48 @@ const filteredLogs = computed<LogRecord[]>(() => {
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
return false
}
// 然后按搜索文本筛选
if (searchText.value === '') {
// 然后按过滤文本筛选
if (filterText.value === '') {
return true
}
return formatedLog.toLowerCase().includes(searchText.value.toLowerCase())
return textForFilter.toLowerCase().includes(filterText.value.toLowerCase())
})
})
onMounted(async () => {
const result = await commands.getLogsDirSize()
if (result.status === 'error') {
console.error(result.error)
return
}
// 检查日志目录大小
if (result.data > 50 * 1024 * 1024) {
notification.warning({
title: '日志目录大小超过50MB请及时清理日志文件',
description: () => (
<>
<div>
点击右上角的 <span class="bg-gray-2 px-1">日志</span> 按钮
</div>
<div>
里边有 <span class="bg-gray-2 px-1">打开日志目录</span> 按钮
</div>
<div>
你也可以在里边取消勾选 <span class="bg-gray-2 px-1">输出文件日志</span>
</div>
<div>这样将不再产生文件日志</div>
</>
),
})
}
})
watch(filteredLogs, async () => {
await nextTick()
updateScrollEdgeState()
})
watch(showing, async () => {
if (showing.value) {
const result = await commands.getLogsDirSize()
@@ -97,61 +163,88 @@ watch(showing, async () => {
}
})
onMounted(async () => {
await events.logEvent.listen(async ({ payload: logEvent }) => {
const logRecord: LogRecord = {
...logEvent,
id: nextLogRecordId++,
formatedLog: formatLogEvent(logEvent),
}
logRecords.value.push(logRecord)
let unListenLogEvent: () => void | undefined
onMounted(() => {
events.logEvent
.listen(({ payload: logEvent }) => {
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
const { level, fields } = logEvent
if (level === 'ERROR') {
notification.error({
title: fields['err_title'] as string,
description: fields['message'] as string,
duration: 0,
})
}
})
const logRecord = logMetadataToLogRecord(logMetadata)
liveLogRecords.value.push(logRecord)
triggerRef(liveLogRecords)
if (logRecord.level === 'ERROR') {
notification.error({
title: (logRecord.fields['err_title'] as string) || 'Error',
description: (logRecord.fields['message'] as string) || 'Unknown Error',
duration: 0,
})
}
})
.then((unListenFn) => {
unListenLogEvent = unListenFn
})
})
onUnmounted(() => {
unListenLogEvent?.()
})
function formatLogEvent(logEvent: LogEvent): string {
const { timestamp, level, fields, target, filename, line_number } = logEvent
const fields_str = Object.entries(fields)
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([key, value]) => `${key}=${value}`)
.join(' ')
return `${timestamp} ${level} ${target}: ${filename}:${line_number} ${fields_str}`
function formatJsonValue(jsonValue: JsonValue): string {
if (Array.isArray(jsonValue)) return `[${jsonValue.map(formatJsonValue).join(', ')}]`
if (typeof jsonValue === 'object' && jsonValue !== null)
return `{${Object.entries(jsonValue)
.map(([k, v]) => `${k}: ${formatJsonValue(v)}`)
.join(', ')}}`
return typeof jsonValue === 'string' ? `"${jsonValue}"` : String(jsonValue)
}
function getLevelStyles(level: LogLevel) {
switch (level) {
case 'TRACE':
return 'text-gray-400'
case 'DEBUG':
return 'text-green-400'
case 'INFO':
return 'text-blue-400'
case 'WARN':
return 'text-yellow-400'
case 'ERROR':
return 'text-red-400'
function logMetadataToLogRecord(meta: LogMetadata): LogRecord {
const message = meta.fields['message'] as string
const extraFields = Object.entries(meta.fields)
.filter(([key]) => key !== 'message')
.map(([key, jsonValue]) => ({
key,
value: formatJsonValue(jsonValue),
}))
const spanLines = meta.spans
?.slice()
.reverse()
.map((span) => {
const args = Object.entries(span)
.filter(([key]) => key !== 'name')
.map(([key, jsonValue]) => ({
key,
value: formatJsonValue(jsonValue),
}))
return { name: span.name, args }
})
const extraFieldsStr = extraFields.map((f) => `${f.key}: ${f.value}`).join(', ')
const headerLine = `${meta.timestamp} ${meta.level} ${meta.target}: ${message} ${extraFieldsStr}`
const locationLine = `at ${meta.filename}:${meta.line_number}`
const contextLines = spanLines
?.map((s) => {
const argsStr = s.args.map((a) => `${a.key}: ${a.value}`).join(', ')
return `in ${s.name} ${argsStr}`
})
.join('\n')
const textForFilter = `${headerLine}\n${locationLine}\n${contextLines}`
return {
...meta,
id: nextLogRecordId++,
textForFilter,
renderData: { message, extraFields, spanLines },
}
}
const logLevelOptions = [
{ value: 'TRACE', label: 'TRACE' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'INFO', label: 'INFO' },
{ value: 'WARN', label: 'WARN' },
{ value: 'ERROR', label: 'ERROR' },
]
function clearLogRecords() {
logRecords.value = []
nextLogRecordId = 1
function clearLiveLogRecords() {
liveLogRecords.value = []
}
async function showLogsDirInFileManager() {
@@ -161,44 +254,259 @@ async function showLogsDirInFileManager() {
console.error(result.error)
}
}
async function openLogFile() {
const logsDir = await path.join(await appDataDir(), '日志')
const selectedFilePath = await open({
defaultPath: logsDir,
filters: [{ name: 'Log Files', extensions: ['log'] }],
})
if (selectedFilePath === null) {
return
}
const result = await commands.openLogFile(selectedFilePath)
if (result.status === 'error') {
console.error(result.error)
return
}
fileLogRecords.value = result.data.map(logMetadataToLogRecord)
currentFileName.value = await basename(selectedFilePath)
viewMode.value = 'file'
}
function exitFileMode() {
viewMode.value = 'live'
currentFileName.value = ''
fileLogRecords.value = []
}
function jumpToTop() {
vListRef.value?.scrollTo(0)
}
function jumpToBottom() {
vListRef.value?.scrollToIndex(filteredLogs.value.length - 1)
}
function updateScrollEdgeState() {
if (vListRef.value === null) {
return
}
const { scrollOffset, scrollSize, viewportSize } = vListRef.value
const threshold = 50
isAtTop.value = scrollOffset <= threshold
isAtBottom.value = scrollOffset + viewportSize >= scrollSize - threshold
}
const LogRecordComponent = defineComponent({
name: 'LogRecordComponent',
props: {
logRecord: {
type: Object as PropType<LogRecord>,
required: true,
},
},
setup(props) {
const levelTextClass = computed(() => {
switch (props.logRecord.level) {
case 'TRACE':
return 'text-fuchsia-400'
case 'DEBUG':
return 'text-blue-400'
case 'INFO':
return 'text-green-400'
case 'WARN':
return 'text-amber-400'
case 'ERROR':
return 'text-red-400'
default:
return ''
}
})
const levelBoldClass = computed(() => {
switch (props.logRecord.level) {
case 'TRACE':
return 'font-bold text-fuchsia-600'
case 'DEBUG':
return 'font-bold text-blue-600'
case 'INFO':
return 'font-bold text-green-600'
case 'WARN':
return 'font-bold text-amber-600'
case 'ERROR':
return 'font-bold text-red-600'
default:
return ''
}
})
const levelTagClass = computed(() => {
switch (props.logRecord.level) {
case 'TRACE':
return 'rounded-md px-1 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 border-solid border-2 border-fuchsia-500/30'
case 'DEBUG':
return 'rounded-md px-1 py-0.5 bg-blue-500/20 text-blue-300 border-solid border-2 border-blue-500/30'
case 'INFO':
return 'rounded-md px-1 py-0.5 bg-green-500/20 text-green-300 border-solid border-2 border-green-500/30'
case 'WARN':
return 'rounded-md px-1 py-0.5 bg-amber-500/20 text-amber-300 border-solid border-2 border-amber-500/30'
case 'ERROR':
return 'rounded-md px-1 py-0.5 bg-red-500/20 text-red-300 border-solid border-2 border-red-500/30'
default:
return ''
}
})
return () => (
<div class="py-1 px-3 hover:bg-white/5 whitespace-pre-wrap break-all">
<div>
<span class="text-gray-500 whitespace-nowrap">{props.logRecord.timestamp}</span>
<span> </span>
<span class={levelTextClass.value}>
<span class={levelTagClass.value}>{props.logRecord.level}</span>
<span> </span>
<span class={levelBoldClass.value}>{props.logRecord.target}:</span>
<span> </span>
<span>{props.logRecord.renderData.message}</span>
{props.logRecord.renderData.extraFields.length > 0 && (
<span>
<span>{', '}</span>
{props.logRecord.renderData.extraFields.map(({ key, value }, i) => (
<span>
{i > 0 && <span>{', '}</span>}
<span class={levelBoldClass.value}>{key}</span>
<span>{': '}</span>
<span class="text-orange-300">{value}</span>
</span>
))}
</span>
)}
</span>
</div>
<div class="text-gray-300">
<span>{' '}</span>
<span class="text-gray-500">at</span>
<span> </span>
<span>
{props.logRecord.filename}:{props.logRecord.line_number}
</span>
</div>
{props.logRecord.renderData.spanLines?.map((span, idx) => (
<div key={idx} class="text-gray-300">
<span>{' '}</span>
<span class="text-gray-500">in</span>
<span> </span>
<span class="font-bold text-indigo-300">{span.name}</span>
{span.args.length > 0 && (
<span>
<span> </span>
<span class="text-gray-500">with</span>
<span> </span>
{span.args.map((arg, i) => (
<span>
{i > 0 && <span>{', '}</span>}
<span class="font-bold text-gray-300">{arg.key}</span>
<span>: </span>
<span class="text-orange-300">{arg.value}</span>
</span>
))}
</span>
)}
</div>
))}
</div>
)
},
})
</script>
<template>
<n-modal v-model:show="showing" v-if="store.config !== undefined">
<n-dialog
:showIcon="false"
:title="`日志目录总大小:${formatedLogsDirSize}`"
@close="showing = false"
style="width: 95%">
<div class="mb-2 flex flex-wrap gap-2">
<n-input-group class="w-100">
<n-input size="small" v-model:value="searchText" placeholder="搜素日志..." clearable />
<n-select size="small" v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
<n-dialog :showIcon="false" @close="showing = false" style="width: 95%">
<template #header>
<div class="text-lg font-bold flex items-center gap-2">
<span v-if="viewMode === 'live'">📡 实时日志</span>
<span v-else>
📂 文件日志
<n-tag class="ml-2" type="primary" size="small">
{{ currentFileName }}
</n-tag>
</span>
</div>
</template>
<div class="mb-2 flex flex-wrap">
<n-input-group class="flex-1 mr-4">
<n-input v-model:value="filterText" placeholder="关键词过滤..." clearable />
<n-select v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
</n-input-group>
<div class="flex flex-wrap gap-2 ml-auto items-center">
<n-button size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
<n-button v-if="viewMode === 'file'" class="mr-2" type="primary" secondary @click="exitFileMode">
返回实时日志
</n-button>
<n-button type="primary" @click="openLogFile">打开日志文件</n-button>
</div>
<div class="relative h-[calc(100vh-250px)]!">
<VList
ref="vListRef"
class="h-full overflow-hidden bg-gray-950 text-sm"
:data="filteredLogs"
@scroll="updateScrollEdgeState"
#default="{ item }: { item: LogRecord }">
<LogRecordComponent :key="item.id" :logRecord="item" />
</VList>
<div v-show="isAtTop === false" class="absolute top-6 right-6">
<n-button circle type="primary" class="opacity-30 hover:opacity-100 transition-opacity" @click="jumpToTop">
<template #icon>
<n-icon>
<PhArrowUp />
</n-icon>
</template>
</n-button>
</div>
<div v-show="isAtBottom === false" class="absolute bottom-6 right-6">
<n-button circle type="primary" class="opacity-30 hover:opacity-100 transition-opacity" @click="jumpToBottom">
<template #icon>
<n-icon>
<PhArrowDown />
</n-icon>
</template>
</n-button>
</div>
</div>
<n-config-provider :theme="darkTheme" :theme-overrides="{ Scrollbar: { width: '8px' } }">
<n-virtual-list
class="h-[calc(100vh-300px)] overflow-hidden bg-gray-900"
:item-size="42"
item-resizable
:hoverable="false"
:items="filteredLogs"
:scrollbar-props="{ trigger: 'none' }">
<template #default="{ item: { level, formatedLog } }: { item: LogRecord }">
<div :class="['py-1 px-3 hover:bg-white/10 whitespace-pre-wrap mr-4', getLevelStyles(level)]">
{{ formatedLog }}
</div>
</template>
</n-virtual-list>
</n-config-provider>
<div class="pt-1 flex">
<n-button ghost class="ml-auto" size="small" type="error" @click="clearLogRecords">清空日志浏览器</n-button>
<div class="pt-2 flex flex-wrap items-center">
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
<n-button class="ml-2" size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
<n-tag class="ml-1" size="small" :bordered="false">
{{ formatedLogsDirSize }}
</n-tag>
<n-button
v-if="viewMode === 'live'"
ghost
class="ml-auto"
size="small"
type="error"
@click="clearLiveLogRecords">
清空实时日志
</n-button>
</div>
</n-dialog>
</n-modal>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { commands, QrcodeData, QrcodeStatus } from '../bindings.ts'
import { ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { NDialog, NModal, NQrCode, NTabPane, NTabs, useMessage } from 'naive-ui'
import { useStore } from '../store.ts'
import icon from '../../src-tauri/icons/128x128.png'
import FloatLabelInput from '../components/FloatLabelInput.vue'

View File

@@ -5,11 +5,11 @@ import { path } from '@tauri-apps/api'
import { appDataDir } from '@tauri-apps/api/path'
import { useStore } from '../../store.ts'
import DownloadSettings from './components/DownloadSettings.vue'
import ProxySettings from './components/ProxySettings.vue'
import FmtSettings from './components/FmtSettings.vue'
import DownloadSpeedSettings from './components/DownloadSpeedSettings.vue'
import NetworkSettings from './components/NetworkSettings.vue'
import AssDanmakuSettings from './components/AssDanmakuSettings.vue'
import { useMessage } from 'naive-ui'
import PluginSettings from './components/PluginSettings.vue'
import { NButton, NDialog, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
const store = useStore()
@@ -52,9 +52,9 @@ async function showConfigInFileManager() {
<template>
<n-modal v-if="store.config !== undefined" v-model:show="showing">
<n-dialog :showIcon="false" title="配置" content-style="" @close="showing = false">
<n-dialog :showIcon="false" content-style="" @close="showing = false">
<div class="flex flex-col gap-row-2">
<n-tabs class="h-full" v-model:value="currentTabName" type="line" size="small" animated>
<n-tabs class="h-full settings-tabs" v-model:value="currentTabName" type="line" size="small" animated>
<n-tab-pane name="download_settings" tab="下载内容">
<DownloadSettings />
</n-tab-pane>
@@ -64,15 +64,21 @@ async function showConfigInFileManager() {
<n-tab-pane name="ass_danmaku_settings" tab="ass弹幕">
<AssDanmakuSettings />
</n-tab-pane>
<n-tab-pane name="download_speed_settings" tab="下载速度">
<DownloadSpeedSettings />
<n-tab-pane name="network_settings" tab="网络">
<NetworkSettings />
</n-tab-pane>
<n-tab-pane name="proxy_settings" tab="代理">
<ProxySettings />
<n-tab-pane name="plugin_settings" tab="插件">
<PluginSettings />
</n-tab-pane>
</n-tabs>
<n-button class="ml-auto mt-2" size="small" @click="showConfigInFileManager">打开配置目录</n-button>
<n-button
v-if="currentTabName !== 'plugin_settings'"
class="ml-auto mt-2"
size="small"
@click="showConfigInFileManager">
打开配置目录
</n-button>
</div>
</n-dialog>
</n-modal>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useStore } from '../../../store.ts'
import { NTooltip, NInputGroupLabel, NCheckbox, NInputNumber, NInput, NInputGroup } from 'naive-ui'
const store = useStore()
</script>

View File

@@ -1,44 +1,15 @@
<script setup lang="ts">
import { AudioQuality, VideoQuality, CodecType } from '../../../bindings.ts'
import { useStore } from '../../../store.ts'
import { VueDraggable } from 'vue-draggable-plus'
import ColorfulTag from '../../../components/ColorfulTag.vue'
import { getVideoQualityName, getAudioQualityName, getCodecTypeName } from '../../../utils.tsx'
import { NTooltip, NCheckbox, NRadioGroup, NRadioButton } from 'naive-ui'
const store = useStore()
const videoQualityNameMap: Map<VideoQuality, string> = new Map([
['240P', '240P 极速'],
['360P', '360P 流畅'],
['480P', '480P 标清'],
['720P', '720P 准高清'],
['720P60', '720P 60帧'],
['1080P', '1080P 高清'],
['AiRepair', 'AI智能修复'],
['1080P+', '1080P 高码率'],
['1080P60', '1080P 60帧'],
['4K', '4K 超高清'],
['HDR', 'HDR 真彩色'],
['Dolby', '杜比视界'],
['8K', '8K 超高清'],
])
const audioQualityNameMap: Map<AudioQuality, string> = new Map([
['64K', '64K'],
['132K', '132K'],
['192K', '192K'],
['Dolby', '杜比全景声'],
['HiRes', 'Hi-Res 无损'],
])
const codecTypeNameMap: Map<CodecType, string> = new Map([
['AVC', 'AVC (H.264)'],
['HEVC', 'HEVC (H.265)'],
['AV1', 'AV1'],
])
</script>
<template>
<div v-if="store.config !== undefined" class="flex flex-col gap-row-2">
<div v-if="store.config !== undefined" class="flex flex-col gap-row-1">
<div class="flex gap-2">
<span class="w-15 font-bold">主要内容</span>
<n-checkbox class="w-22" v-model:checked="store.config.download_video">下载视频</n-checkbox>
@@ -83,7 +54,7 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
</div>
<div class="flex gap-2">
<span class="w-14 w-15 font-bold">元数据</span>
<span class="w-15 font-bold">元数据</span>
<n-tooltip placement="top" trigger="hover">
<div>还会顺便下载poster和fanart(如果有的话)</div>
<template #trigger>
@@ -109,7 +80,7 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
color="blue"
v-for="videoQuality in store.config.video_quality_priority"
:key="videoQuality">
{{ videoQualityNameMap.get(videoQuality) || videoQuality }}
{{ getVideoQualityName(videoQuality) }}
</ColorfulTag>
</VueDraggable>
</div>
@@ -129,7 +100,7 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
color="blue"
v-for="audioQuality in store.config.audio_quality_priority"
:key="audioQuality">
{{ audioQualityNameMap.get(audioQuality) || audioQuality }}
{{ getAudioQualityName(audioQuality) }}
</ColorfulTag>
</VueDraggable>
</div>
@@ -148,11 +119,26 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
color="blue"
v-for="codecType in store.config.codec_type_priority"
:key="codecType">
{{ codecTypeNameMap.get(codecType) || codecType }}
{{ getCodecTypeName(codecType, { AVC: 'AVC (H.264)', HEVC: 'HEVC (H.265)' }) }}
</ColorfulTag>
</VueDraggable>
</div>
</div>
<div class="flex flex-col">
<span class="font-bold">文件已存在时</span>
<n-radio-group v-model:value="store.config.file_exist_action" size="small">
<n-radio-button value="Overwrite">覆盖旧文件</n-radio-button>
<n-radio-button value="Skip">跳过下载</n-radio-button>
</n-radio-group>
</div>
<div class="flex flex-col">
<span class="font-bold">其他</span>
<n-checkbox class="w-fit" v-model:checked="store.config.auto_start_download_task">
创建下载任务后自动开始
</n-checkbox>
</div>
</div>
</template>

View File

@@ -1,77 +0,0 @@
<script setup lang="ts">
import { useStore } from '../../../store.ts'
import { useMessage } from 'naive-ui'
const message = useMessage()
const store = useStore()
</script>
<template>
<div v-if="store.config !== undefined" class="flex flex-col gap-row-2">
<div class="flex gap-1">
<n-tooltip placement="top" trigger="hover">
<div>最多有多少个任务同时下载</div>
<template #trigger>
<n-input-group class="w-40%">
<n-input-group-label size="small">任务并发</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.task_concurrency"
size="small"
@update:value="message.warning('对任务并发的修改需要重启才能生效')"
:min="1"
:parse="(x: string) => Number(x)" />
</n-input-group>
</template>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<div>每个任务下载完成后休息多久</div>
<template #trigger>
<n-input-group class="w-60%">
<n-input-group-label size="small">任务下载间隔</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.task_download_interval_sec"
size="small"
:min="0"
:parse="(x: string) => Number(x)" />
<n-input-group-label size="small"></n-input-group-label>
</n-input-group>
</template>
</n-tooltip>
</div>
<div class="flex gap-1">
<n-tooltip placement="top" trigger="hover">
<div>最多有多少个分片同时下载</div>
<template #trigger>
<n-input-group class="w-40%">
<n-input-group-label size="small">分片并发</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.chunk_concurrency"
size="small"
@update-value="message.warning('对分片并发的修改需要重启才能生效')"
:min="1"
:parse="(x: string) => Number(x)" />
</n-input-group>
</template>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<div>每个分片下载完成后休息多久</div>
<template #trigger>
<n-input-group class="w-60%">
<n-input-group-label size="small">分片下载间隔</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.chunk_download_interval_sec"
size="small"
:min="0"
:parse="(x: string) => Number(x)" />
<n-input-group-label size="small"></n-input-group-label>
</n-input-group>
</template>
</n-tooltip>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="tsx">
import { ref } from 'vue'
import { useStore } from '../../../store.ts'
import { NA, NConfigProvider, NInput, NPopover, NTooltip } from 'naive-ui'
const store = useStore()
@@ -65,7 +66,20 @@ function AvailableFmtFields() {
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">episode_order</span>
<span class="ml-2">在合集里的序号(从1起)</span>
<span class="ml-2">在合集里的序号(从1起)</span>
<NPopover placement="top" trigger="hover">
{{
trigger: () => <span class="text-blue">支持补齐</span>,
default: () => (
<div class="text-xs">
<span>示例</span>
<span class="rounded bg-gray-300 px-1 select-all font-mono">{'{episode_order:0>4}'}</span>
<span>表示用0补齐4位</span>
<span class="mr-2">例如 13 &rarr; 0013</span>
</div>
),
}}
</NPopover>
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">part_title</span>
@@ -73,8 +87,22 @@ function AvailableFmtFields() {
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">part_order</span>
<span class="ml-2">分P序号</span>
<span class="ml-2">分P序号</span>
<NPopover placement="top" trigger="hover">
{{
trigger: () => <span class="text-blue">支持补齐</span>,
default: () => (
<div class="text-xs">
<span>示例</span>
<span class="rounded bg-gray-300 px-1 select-all font-mono">{'{part_order:0>4}'}</span>
<span>表示用0补齐4位</span>
<span class="mr-2">例如 13 &rarr; 0013</span>
</div>
),
}}
</NPopover>
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">up_name</span>
<span class="ml-2">up昵称</span>
@@ -87,6 +115,18 @@ function AvailableFmtFields() {
<span class="rounded bg-gray-500 px-1 select-all">create_ts</span>
<span class="ml-2">下载任务创建的时间</span>
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">video_quality</span>
<span class="ml-2">画质(Unknown / 1080P / 1080P60 / AiRepair / 4K / Dolby ...)</span>
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">codec_type</span>
<span class="ml-2">编码(Unknown / AVC / HEVC / AV1 / Audio)</span>
</div>
<div>
<span class="rounded bg-gray-500 px-1 select-all">audio_quality</span>
<span class="ml-2">音质(Unknown / 64K / 132K / 192K / Dolby / HiRes)</span>
</div>
</>
)
}

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useStore } from '../../../store.ts'
import {
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NRadioButton,
NRadioGroup,
NTooltip,
useMessage,
} from 'naive-ui'
import { ref } from 'vue'
const message = useMessage()
const store = useStore()
const proxyHost = ref<string>(store.config?.proxy_host ?? '')
</script>
<template>
<div v-if="store.config !== undefined" class="flex flex-col gap-row-2">
<div class="flex flex-col">
<span class="font-bold">代理类型</span>
<n-radio-group v-model:value="store.config.proxy_mode" size="small">
<n-radio-button value="NoProxy">直连</n-radio-button>
<n-radio-button value="System">系统代理</n-radio-button>
<n-radio-button value="Custom">自定义</n-radio-button>
</n-radio-group>
<n-input-group v-if="store.config.proxy_mode === 'Custom'" class="mt-1">
<n-input-group-label size="small">http://</n-input-group-label>
<n-input
v-model:value="proxyHost"
size="small"
placeholder=""
@blur="store.config.proxy_host = proxyHost"
@keydown.enter="store.config.proxy_host = proxyHost" />
<n-input-group-label size="small">:</n-input-group-label>
<n-input-number
v-model:value="store.config.proxy_port"
size="small"
placeholder=""
:parse="(x: string) => parseInt(x)" />
</n-input-group>
</div>
<div class="flex flex-col gap-row-1">
<span class="font-bold">下载速度</span>
<div class="flex gap-1">
<n-tooltip placement="top" trigger="hover">
<div>最多有多少个任务同时下载</div>
<template #trigger>
<n-input-group class="w-40%">
<n-input-group-label size="small">任务并发</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.task_concurrency"
size="small"
@update:value="message.warning('对任务并发的修改需要重启才能生效')"
:min="1"
:parse="(x: string) => Number(x)" />
</n-input-group>
</template>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<div>每个任务下载完成后休息多久</div>
<template #trigger>
<n-input-group class="w-60%">
<n-input-group-label size="small">任务下载间隔</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.task_download_interval_sec"
size="small"
:min="0"
:parse="(x: string) => Number(x)" />
<n-input-group-label size="small"></n-input-group-label>
</n-input-group>
</template>
</n-tooltip>
</div>
<div class="flex gap-1">
<n-tooltip placement="top" trigger="hover">
<div>最多有多少个分片同时下载</div>
<template #trigger>
<n-input-group class="w-40%">
<n-input-group-label size="small">分片并发</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.chunk_concurrency"
size="small"
@update-value="message.warning('对分片并发的修改需要重启才能生效')"
:min="1"
:parse="(x: string) => Number(x)" />
</n-input-group>
</template>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<div>每个分片下载完成后休息多久</div>
<template #trigger>
<n-input-group class="w-60%">
<n-input-group-label size="small">分片下载间隔</n-input-group-label>
<n-input-number
class="w-full"
v-model:value="store.config.chunk_download_interval_sec"
size="small"
:min="0"
:parse="(x: string) => Number(x)" />
<n-input-group-label size="small"></n-input-group-label>
</n-input-group>
</template>
</n-tooltip>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,389 @@
<script setup lang="tsx">
import { computed, defineComponent, onMounted, onUnmounted, ref, useTemplateRef, type PropType } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { NButton, NCollapseTransition, NInputNumber, NSwitch, NTag, useMessage, NEmpty, NIcon } from 'naive-ui'
import { commands, events, PluginInfo } from '../../../bindings.ts'
import { PhPlusCircle } from '@phosphor-icons/vue'
import { path } from '@tauri-apps/api'
import { appDataDir } from '@tauri-apps/api/path'
import { getCurrentWindow } from '@tauri-apps/api/window'
const message = useMessage()
const allowedPluginExtensions = new Set(['.dll', '.so', '.dylib'])
const pluginInfos = ref<Map<string, PluginInfo>>(new Map())
const sortedPluginInfos = computed<PluginInfo[]>(() =>
Array.from(pluginInfos.value.values()).sort((a, b) => b.priority - a.priority),
)
const expandedPluginIds = ref<Set<string>>(new Set())
const dropZoneRef = useTemplateRef('dropZoneRef')
const isDragOverDropZone = ref<boolean>(false)
onMounted(async () => {
const infos = await commands.getPluginInfos()
const pluginMap = new Map<string, PluginInfo>()
for (const info of infos) {
pluginMap.set(info.path, info)
}
pluginInfos.value = pluginMap
})
let unListenPluginEvent: (() => void) | undefined
let unListenDragDropEvent: (() => void) | undefined
onMounted(() => {
events.pluginEvent
.listen(({ payload: { event, data } }) => {
if (event === 'Loaded') {
const info = data.plugin_info
pluginInfos.value.set(info.path, info)
} else if (event === 'Update') {
const info = data.plugin_info
const pluginInfo = pluginInfos.value.get(info.path)
if (pluginInfo !== undefined) {
Object.assign(pluginInfo, info)
}
} else if (event === 'Uninstall') {
const pluginPath = data.plugin_path
pluginInfos.value.delete(pluginPath)
expandedPluginIds.value.delete(pluginPath)
}
})
.then((unListenFn) => {
unListenPluginEvent = unListenFn
})
getCurrentWindow()
.onDragDropEvent(({ payload }) => {
if (payload.type === 'leave') {
isDragOverDropZone.value = false
return
}
if (payload.type === 'enter' || payload.type === 'over') {
isDragOverDropZone.value = isPointInDropZone(payload.position.x, payload.position.y)
return
}
const droppingOnDropZone = isPointInDropZone(payload.position.x, payload.position.y)
isDragOverDropZone.value = false
if (!droppingOnDropZone) {
return
}
addPluginsByPaths(payload.paths)
})
.then((unListenFn) => {
unListenDragDropEvent = unListenFn
})
})
onUnmounted(() => {
unListenPluginEvent?.()
unListenDragDropEvent?.()
})
function getFileName(pluginPath: string): string {
const normalizedPath = pluginPath.replace(/\\/g, '/')
const parts = normalizedPath.split('/')
const filename = parts[parts.length - 1]
return filename === '' ? pluginPath : filename
}
function getFileExtension(pluginPath: string): string {
const filename = getFileName(pluginPath)
const dotIndex = filename.lastIndexOf('.')
if (dotIndex <= 0) {
return ''
}
return filename.slice(dotIndex).toLowerCase()
}
function isPluginDynamicLibrary(pluginPath: string): boolean {
return allowedPluginExtensions.has(getFileExtension(pluginPath))
}
function isPointInDropZone(rawX: number, rawY: number): boolean {
const dropZone = dropZoneRef.value
if (dropZone === null) {
return false
}
const rect = dropZone.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const candidates: Array<[number, number]> = [[rawX, rawY]]
if (dpr !== 1) {
candidates.push([rawX / dpr, rawY / dpr])
}
return candidates.some(([x, y]) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom)
}
async function addPluginByPath(pluginPath: string) {
const result = await commands.addPlugin(pluginPath)
const filename = getFileName(pluginPath)
if (result.status === 'error') {
console.error(result.error)
return
}
message.success(`添加插件成功: ${filename}`)
}
async function addPluginsByPaths(pluginPaths: string[]) {
for (const pluginPath of pluginPaths) {
const filename = getFileName(pluginPath)
if (!isPluginDynamicLibrary(pluginPath)) {
message.warning(`跳过非插件文件: ${filename}`)
continue
}
await addPluginByPath(pluginPath)
}
}
async function addPlugin() {
const selectedPath = await open({
directory: false,
multiple: false,
filters: [{ name: '', extensions: ['dll', 'so', 'dylib'] }],
})
if (selectedPath === null) {
return
}
await addPluginByPath(selectedPath)
}
async function showPluginConfigInFileManager() {
const configPath = await path.join(await appDataDir(), 'plugin.json')
const result = await commands.showPathInFileManager(configPath)
if (result.status === 'error') {
console.error(result.error)
}
}
const PluginCard = defineComponent({
name: 'PluginCard',
props: {
pluginInfo: {
type: Object as PropType<PluginInfo>,
required: true,
},
},
setup(props) {
function toggleAdvanced(pluginPath: string) {
if (expandedPluginIds.value.has(pluginPath)) {
expandedPluginIds.value.delete(pluginPath)
} else {
expandedPluginIds.value.add(pluginPath)
}
}
function isAdvancedShown(pluginPath: string): boolean {
return expandedPluginIds.value.has(pluginPath)
}
return () => {
async function uninstallPlugin(pluginPath: string) {
const result = await commands.uninstallPlugin(pluginPath)
if (result.status === 'error') {
console.error(result.error)
return
}
expandedPluginIds.value.delete(pluginPath)
message.success('卸载插件成功')
}
async function updateEnabled(pluginPath: string, enabled: boolean) {
const pluginInfo = pluginInfos.value.get(pluginPath)
if (pluginInfo === undefined) {
return
}
const prevEnabled = pluginInfo.enabled
pluginInfo.enabled = enabled
const result = await commands.setPluginEnabled(pluginPath, enabled)
if (result.status === 'error') {
console.error(result.error)
pluginInfo.enabled = prevEnabled
}
}
async function updatePriority(pluginPath: string, priority: number | null) {
if (priority === null) {
return
}
const pluginInfo = pluginInfos.value.get(pluginPath)
if (pluginInfo === undefined) {
return
}
const prevPriority = pluginInfo.priority
const result = await commands.setPluginPriority(pluginPath, priority)
if (result.status === 'error') {
console.error(result.error)
pluginInfo.priority = prevPriority
}
}
type TagType = 'error' | 'default' | 'primary' | 'info' | 'success' | 'warning'
function getRuntimeStatusMeta(status: PluginInfo['runtime_status']): { text: string; type: TagType } {
switch (status) {
case 'Loaded':
return { text: '已加载', type: 'success' }
case 'Disabled':
return { text: '未启用', type: 'warning' }
case 'LoadFailed':
return { text: '加载失败', type: 'error' }
case 'Unknown':
return { text: '待加载', type: 'info' }
}
}
function getFailurePolicyMeta(policy: PluginInfo['descriptor']['failure_policy']): {
text: string
type: TagType
} {
if (policy === 'FailClosed') {
return { text: 'FailClosed', type: 'warning' }
}
return { text: 'FailOpen', type: 'info' }
}
const runtimeStatus = getRuntimeStatusMeta(props.pluginInfo.runtime_status)
const failurePolicy = getFailurePolicyMeta(props.pluginInfo.descriptor.failure_policy)
const advancedShowing = isAdvancedShown(props.pluginInfo.path)
return (
<div class="rounded border-1 border-solid border-gray-3 px-3 py-2 flex flex-col gap-1">
<div class="flex items-center flex-wrap gap-2">
<span class="font-bold text-sm">{props.pluginInfo.descriptor.name}</span>
<NTag size="small">v{props.pluginInfo.descriptor.version}</NTag>
<NTag size="small" type={runtimeStatus.type}>
{runtimeStatus.text}
</NTag>
</div>
<div class="text-xs text-gray-6 break-words">{props.pluginInfo.descriptor.description}</div>
<div class="flex flex-col">
<NCollapseTransition show={advancedShowing}>
<div class="flex flex-col gap-1">
<div class="text-xs break-all">
<span class="font-bold">SDK API:</span>
<span class="ml-1">{props.pluginInfo.descriptor.sdk_api_version}</span>
</div>
<div class="text-xs break-all">
<span class="font-bold">ID:</span>
<span class="ml-1">{props.pluginInfo.descriptor.id}</span>
</div>
<div class="text-xs break-all">
<span class="font-bold">路径:</span>
<span class="ml-1">{props.pluginInfo.path}</span>
</div>
<div class="flex items-center gap-1 flex-wrap">
<span class="text-xs font-bold">Hooks:</span>
{props.pluginInfo.descriptor.hooks.map((hookPoint) => (
<NTag key={hookPoint} size="small">
{hookPoint}
</NTag>
))}
</div>
<div class="flex items-center gap-1">
<span class="text-xs font-bold">失败策略:</span>
<NTag size="small" type={failurePolicy.type}>
{failurePolicy.text}
</NTag>
</div>
</div>
</NCollapseTransition>
<div class="flex gap-4">
<div class="flex items-center gap-2">
<span class="text-sm">启用</span>
<NSwitch
size="small"
value={props.pluginInfo.enabled}
onUpdate:value={(value: boolean) => updateEnabled(props.pluginInfo.path, value)}
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">优先级</span>
<NInputNumber
class="w-26"
size="small"
value={props.pluginInfo.priority}
onUpdate:value={(value: number | null) => updatePriority(props.pluginInfo.path, value)}
parse={(x: string) => Number(x)}
/>
</div>
</div>
<div class="flex w-full items-center gap-2">
<NButton size="small" onClick={() => toggleAdvanced(props.pluginInfo.path)}>
{advancedShowing ? '隐藏高级信息' : '显示高级信息'}
</NButton>
<NButton
class="ml-auto"
size="small"
type="error"
ghost
onClick={() => uninstallPlugin(props.pluginInfo.path)}>
卸载
</NButton>
</div>
</div>
</div>
)
}
},
})
</script>
<template>
<div class="flex flex-col gap-row-2">
<n-empty v-if="sortedPluginInfos.length === 0" description="暂无插件,点击 添加插件 按钮导入插件" />
<div v-else class="flex flex-col gap-2 max-h-60vh overflow-auto overflow-x-hidden pr-1">
<PluginCard v-for="pluginInfo in sortedPluginInfos" :key="pluginInfo.path" :plugin-info="pluginInfo" />
</div>
<div class="mt-2 flex items-center gap-2">
<div
ref="dropZoneRef"
class="flex items-center gap-2 rounded-md border border-dashed px-2 py-1 transition-colors"
:class="isDragOverDropZone ? 'border-sky-4 bg-sky-1/70' : 'border-gray-3 bg-transparent'">
<n-button size="small" type="primary" @click="addPlugin">
<template #icon>
<n-icon size="20">
<PhPlusCircle />
</n-icon>
</template>
添加插件
</n-button>
<span class="text-xs text-gray-6">也可以拖入此处导入</span>
</div>
<n-button class="ml-auto" size="small" @click="showPluginConfigInFileManager">打开配置目录</n-button>
</div>
</div>
</template>

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useStore } from '../../../store.ts'
const store = useStore()
const proxyHost = ref<string>(store.config?.proxy_host ?? '')
const disableProxyHostAndPort = computed(() => store.config?.proxy_mode !== 'Custom')
</script>
<template>
<div v-if="store.config !== undefined" class="flex flex-col gap-row-2">
<n-radio-group v-model:value="store.config.proxy_mode" size="small">
<n-radio-button value="NoProxy">直连</n-radio-button>
<n-radio-button value="System">系统代理</n-radio-button>
<n-radio-button value="Custom">自定义</n-radio-button>
</n-radio-group>
<n-input-group>
<n-input-group-label size="small">http://</n-input-group-label>
<n-input
:disabled="disableProxyHostAndPort"
v-model:value="proxyHost"
size="small"
placeholder=""
@blur="store.config.proxy_host = proxyHost"
@keydown.enter="store.config.proxy_host = proxyHost" />
<n-input-group-label size="small">:</n-input-group-label>
<n-input-number
:disabled="disableProxyHostAndPort"
v-model:value="store.config.proxy_port"
size="small"
placeholder=""
:parse="(x: string) => parseInt(x)" />
</n-input-group>
</div>
</template>

View File

@@ -1,6 +1,6 @@
import type { InjectionKey, Ref } from 'vue'
import SearchPane from './panes/SearchPane/SearchPane.vue'
export const navDownloadButtonRefKey = Symbol() as InjectionKey<Ref<HTMLDivElement | undefined>>
export const navDownloadButtonRefKey = Symbol() as InjectionKey<Ref<HTMLDivElement | null>>
export const searchPaneRefKey = Symbol() as InjectionKey<Ref<InstanceType<typeof SearchPane> | undefined>>
export const searchPaneRefKey = Symbol() as InjectionKey<Ref<InstanceType<typeof SearchPane> | null>>

View File

@@ -4,8 +4,14 @@ import App from './App.vue'
import 'virtual:uno.css'
import 'lazysizes'
import 'lazysizes/plugins/parent-fit/ls.parent-fit'
import VueScan, { type VueScanOptions } from 'z-vue-scan'
const pinia = createPinia()
const app = createApp(App)
const isProduction = import.meta.env.PROD
if (!isProduction) {
app.use<VueScanOptions>(VueScan, {})
}
app.use(pinia).mount('#app')

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