mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-05-11 18:10:36 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7261e30f5 | ||
|
|
a39baa2b9a | ||
|
|
be62683caa | ||
|
|
d8e9efef87 | ||
|
|
b5698f8d43 | ||
|
|
43d9e8fe4d | ||
|
|
448b329a2a | ||
|
|
e240e719ce | ||
|
|
933f8000dd | ||
|
|
6be3289d93 | ||
|
|
02d157ba70 | ||
|
|
d1be51bec1 | ||
|
|
e757c6b64e | ||
|
|
15cdb30763 | ||
|
|
32c175ec2e | ||
|
|
b5db04f5bd | ||
|
|
5478edc770 | ||
|
|
6ebdc83104 | ||
|
|
ef75daf6e1 | ||
|
|
b8aeff7cb2 | ||
|
|
7065a4ce73 | ||
|
|
49fac05435 | ||
|
|
c560277589 | ||
|
|
4cfafd4306 | ||
|
|
74af53f2e4 | ||
|
|
ed368c1c6e | ||
|
|
3bfc9c272d | ||
|
|
04cc05856c | ||
|
|
a45a85ee56 | ||
|
|
59e28536e1 | ||
|
|
2c40806eaa | ||
|
|
143bd0e773 | ||
|
|
8a91ae9a11 | ||
|
|
fa1191d0bb | ||
|
|
2a296da7b1 | ||
|
|
e8702fb734 | ||
|
|
b988e9ff15 | ||
|
|
fe36c84426 | ||
|
|
c21e92c3a5 | ||
|
|
89666f1559 | ||
|
|
bbfa31d4fa | ||
|
|
b7b99a8e43 | ||
|
|
ff573f1de9 | ||
|
|
bde649575f | ||
|
|
fa5be81fb8 | ||
|
|
f09e8cfc49 | ||
|
|
c9d377239b | ||
|
|
bfba2a1ab5 | ||
|
|
b4851e4143 | ||
|
|
4bfe2df405 | ||
|
|
8c5cb7fa5c | ||
|
|
948fecb7cd | ||
|
|
747300ef30 | ||
|
|
813c0757b4 | ||
|
|
e2974ed0f6 | ||
|
|
dab9c21366 | ||
|
|
91a81fb1ba | ||
|
|
d76130a71f | ||
|
|
c96f69892f | ||
|
|
588831ff1b | ||
|
|
72811e3b7b | ||
|
|
7055dbac1f | ||
|
|
8a498b0f6a | ||
|
|
d8f350df9a | ||
|
|
ca3e106be0 | ||
|
|
ccdb0b5e5a | ||
|
|
19df52b4bc |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
17
.github/pull_request_template.md
vendored
Normal 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
67
.github/workflows/check-backend.yml
vendored
Normal 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
46
.github/workflows/check-format.yml
vendored
Normal 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
66
.github/workflows/check-frontend.yml
vendored
Normal 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
35
.prettierignore
Normal 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/
|
||||
2
LICENSE
2
LICENSE
@@ -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
144
README.md
@@ -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 ⭐ 支持!你的支持是我持续更新维护的动力 🙏**
|
||||
|
||||
## 🖥️图形界面
|
||||
|
||||

|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
## 主要功能演示
|
||||
| 特性 | 说明 |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🖥️图形界面 | 基于 [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)
|
||||
|
||||
## 支持的配置
|
||||
## ⚠️关于被杀毒软件误判为病毒
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
对于个人开发的项目来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~)
|
||||
我能想到的解决办法只有:
|
||||
|
||||
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
75
auto-imports.d.ts
vendored
@@ -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
53
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,7 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['src/bindings.ts', 'src/vite-env.d.ts'],
|
||||
},
|
||||
])
|
||||
|
||||
10
package.json
10
package.json
@@ -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
181
pnpm-lock.yaml
generated
@@ -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
3
src-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
target/
|
||||
215
src-plugin/Cargo.lock
generated
Normal file
215
src-plugin/Cargo.lock
generated
Normal 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
3
src-plugin/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["plugin-api", "plugin-sdk"]
|
||||
resolver = "3"
|
||||
1508
src-plugin/examples/Cargo.lock
generated
Normal file
1508
src-plugin/examples/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
src-plugin/examples/Cargo.toml
Normal file
8
src-plugin/examples/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
members = ["basic-example"]
|
||||
resolver = "3"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
121
src-plugin/examples/README.md
Normal file
121
src-plugin/examples/README.md
Normal 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);
|
||||
```
|
||||
13
src-plugin/examples/basic-example/Cargo.toml
Normal file
13
src-plugin/examples/basic-example/Cargo.toml
Normal 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"] }
|
||||
95
src-plugin/examples/basic-example/src/lib.rs
Normal file
95
src-plugin/examples/basic-example/src/lib.rs
Normal 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);
|
||||
10
src-plugin/plugin-api/Cargo.toml
Normal file
10
src-plugin/plugin-api/Cargo.toml
Normal 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"] }
|
||||
349
src-plugin/plugin-api/src/lib.rs
Normal file
349
src-plugin/plugin-api/src/lib.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
14
src-plugin/plugin-sdk/Cargo.toml
Normal file
14
src-plugin/plugin-sdk/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "bilibili-video-downloader-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "bilibili_video_downloader_plugin_sdk"
|
||||
|
||||
[dependencies]
|
||||
bilibili-video-downloader-plugin-api = { path = "../plugin-api" }
|
||||
|
||||
eyre = { version = "0.6.12" }
|
||||
serde_json = { version = "1" }
|
||||
parking_lot = { version = "0.12.5" }
|
||||
191
src-plugin/plugin-sdk/src/lib.rs
Normal file
191
src-plugin/plugin-sdk/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
54
src-plugin/plugin-sdk/tests/macro_smoke.rs
Normal file
54
src-plugin/plugin-sdk/tests/macro_smoke.rs
Normal 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
52
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(¶ms);
|
||||
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(¶ms);
|
||||
}
|
||||
|
||||
#[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(¶ms.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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ pub mod drawable;
|
||||
|
||||
use std::{cmp::Ordering, fs::File};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ass_writer::AssWriter;
|
||||
use canvas::CanvasConfig;
|
||||
use danmaku::{Danmaku, DanmakuType};
|
||||
use eyre::eyre;
|
||||
use tracing::instrument;
|
||||
use yaserde::{YaDeserialize, YaSerialize};
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
@@ -28,12 +29,13 @@ pub struct DanmakuXmlITag {
|
||||
pub elems: Vec<DamakuXmlDTag>,
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn xml_to_ass(
|
||||
xml: &str,
|
||||
ass_file: File,
|
||||
title: String,
|
||||
config: CanvasConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
) -> eyre::Result<()> {
|
||||
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
|
||||
let mut canvas = config.canvas();
|
||||
|
||||
@@ -54,24 +56,26 @@ pub fn xml_to_ass(
|
||||
}
|
||||
|
||||
trait ToDanmakuType {
|
||||
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType>;
|
||||
fn to_danmaku_type(&self) -> eyre::Result<DanmakuType>;
|
||||
}
|
||||
|
||||
impl ToDanmakuType for u32 {
|
||||
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType> {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
fn to_danmaku_type(&self) -> eyre::Result<DanmakuType> {
|
||||
match self {
|
||||
1 => Ok(DanmakuType::Float),
|
||||
4 => Ok(DanmakuType::Bottom),
|
||||
5 => Ok(DanmakuType::Top),
|
||||
6 => Ok(DanmakuType::Reverse),
|
||||
_ => Err(anyhow!("未知的弹幕类型:{self}")),
|
||||
_ => Err(eyre!("未知的弹幕类型:{self}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn xml_to_danmakus(xml: &str) -> eyre::Result<Vec<Danmaku>> {
|
||||
let xml = sanitize_xml(xml);
|
||||
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| anyhow!(e))?;
|
||||
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| eyre!(e))?;
|
||||
|
||||
let mut danmakus = Vec::new();
|
||||
|
||||
@@ -83,7 +87,7 @@ pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
|
||||
let mut p_attr = elem.p.split(',');
|
||||
|
||||
let Some(timeline_s) = p_attr.next().and_then(|s| s.parse::<f64>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有时间"));
|
||||
return Err(eyre!("弹幕`{content}`的p属性中没有时间"));
|
||||
};
|
||||
|
||||
let Some(r#type) = p_attr
|
||||
@@ -91,15 +95,15 @@ pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.and_then(|num| num.to_danmaku_type().ok())
|
||||
else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有弹幕类型"));
|
||||
return Err(eyre!("弹幕`{content}`的p属性中没有弹幕类型"));
|
||||
};
|
||||
|
||||
let Some(fontsize) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有字体大小"));
|
||||
return Err(eyre!("弹幕`{content}`的p属性中没有字体大小"));
|
||||
};
|
||||
|
||||
let Some(rgb) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有颜色"));
|
||||
return Err(eyre!("弹幕`{content}`的p属性中没有颜色"));
|
||||
};
|
||||
|
||||
// rgb 是个数字,类似 0x010203
|
||||
@@ -1,8 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::{BufWriter, Write};
|
||||
|
||||
use tracing::instrument;
|
||||
|
||||
use super::canvas::CanvasConfig;
|
||||
use super::drawable::{DrawEffect, Drawable};
|
||||
|
||||
@@ -99,7 +100,8 @@ pub struct AssWriter<W: Write> {
|
||||
}
|
||||
|
||||
impl<W: Write> AssWriter<W> {
|
||||
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> eyre::Result<Self> {
|
||||
let mut this = AssWriter {
|
||||
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
||||
f: BufWriter::with_capacity(10 << 20, f),
|
||||
@@ -112,7 +114,8 @@ impl<W: Write> AssWriter<W> {
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn init(&mut self) -> eyre::Result<()> {
|
||||
write!(
|
||||
self.f,
|
||||
"\
|
||||
@@ -147,7 +150,8 @@ impl<W: Write> AssWriter<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, drawable: Drawable) -> Result<()> {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn write(&mut self, drawable: Drawable) -> eyre::Result<()> {
|
||||
writeln!(
|
||||
self.f,
|
||||
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
@@ -171,7 +175,7 @@ impl<W: Write> AssWriter<W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_text(text: &str) -> Cow<str> {
|
||||
fn escape_text(text: &str) -> Cow<'_, str> {
|
||||
let text = text.trim();
|
||||
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
||||
Cow::from(text.replace('\n', "\\N"))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = ¶ms.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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ¶ms.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, ¶ms.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 ¶ms.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, ¶ms.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 ¶ms.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, ¶ms.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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("日志"))
|
||||
}
|
||||
|
||||
@@ -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
6
src-tauri/src/plugin.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod hook_context;
|
||||
pub mod host_api;
|
||||
pub mod plugin_executor;
|
||||
pub mod plugin_loader;
|
||||
pub mod plugin_manager;
|
||||
pub mod plugin_types;
|
||||
189
src-tauri/src/plugin/hook_context.rs
Normal file
189
src-tauri/src/plugin/hook_context.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use bilibili_video_downloader_plugin_api::v1::{
|
||||
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, DownloadProgressV1, HookInputV1,
|
||||
HookOutputV1, HookPayloadV1, HookPointV1, HookReadonlyMetaV1, OnCompletedPayloadV1,
|
||||
};
|
||||
use eyre::{WrapErr, eyre};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::downloader::download_progress::DownloadProgress;
|
||||
|
||||
pub struct BeforeVideoProcessContext<'a> {
|
||||
progress: &'a mut DownloadProgress,
|
||||
}
|
||||
|
||||
impl<'a> BeforeVideoProcessContext<'a> {
|
||||
pub fn new(progress: &'a mut DownloadProgress) -> Self {
|
||||
Self { progress }
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> eyre::Result<BeforeVideoProcessPayloadV1> {
|
||||
Ok(BeforeVideoProcessPayloadV1 {
|
||||
progress: host_to_api_progress(self.progress)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_payload(&mut self, payload: BeforeVideoProcessPayloadV1) -> eyre::Result<()> {
|
||||
validate_task_id_unchanged(self.progress, &payload.progress)?;
|
||||
|
||||
let next_progress = api_to_host_progress(payload.progress)?;
|
||||
|
||||
*self.progress = next_progress;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OnCompletedContext<'a> {
|
||||
progress: &'a mut DownloadProgress,
|
||||
}
|
||||
|
||||
impl<'a> OnCompletedContext<'a> {
|
||||
pub fn new(progress: &'a mut DownloadProgress) -> Self {
|
||||
Self { progress }
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> eyre::Result<OnCompletedPayloadV1> {
|
||||
Ok(OnCompletedPayloadV1 {
|
||||
progress: host_to_api_progress(self.progress)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_payload(&mut self, payload: OnCompletedPayloadV1) -> eyre::Result<()> {
|
||||
validate_task_id_unchanged(self.progress, &payload.progress)?;
|
||||
|
||||
let next_progress = api_to_host_progress(payload.progress)?;
|
||||
|
||||
*self.progress = next_progress;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AfterPrepareContext<'a> {
|
||||
progress: &'a mut DownloadProgress,
|
||||
}
|
||||
|
||||
impl<'a> AfterPrepareContext<'a> {
|
||||
pub fn new(progress: &'a mut DownloadProgress) -> Self {
|
||||
Self { progress }
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> eyre::Result<AfterPreparePayloadV1> {
|
||||
Ok(AfterPreparePayloadV1 {
|
||||
progress: host_to_api_progress(self.progress)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_payload(&mut self, payload: AfterPreparePayloadV1) -> eyre::Result<()> {
|
||||
validate_task_id_unchanged(self.progress, &payload.progress)?;
|
||||
|
||||
let next_progress = api_to_host_progress(payload.progress)?;
|
||||
|
||||
*self.progress = next_progress;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum HookContext<'a> {
|
||||
BeforeVideoProcess(BeforeVideoProcessContext<'a>),
|
||||
AfterPrepare(AfterPrepareContext<'a>),
|
||||
OnCompleted(OnCompletedContext<'a>),
|
||||
}
|
||||
|
||||
impl HookContext<'_> {
|
||||
pub fn hook_point(&self) -> HookPointV1 {
|
||||
match self {
|
||||
HookContext::BeforeVideoProcess(_) => HookPointV1::BeforeVideoProcess,
|
||||
HookContext::AfterPrepare(_) => HookPointV1::AfterPrepare,
|
||||
HookContext::OnCompleted(_) => HookPointV1::OnCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_input(&self, app_version: &str) -> eyre::Result<HookInputV1> {
|
||||
let hook_point = self.hook_point();
|
||||
let payload = match self {
|
||||
HookContext::BeforeVideoProcess(context) => {
|
||||
HookPayloadV1::BeforeVideoProcess(context.to_payload()?)
|
||||
}
|
||||
HookContext::AfterPrepare(context) => {
|
||||
HookPayloadV1::AfterPrepare(context.to_payload()?)
|
||||
}
|
||||
HookContext::OnCompleted(context) => HookPayloadV1::OnCompleted(context.to_payload()?),
|
||||
};
|
||||
|
||||
let input = HookInputV1 {
|
||||
hook_point,
|
||||
payload,
|
||||
readonly_meta: HookReadonlyMetaV1 {
|
||||
app_version: app_version.to_string(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
process_id: std::process::id(),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn apply_output(&mut self, output: HookOutputV1) -> eyre::Result<()> {
|
||||
let context_hook_point = self.hook_point();
|
||||
match (self, output.payload) {
|
||||
(
|
||||
HookContext::BeforeVideoProcess(context),
|
||||
HookPayloadV1::BeforeVideoProcess(payload),
|
||||
) => context.apply_payload(payload),
|
||||
|
||||
(HookContext::AfterPrepare(context), HookPayloadV1::AfterPrepare(payload)) => {
|
||||
context.apply_payload(payload)
|
||||
}
|
||||
|
||||
(HookContext::OnCompleted(context), HookPayloadV1::OnCompleted(payload)) => {
|
||||
context.apply_payload(payload)
|
||||
}
|
||||
|
||||
(_, payload) => Err(eyre!(
|
||||
"hook_point 与 payload 不匹配: hook_point={context_hook_point:?}, payload={payload:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_task_id_unchanged(
|
||||
current_progress: &DownloadProgress,
|
||||
next_progress: &DownloadProgressV1,
|
||||
) -> eyre::Result<()> {
|
||||
if current_progress.task_id != next_progress.task_id {
|
||||
return Err(eyre!("task_id 不可修改"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn host_to_api_progress(progress: &DownloadProgress) -> eyre::Result<DownloadProgressV1> {
|
||||
convert_via_json(
|
||||
progress,
|
||||
"序列化宿主 DownloadProgress 失败",
|
||||
"反序列化为插件 DownloadProgressV1 失败",
|
||||
)
|
||||
}
|
||||
|
||||
fn api_to_host_progress(progress: DownloadProgressV1) -> eyre::Result<DownloadProgress> {
|
||||
convert_via_json(
|
||||
progress,
|
||||
"序列化插件 DownloadProgressV1 失败",
|
||||
"反序列化为宿主 DownloadProgress 失败",
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_via_json<TSrc, TDst>(
|
||||
source: TSrc,
|
||||
serialize_err: &str,
|
||||
deserialize_err: &str,
|
||||
) -> eyre::Result<TDst>
|
||||
where
|
||||
TSrc: Serialize,
|
||||
TDst: DeserializeOwned,
|
||||
{
|
||||
let value = serde_json::to_value(source).wrap_err_with(|| serialize_err.to_string())?;
|
||||
serde_json::from_value(value).wrap_err_with(|| deserialize_err.to_string())
|
||||
}
|
||||
67
src-tauri/src/plugin/host_api.rs
Normal file
67
src-tauri/src/plugin/host_api.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use bilibili_video_downloader_plugin_api::v1::{HostApiV1, HostConfigV1};
|
||||
use eyre::WrapErr;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{config::Config, extensions::AppHandleExt};
|
||||
|
||||
static HOST_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
pub fn init(app: &AppHandle) {
|
||||
HOST_APP_HANDLE.get_or_init(|| app.clone());
|
||||
}
|
||||
|
||||
pub fn build_host_api_v1() -> HostApiV1 {
|
||||
HostApiV1 {
|
||||
get_config_json: host_get_config_json_v1,
|
||||
free_buffer: host_free_buffer_v1,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_get_config_json_v1(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32 {
|
||||
if out_ptr.is_null() || out_len.is_null() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let Some(app) = HOST_APP_HANDLE.get() else {
|
||||
return 2;
|
||||
};
|
||||
|
||||
let host_config = app.get_config().read().clone();
|
||||
let Ok(host_config_v1) = to_host_config_v1(&host_config) else {
|
||||
return 3;
|
||||
};
|
||||
|
||||
let Ok(output_bytes) = serde_json::to_vec(&host_config_v1) else {
|
||||
return 3;
|
||||
};
|
||||
|
||||
let boxed = output_bytes.into_boxed_slice();
|
||||
let len = boxed.len();
|
||||
let ptr = Box::into_raw(boxed).cast::<u8>();
|
||||
|
||||
unsafe {
|
||||
*out_ptr = ptr;
|
||||
*out_len = len;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_free_buffer_v1(ptr: *mut u8, len: usize) {
|
||||
if ptr.is_null() || len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len);
|
||||
unsafe {
|
||||
drop(Box::from_raw(raw_slice));
|
||||
}
|
||||
}
|
||||
|
||||
fn to_host_config_v1(config: &Config) -> eyre::Result<HostConfigV1> {
|
||||
let value = serde_json::to_value(config).wrap_err("序列化宿主 Config 失败")?;
|
||||
let host_config = serde_json::from_value(value).wrap_err("反序列化为插件 HostConfigV1 失败")?;
|
||||
Ok(host_config)
|
||||
}
|
||||
66
src-tauri/src/plugin/plugin_executor.rs
Normal file
66
src-tauri/src/plugin/plugin_executor.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::{ffi::CStr, sync::Arc};
|
||||
|
||||
use bilibili_video_downloader_plugin_api::v1::{HookInputV1, HookOutputV1};
|
||||
use dlopen2::wrapper::Container;
|
||||
use eyre::eyre;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::plugin::plugin_types::{PluginDylibApi, PluginRuntime};
|
||||
|
||||
#[instrument(level = "error", skip_all, fields(plugin_name = plugin.display_name(), hook_point = ?input.hook_point))]
|
||||
pub async fn execute_hook(
|
||||
plugin: &PluginRuntime,
|
||||
input: &HookInputV1,
|
||||
) -> eyre::Result<HookOutputV1> {
|
||||
let input_bytes = serde_json::to_vec(input)?;
|
||||
let api = plugin.api.clone();
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<eyre::Result<Vec<u8>>>();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = call_on_hook_blocking(api, &input_bytes);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
let output_bytes = rx.await??;
|
||||
let output: HookOutputV1 = serde_json::from_slice(&output_bytes)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all)]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn call_on_hook_blocking(
|
||||
api: Arc<Container<PluginDylibApi>>,
|
||||
input_bytes: &[u8],
|
||||
) -> eyre::Result<Vec<u8>> {
|
||||
let mut output_ptr: *mut u8 = std::ptr::null_mut();
|
||||
let mut output_len: usize = 0;
|
||||
let rc = unsafe {
|
||||
api.on_hook(
|
||||
input_bytes.as_ptr(),
|
||||
input_bytes.len(),
|
||||
&raw mut output_ptr,
|
||||
&raw mut output_len,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
let detail = get_last_error(&api);
|
||||
return Err(eyre!("插件返回错误码: code={rc}, detail={detail}"));
|
||||
}
|
||||
if output_ptr.is_null() {
|
||||
return Err(eyre!("插件返回空输出缓冲区"));
|
||||
}
|
||||
|
||||
let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec();
|
||||
unsafe { api.free_buffer(output_ptr, output_len) };
|
||||
Ok(output_bytes)
|
||||
}
|
||||
|
||||
fn get_last_error(api: &Arc<Container<PluginDylibApi>>) -> String {
|
||||
let error_ptr = unsafe { api.last_error() };
|
||||
if error_ptr.is_null() {
|
||||
return "获取错误信息失败,error_ptr为null".to_string();
|
||||
}
|
||||
|
||||
let error_cstr = unsafe { CStr::from_ptr(error_ptr) };
|
||||
error_cstr.to_string_lossy().to_string()
|
||||
}
|
||||
78
src-tauri/src/plugin/plugin_loader.rs
Normal file
78
src-tauri/src/plugin/plugin_loader.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use std::{ffi::CStr, path::Path, sync::Arc};
|
||||
|
||||
use bilibili_video_downloader_plugin_api::{SDK_API_VERSION_V1, v1::PluginDescriptorV1};
|
||||
use dlopen2::wrapper::Container;
|
||||
use eyre::{WrapErr, eyre};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::plugin::{
|
||||
host_api,
|
||||
plugin_types::{PluginDylibApi, PluginRuntime},
|
||||
};
|
||||
|
||||
#[instrument(level = "error", skip_all, fields(plugin_path = %plugin_path.display(), priority = priority, enabled = enabled))]
|
||||
pub fn load_plugin_from_path(
|
||||
plugin_path: &Path,
|
||||
priority: i32,
|
||||
enabled: bool,
|
||||
) -> eyre::Result<PluginRuntime> {
|
||||
if !plugin_path.is_absolute() {
|
||||
return Err(eyre!("插件路径必须是绝对路径: `{}`", plugin_path.display()));
|
||||
}
|
||||
if !plugin_path.exists() {
|
||||
return Err(eyre!("插件动态库文件`{}`不存在", plugin_path.display()));
|
||||
}
|
||||
|
||||
let api = unsafe { Container::<PluginDylibApi>::load(plugin_path) }
|
||||
.wrap_err(format!("加载插件动态库文件`{}`失败", plugin_path.display()))?;
|
||||
|
||||
let descriptor_json = get_descriptor_json(&api).wrap_err("读取插件描述失败")?;
|
||||
let descriptor: PluginDescriptorV1 = serde_json::from_str(&descriptor_json)
|
||||
.wrap_err(format!("解析插件描述失败: {descriptor_json}"))?;
|
||||
|
||||
if descriptor.sdk_api_version != SDK_API_VERSION_V1 {
|
||||
return Err(eyre!(
|
||||
"插件SDK版本不匹配: 期望版本={}, 实际版本={}",
|
||||
SDK_API_VERSION_V1,
|
||||
descriptor.sdk_api_version
|
||||
));
|
||||
}
|
||||
|
||||
if descriptor.id.trim().is_empty() {
|
||||
return Err(eyre!("descriptor.id 为空"));
|
||||
}
|
||||
|
||||
if descriptor.hooks.is_empty() {
|
||||
return Err(eyre!("插件未声明任何可执行 Hook"));
|
||||
}
|
||||
|
||||
let host_api = host_api::build_host_api_v1();
|
||||
let rc = unsafe { api.set_host_api(&raw const host_api) };
|
||||
if rc != 0 {
|
||||
return Err(eyre!(
|
||||
"注册宿主 Host API 失败: plugin_id={}, rc={rc}",
|
||||
descriptor.id
|
||||
));
|
||||
}
|
||||
|
||||
Ok(PluginRuntime {
|
||||
descriptor,
|
||||
plugin_path: plugin_path.to_path_buf(),
|
||||
enabled,
|
||||
priority,
|
||||
api: Arc::new(api),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all)]
|
||||
fn get_descriptor_json(api: &Container<PluginDylibApi>) -> eyre::Result<String> {
|
||||
let descriptor_ptr = unsafe { api.descriptor() };
|
||||
if descriptor_ptr.is_null() {
|
||||
return Err(eyre!("descriptor 指针为空"));
|
||||
}
|
||||
|
||||
let descriptor_cstr = unsafe { CStr::from_ptr(descriptor_ptr).to_str() }
|
||||
.wrap_err("descriptor 非 UTF-8 字符串")?;
|
||||
|
||||
Ok(descriptor_cstr.to_string())
|
||||
}
|
||||
351
src-tauri/src/plugin/plugin_manager.rs
Normal file
351
src-tauri/src/plugin/plugin_manager.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use eyre::eyre;
|
||||
use parking_lot::RwLock;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_specta::Event;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
events::PluginEvent,
|
||||
extensions::EyreReportToMessage,
|
||||
types::plugin_info::{PluginDescriptorInfo, PluginInfo, PluginMetadata, PluginRuntimeStatus},
|
||||
};
|
||||
|
||||
use super::{
|
||||
hook_context::HookContext, host_api, plugin_executor, plugin_loader,
|
||||
plugin_types::PluginRuntime,
|
||||
};
|
||||
|
||||
pub struct PluginManager {
|
||||
app: AppHandle,
|
||||
infos: RwLock<HashMap<String, PluginInfo>>,
|
||||
runtimes: RwLock<Vec<PluginRuntime>>,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub fn new(app: &AppHandle) -> eyre::Result<PluginManager> {
|
||||
host_api::init(app);
|
||||
|
||||
let app_data_dir = app.path().app_data_dir()?;
|
||||
let plugin_json_path = app_data_dir.join("plugin.json");
|
||||
|
||||
let mut infos = HashMap::new();
|
||||
if plugin_json_path.exists() {
|
||||
let json_string = std::fs::read_to_string(&plugin_json_path)?;
|
||||
|
||||
let metadata_map: HashMap<String, PluginMetadata> =
|
||||
serde_json::from_str(&json_string).unwrap_or_default();
|
||||
|
||||
for (plugin_path, metadata) in metadata_map {
|
||||
let status = PluginRuntimeStatus::Unknown;
|
||||
let info = PluginInfo::from_metadata(metadata, status);
|
||||
infos.insert(plugin_path, info);
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtimes = Vec::new();
|
||||
for info in infos.values_mut() {
|
||||
if !info.enabled {
|
||||
info.runtime_status = PluginRuntimeStatus::Disabled;
|
||||
continue;
|
||||
}
|
||||
|
||||
match plugin_loader::load_plugin_from_path(&info.path, info.priority, true) {
|
||||
Ok(runtime) => {
|
||||
tracing::info!(
|
||||
"插件加载成功: plugin_name={}, plugin_path={}",
|
||||
runtime.display_name(),
|
||||
runtime.plugin_path.display()
|
||||
);
|
||||
|
||||
info.runtime_status = PluginRuntimeStatus::Loaded;
|
||||
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
|
||||
|
||||
insert_runtime_by_priority(&mut runtimes, runtime);
|
||||
}
|
||||
Err(err) => {
|
||||
let err_title = "某个插件加载失败,已跳过";
|
||||
let message = err.to_message();
|
||||
tracing::error!(err_title, message);
|
||||
|
||||
info.runtime_status = PluginRuntimeStatus::LoadFailed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let plugin_manager = Self {
|
||||
app: app.clone(),
|
||||
infos: RwLock::new(infos),
|
||||
runtimes: RwLock::new(runtimes),
|
||||
};
|
||||
|
||||
plugin_manager.save_metadata()?;
|
||||
|
||||
Ok(plugin_manager)
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
|
||||
pub fn add_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
|
||||
let runtime = plugin_loader::load_plugin_from_path(&PathBuf::from(plugin_path), 0, true)?;
|
||||
|
||||
let plugin_info = {
|
||||
let mut infos = self.infos.write();
|
||||
if infos.contains_key(plugin_path) {
|
||||
return Err(eyre!("插件已存在: {plugin_path}"));
|
||||
}
|
||||
let status = PluginRuntimeStatus::Loaded;
|
||||
let metadata = PluginMetadata::from_plugin_runtime(&runtime);
|
||||
let info = PluginInfo::from_metadata(metadata, status);
|
||||
infos.insert(plugin_path.to_string(), info.clone());
|
||||
info
|
||||
};
|
||||
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
insert_runtime_by_priority(&mut runtimes, runtime);
|
||||
}
|
||||
|
||||
self.save_metadata()?;
|
||||
let _ = PluginEvent::Loaded { plugin_info }.emit(&self.app);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
|
||||
pub fn uninstall_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
|
||||
{
|
||||
let mut infos = self.infos.write();
|
||||
if !infos.contains_key(plugin_path) {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
}
|
||||
infos.remove(plugin_path);
|
||||
}
|
||||
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
|
||||
}
|
||||
|
||||
let _ = PluginEvent::Uninstall {
|
||||
plugin_path: plugin_path.to_string(),
|
||||
}
|
||||
.emit(&self.app);
|
||||
|
||||
self.save_metadata()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))]
|
||||
pub fn set_plugin_enabled(&self, plugin_path: &str, enabled: bool) -> eyre::Result<()> {
|
||||
if !enabled {
|
||||
let plugin_info = {
|
||||
let mut infos = self.infos.write();
|
||||
let Some(info) = infos.get_mut(plugin_path) else {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
};
|
||||
if info.enabled == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
info.enabled = false;
|
||||
info.runtime_status = PluginRuntimeStatus::Disabled;
|
||||
info.clone()
|
||||
};
|
||||
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
|
||||
}
|
||||
|
||||
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
|
||||
|
||||
self.save_metadata()?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (plugin_file_path, priority) = {
|
||||
let mut infos = self.infos.write();
|
||||
let Some(info) = infos.get_mut(plugin_path) else {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
};
|
||||
if info.enabled == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
info.enabled = true;
|
||||
(info.path.clone(), info.priority)
|
||||
};
|
||||
|
||||
let plugin_info =
|
||||
match plugin_loader::load_plugin_from_path(&plugin_file_path, priority, true) {
|
||||
Ok(runtime) => {
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
|
||||
insert_runtime_by_priority(&mut runtimes, runtime.clone());
|
||||
}
|
||||
|
||||
let mut infos = self.infos.write();
|
||||
let Some(info) = infos.get_mut(plugin_path) else {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
};
|
||||
info.runtime_status = PluginRuntimeStatus::Loaded;
|
||||
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
|
||||
info.clone()
|
||||
}
|
||||
Err(err) => {
|
||||
let err_title = "启用插件时加载失败";
|
||||
let message = err.to_message();
|
||||
tracing::error!(err_title, message);
|
||||
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
|
||||
}
|
||||
|
||||
let mut infos = self.infos.write();
|
||||
let Some(info) = infos.get_mut(plugin_path) else {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
};
|
||||
info.runtime_status = PluginRuntimeStatus::LoadFailed;
|
||||
info.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
|
||||
|
||||
self.save_metadata()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
level = "error",
|
||||
skip_all,
|
||||
fields(plugin_path = plugin_path, priority = priority)
|
||||
)]
|
||||
pub fn set_plugin_priority(&self, plugin_path: &str, priority: i32) -> eyre::Result<()> {
|
||||
let plugin_info = {
|
||||
let mut infos = self.infos.write();
|
||||
let Some(info) = infos.get_mut(plugin_path) else {
|
||||
return Err(eyre!("key中没有插件路径: {plugin_path}"));
|
||||
};
|
||||
if info.priority == priority {
|
||||
return Ok(());
|
||||
}
|
||||
info.priority = priority;
|
||||
info.clone()
|
||||
};
|
||||
|
||||
{
|
||||
let mut runtimes = self.runtimes.write();
|
||||
if let Some(mut runtime) = remove_runtime_by_path(&mut runtimes, Path::new(plugin_path))
|
||||
{
|
||||
runtime.priority = priority;
|
||||
insert_runtime_by_priority(&mut runtimes, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
|
||||
|
||||
self.save_metadata()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_plugin_infos(&self) -> Vec<PluginInfo> {
|
||||
self.infos.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all)]
|
||||
pub async fn run_hook(&self, mut context: HookContext<'_>) -> eyre::Result<()> {
|
||||
let hook_point = context.hook_point();
|
||||
let runtimes = self.runtimes.read().clone();
|
||||
if runtimes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let app_version = self.app.package_info().version.to_string();
|
||||
|
||||
for runtime in &runtimes {
|
||||
if !runtime.enabled || !runtime.should_run_hook(hook_point) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input = context.to_input(&app_version)?;
|
||||
let output = match plugin_executor::execute_hook(runtime, &input).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => match runtime.descriptor.failure_policy {
|
||||
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
|
||||
let err_title = "插件执行出错,按照 FailOpen 继续其他任务";
|
||||
let message = err.to_message();
|
||||
tracing::error!(err_title, message);
|
||||
continue;
|
||||
}
|
||||
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
|
||||
let err = err.wrap_err("插件执行出错,按照 FailClosed 中断任务");
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(err) = context.apply_output(output) {
|
||||
match runtime.descriptor.failure_policy {
|
||||
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
|
||||
let err_title = "插件输出无效,按照 FailOpen 继续其他任务";
|
||||
let message = err.to_message();
|
||||
tracing::error!(err_title, message);
|
||||
}
|
||||
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
|
||||
let err = err.wrap_err("插件输出无效,按照 FailClosed 中断任务");
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(level = "error", skip_all)]
|
||||
fn save_metadata(&self) -> eyre::Result<()> {
|
||||
let app_data_dir = self.app.path().app_data_dir()?;
|
||||
let plugin_json_path = app_data_dir.join("plugin.json");
|
||||
|
||||
let metadata_by_path: HashMap<String, PluginMetadata> = self
|
||||
.infos
|
||||
.read()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(plugin_path, info)| (plugin_path, info.into_metadata()))
|
||||
.collect();
|
||||
let json_string = serde_json::to_string_pretty(&metadata_by_path)?;
|
||||
|
||||
std::fs::write(plugin_json_path, json_string)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_runtime_by_priority(runtimes: &mut Vec<PluginRuntime>, runtime: PluginRuntime) {
|
||||
let insert_idx = runtimes
|
||||
.iter()
|
||||
.position(|existing| existing.priority < runtime.priority)
|
||||
.unwrap_or(runtimes.len());
|
||||
runtimes.insert(insert_idx, runtime);
|
||||
}
|
||||
|
||||
fn remove_runtime_by_path(
|
||||
runtimes: &mut Vec<PluginRuntime>,
|
||||
plugin_path: &Path,
|
||||
) -> Option<PluginRuntime> {
|
||||
let remove_idx = runtimes
|
||||
.iter()
|
||||
.position(|runtime| runtime.plugin_path == plugin_path)?;
|
||||
Some(runtimes.remove(remove_idx))
|
||||
}
|
||||
45
src-tauri/src/plugin/plugin_types.rs
Normal file
45
src-tauri/src/plugin/plugin_types.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::{ffi::c_char, path::PathBuf, sync::Arc};
|
||||
|
||||
use bilibili_video_downloader_plugin_api::v1::{HookPointV1, HostApiV1, PluginDescriptorV1};
|
||||
use dlopen2::wrapper::{Container, WrapperApi};
|
||||
|
||||
#[derive(WrapperApi)]
|
||||
pub struct PluginDylibApi {
|
||||
#[dlopen2_name = "bilibili_video_downloader_plugin_descriptor_v1"]
|
||||
descriptor: unsafe extern "C" fn() -> *const c_char,
|
||||
#[dlopen2_name = "bilibili_video_downloader_plugin_on_hook_v1"]
|
||||
on_hook: unsafe extern "C" fn(
|
||||
input_ptr: *const u8,
|
||||
input_len: usize,
|
||||
out_ptr: *mut *mut u8,
|
||||
out_len: *mut usize,
|
||||
) -> i32,
|
||||
#[dlopen2_name = "bilibili_video_downloader_plugin_free_buffer_v1"]
|
||||
free_buffer: unsafe extern "C" fn(ptr: *mut u8, len: usize),
|
||||
#[dlopen2_name = "bilibili_video_downloader_plugin_last_error_v1"]
|
||||
last_error: unsafe extern "C" fn() -> *const c_char,
|
||||
#[dlopen2_name = "bilibili_video_downloader_plugin_set_host_api_v1"]
|
||||
set_host_api: unsafe extern "C" fn(api: *const HostApiV1) -> i32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginRuntime {
|
||||
pub descriptor: PluginDescriptorV1,
|
||||
pub plugin_path: PathBuf,
|
||||
pub enabled: bool,
|
||||
pub priority: i32,
|
||||
pub api: Arc<Container<PluginDylibApi>>,
|
||||
}
|
||||
|
||||
impl PluginRuntime {
|
||||
pub fn display_name(&self) -> String {
|
||||
format!(
|
||||
"{} ({}, v{})",
|
||||
self.descriptor.name, self.descriptor.id, self.descriptor.version
|
||||
)
|
||||
}
|
||||
|
||||
pub fn should_run_hook(&self, hook: HookPointV1) -> bool {
|
||||
self.descriptor.hooks.contains(&hook)
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -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,
|
||||
|
||||
18
src-tauri/src/types/available_media_formats.rs
Normal file
18
src-tauri/src/types/available_media_formats.rs
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src-tauri/src/types/bangumi_media_url_v2.rs
Normal file
46
src-tauri/src/types/bangumi_media_url_v2.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
src-tauri/src/types/get_available_media_formats_params.rs
Normal file
25
src-tauri/src/types/get_available_media_formats_params.rs
Normal 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,
|
||||
}
|
||||
@@ -6,3 +6,19 @@ pub enum GetBangumiInfoParams {
|
||||
EpId(i64),
|
||||
SeasonId(i64),
|
||||
}
|
||||
|
||||
impl GetBangumiInfoParams {
|
||||
pub fn get_ep_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::EpId(ep_id) => Some(*ep_id),
|
||||
Self::SeasonId(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_season_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::EpId(_) => None,
|
||||
Self::SeasonId(season_id) => Some(*season_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,19 @@ pub enum GetCheeseInfoParams {
|
||||
EpId(i64),
|
||||
SeasonId(i64),
|
||||
}
|
||||
|
||||
impl GetCheeseInfoParams {
|
||||
pub fn get_ep_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::EpId(ep_id) => Some(*ep_id),
|
||||
Self::SeasonId(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_season_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::EpId(_) => None,
|
||||
Self::SeasonId(season_id) => Some(*season_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub enum LogLevel {
|
||||
#[serde(rename = "TRACE")]
|
||||
Trace,
|
||||
#[serde(rename = "DEBUG")]
|
||||
Debug,
|
||||
#[serde(rename = "INFO")]
|
||||
Info,
|
||||
#[serde(rename = "WARN")]
|
||||
Warn,
|
||||
#[serde(rename = "ERROR")]
|
||||
Error,
|
||||
}
|
||||
39
src-tauri/src/types/log_metadata.rs
Normal file
39
src-tauri/src/types/log_metadata.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
src-tauri/src/types/plugin_info.rs
Normal file
131
src-tauri/src/types/plugin_info.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src-tauri/src/types/restart_download_task_params.rs
Normal file
30
src-tauri/src/types/restart_download_task_params.rs
Normal 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,
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
src/App.vue
11
src/App.vue
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 → 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 → 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
117
src/dialogs/SettingsDialog/components/NetworkSettings.vue
Normal file
117
src/dialogs/SettingsDialog/components/NetworkSettings.vue
Normal 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>
|
||||
389
src/dialogs/SettingsDialog/components/PluginSettings.vue
Normal file
389
src/dialogs/SettingsDialog/components/PluginSettings.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user