mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-06-10 01:50:41 +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
|
id: reproduction
|
||||||
attributes:
|
attributes:
|
||||||
label: 复现步骤
|
label: 复现步骤
|
||||||
description: 这是整个issue中**最重要**的部分
|
description: 这是整个issue中**最重要**的部分,请参考[这个issue](https://github.com/lanyeeee/bilibili-video-downloader/issues/1)认真填写,否则开发者会假装看不见,甚至直接关闭issue
|
||||||
placeholder: |
|
placeholder: |
|
||||||
复现步骤是影响issue处理效率的最大因素
|
复现步骤是影响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
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
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
|
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)` |
|
- 不要使用他人发的二进制插件(`dll` / `so` / `dylib`)
|
||||||
| 👤账号相关 | **登录** `Cookie登录` `二维码登录`<br />**内容** `收藏夹` `历史记录` `稍后再看` `追番追剧` |
|
- 插件开发文档与示例请看:[src-plugin/examples](src-plugin/examples)
|
||||||
| 🎬视频下载 | **类型** `视频(合集、分P、充电视频)` `番剧(正片、PV、相关视频)` `课程` <br />**编码** `AVC` `HEVC` `AV1`<br />**画质** `8K` `杜比视界` `HDR` `4K` `AI智能修复`等... |
|
|
||||||
| 🎵音频下载 | `无损` `杜比全景声` `192K` `132K` `64K` |
|
|
||||||
| 📝字幕下载 | 获取视频所有的CC字幕,以`srt`格式保存 |
|
|
||||||
| 🖼️封面下载 | 最高清、无压缩的原始封面图 |
|
|
||||||
| 💬弹幕下载 | 不仅能下载 `xml` `json`格式的原始弹幕,还支持将其转为样式可定制的`ass字幕` |
|
|
||||||
| 📺NFO刮削 | 还会顺便下载poster和fanart,轻松将视频加入emby等媒体库 |
|
|
||||||
| 🎞️章节标记 | 将原视频的章节信息嵌入视频文件,使视频在各类播放器中支持章节导航 |
|
|
||||||
| 🚫广告标记 | 将广告片段以章节的形式嵌入视频文件,配合兼容的播放器可自动跳过广告 |
|
|
||||||
| ⚙️任务管理 | `断点续传` `批量操作` `继续` `暂停` `重来` `删除` |
|
|
||||||
|
|
||||||
## 支持的配置
|
## ⚠️关于被杀毒软件误判为病毒
|
||||||
|
|
||||||

|
对于个人开发的项目来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~)
|
||||||

|
我能想到的解决办法只有:
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
|
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",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"@phosphor-icons/vue": "^2.2.1",
|
"@phosphor-icons/vue": "^2.2.1",
|
||||||
@@ -19,10 +21,10 @@
|
|||||||
"lazysizes": "^5.3.2",
|
"lazysizes": "^5.3.2",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"unplugin-auto-import": "^19.3.0",
|
"virtua": "^0.48.6",
|
||||||
"unplugin-vue-components": "^28.8.0",
|
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-draggable-plus": "^0.6.0"
|
"vue-draggable-plus": "^0.6.0",
|
||||||
|
"z-vue-scan": "^0.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
|
|||||||
181
pnpm-lock.yaml
generated
181
pnpm-lock.yaml
generated
@@ -35,18 +35,18 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(typescript@5.6.3)(vue@3.5.17(typescript@5.6.3))
|
version: 3.0.3(typescript@5.6.3)(vue@3.5.17(typescript@5.6.3))
|
||||||
unplugin-auto-import:
|
virtua:
|
||||||
specifier: ^19.3.0
|
specifier: ^0.48.6
|
||||||
version: 19.3.0
|
version: 0.48.6(vue@3.5.17(typescript@5.6.3))
|
||||||
unplugin-vue-components:
|
|
||||||
specifier: ^28.8.0
|
|
||||||
version: 28.8.0(@babel/parser@7.28.0)(vue@3.5.17(typescript@5.6.3))
|
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.17(typescript@5.6.3)
|
version: 3.5.17(typescript@5.6.3)
|
||||||
vue-draggable-plus:
|
vue-draggable-plus:
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0(@types/sortablejs@1.15.8)
|
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:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.30.1
|
specifier: ^9.30.1
|
||||||
@@ -1190,10 +1190,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
eslint-plugin-vue@10.3.0:
|
||||||
resolution: {integrity: sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==}
|
resolution: {integrity: sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1246,9 +1242,6 @@ packages:
|
|||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
|
||||||
|
|
||||||
esutils@2.0.3:
|
esutils@2.0.3:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1448,9 +1441,6 @@ packages:
|
|||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
js-tokens@9.0.1:
|
|
||||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
|
||||||
|
|
||||||
js-yaml@4.1.0:
|
js-yaml@4.1.0:
|
||||||
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1717,9 +1707,6 @@ packages:
|
|||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
scule@1.3.0:
|
|
||||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
|
||||||
|
|
||||||
seemly@0.3.10:
|
seemly@0.3.10:
|
||||||
resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
|
resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
|
||||||
|
|
||||||
@@ -1764,9 +1751,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
strip-literal@3.0.0:
|
|
||||||
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
|
|
||||||
|
|
||||||
superjson@2.2.2:
|
superjson@2.2.2:
|
||||||
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
|
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -1825,10 +1809,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
unimport@4.2.0:
|
|
||||||
resolution: {integrity: sha512-mYVtA0nmzrysnYnyb3ALMbByJ+Maosee2+WyE0puXl+Xm2bUwPorPaaeZt0ETfuroPOtG8jj1g/qeFZ6buFnag==}
|
|
||||||
engines: {node: '>=18.12.0'}
|
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1845,39 +1825,10 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
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:
|
unplugin-utils@0.2.4:
|
||||||
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
|
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
|
||||||
engines: {node: '>=18.12.0'}
|
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:
|
update-browserslist-db@1.1.3:
|
||||||
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
|
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1895,6 +1846,26 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.11
|
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:
|
vite-hot-client@2.1.0:
|
||||||
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
|
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1969,6 +1940,17 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
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:
|
vue-draggable-plus@0.6.0:
|
||||||
resolution: {integrity: sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==}
|
resolution: {integrity: sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2008,9 +1990,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.11
|
vue: ^3.0.11
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2:
|
|
||||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2035,6 +2014,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
|
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
|
||||||
engines: {node: '>=18'}
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
@@ -3198,8 +3186,6 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
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))):
|
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:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.30.1(jiti@2.4.2))
|
'@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@2.0.2: {}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
|
||||||
dependencies:
|
|
||||||
'@types/estree': 1.0.8
|
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
evtd@0.2.4: {}
|
evtd@0.2.4: {}
|
||||||
@@ -3448,8 +3430,6 @@ snapshots:
|
|||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
|
||||||
|
|
||||||
js-yaml@4.1.0:
|
js-yaml@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
@@ -3725,8 +3705,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
scule@1.3.0: {}
|
|
||||||
|
|
||||||
seemly@0.3.10: {}
|
seemly@0.3.10: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
@@ -3755,10 +3733,6 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
|
|
||||||
strip-literal@3.0.0:
|
|
||||||
dependencies:
|
|
||||||
js-tokens: 9.0.1
|
|
||||||
|
|
||||||
superjson@2.2.2:
|
superjson@2.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything: 3.0.5
|
copy-anything: 3.0.5
|
||||||
@@ -3813,23 +3787,6 @@ snapshots:
|
|||||||
|
|
||||||
unicorn-magic@0.3.0: {}
|
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: {}
|
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)):
|
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
|
- supports-color
|
||||||
- vue
|
- 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:
|
unplugin-utils@0.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
picomatch: 4.0.2
|
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):
|
update-browserslist-db@1.1.3(browserslist@4.25.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.25.1
|
browserslist: 4.25.1
|
||||||
@@ -3913,6 +3839,10 @@ snapshots:
|
|||||||
evtd: 0.2.4
|
evtd: 0.2.4
|
||||||
vue: 3.5.17(typescript@5.6.3)
|
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)):
|
vite-hot-client@2.1.0(vite@6.3.5(jiti@2.4.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 6.3.5(jiti@2.4.2)
|
vite: 6.3.5(jiti@2.4.2)
|
||||||
@@ -3983,6 +3913,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
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):
|
vue-draggable-plus@0.6.0(@types/sortablejs@1.15.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/sortablejs': 1.15.8
|
'@types/sortablejs': 1.15.8
|
||||||
@@ -4030,8 +3964,6 @@ snapshots:
|
|||||||
vooks: 0.2.12(vue@3.5.17(typescript@5.6.3))
|
vooks: 0.2.12(vue@3.5.17(typescript@5.6.3))
|
||||||
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:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -4045,3 +3977,8 @@ snapshots:
|
|||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
yoctocolors@2.1.1: {}
|
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"
|
name = "bilibili-video-downloader"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bilibili-video-downloader-plugin-api",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dlopen2 0.8.2",
|
||||||
|
"eyre",
|
||||||
"float-ord",
|
"float-ord",
|
||||||
"fs4",
|
"fs4",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -317,11 +319,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
|
"tracing-error",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"yaserde",
|
"yaserde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bilibili-video-downloader-plugin-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -860,6 +870,18 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "dlopen2_derive"
|
name = "dlopen2_derive"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1007,6 +1029,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1850,6 +1882,12 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indenter"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -4169,7 +4207,7 @@ dependencies = [
|
|||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"dlopen2",
|
"dlopen2 0.7.0",
|
||||||
"dpi",
|
"dpi",
|
||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
@@ -4874,6 +4912,16 @@ dependencies = [
|
|||||||
"valuable",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-log"
|
name = "tracing-log"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "bilibili-video-downloader"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# 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-opener = "2"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
tauri-plugin-dialog = "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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -35,11 +37,12 @@ reqwest = { version = "0.12.22", default-features = false, features = ["default-
|
|||||||
reqwest-retry = { version = "0.7.0" }
|
reqwest-retry = { version = "0.7.0" }
|
||||||
reqwest-middleware = { version = "0.4.2" }
|
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"] }
|
parking_lot = { version = "0.12.4", features = ["send_guard"] }
|
||||||
tracing = { version = "0.1.41" }
|
tracing = { version = "0.1.41" }
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] }
|
tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] }
|
||||||
tracing-appender = { version = "0.2.3" }
|
tracing-appender = { version = "0.2.3" }
|
||||||
|
tracing-error = { version = "0.2.1" }
|
||||||
notify = { version = "8.0.0" }
|
notify = { version = "8.0.0" }
|
||||||
tokio = { version = "1.46.0", features = ["full"] }
|
tokio = { version = "1.46.0", features = ["full"] }
|
||||||
byteorder = { version = "1.5.0" }
|
byteorder = { version = "1.5.0" }
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
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 parking_lot::RwLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
@@ -9,11 +15,13 @@ use crate::{
|
|||||||
extensions::AppHandleExt,
|
extensions::AppHandleExt,
|
||||||
logger,
|
logger,
|
||||||
types::{
|
types::{
|
||||||
|
available_media_formats::AvailableMediaFormats,
|
||||||
bangumi_follow_info::BangumiFollowInfo,
|
bangumi_follow_info::BangumiFollowInfo,
|
||||||
bangumi_info::{BangumiInfo, EpInBangumi},
|
bangumi_info::{BangumiInfo, EpInBangumi},
|
||||||
create_download_task_params::CreateDownloadTaskParams,
|
create_download_task_params::CreateDownloadTaskParams,
|
||||||
fav_folders::FavFolders,
|
fav_folders::FavFolders,
|
||||||
fav_info::FavInfo,
|
fav_info::FavInfo,
|
||||||
|
get_available_media_formats_params::GetAvailableMediaFormatsParams,
|
||||||
get_bangumi_follow_info_params::GetBangumiFollowInfoParams,
|
get_bangumi_follow_info_params::GetBangumiFollowInfoParams,
|
||||||
get_bangumi_info_params::GetBangumiInfoParams,
|
get_bangumi_info_params::GetBangumiInfoParams,
|
||||||
get_cheese_info_params::GetCheeseInfoParams,
|
get_cheese_info_params::GetCheeseInfoParams,
|
||||||
@@ -22,9 +30,12 @@ use crate::{
|
|||||||
get_normal_info_params::GetNormalInfoParams,
|
get_normal_info_params::GetNormalInfoParams,
|
||||||
get_user_video_info_params::GetUserVideoInfoParams,
|
get_user_video_info_params::GetUserVideoInfoParams,
|
||||||
history_info::HistoryInfo,
|
history_info::HistoryInfo,
|
||||||
|
log_metadata::LogMetadata,
|
||||||
normal_info::NormalInfo,
|
normal_info::NormalInfo,
|
||||||
|
plugin_info::PluginInfo,
|
||||||
qrcode_data::QrcodeData,
|
qrcode_data::QrcodeData,
|
||||||
qrcode_status::QrcodeStatus,
|
qrcode_status::QrcodeStatus,
|
||||||
|
restart_download_task_params::RestartDownloadTaskParams,
|
||||||
search_params::SearchParams,
|
search_params::SearchParams,
|
||||||
search_result::{
|
search_result::{
|
||||||
BangumiSearchResult, CheeseSearchResult, FavSearchResult, NormalSearchResult,
|
BangumiSearchResult, CheeseSearchResult, FavSearchResult, NormalSearchResult,
|
||||||
@@ -40,6 +51,8 @@ use crate::{
|
|||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[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 {
|
pub fn get_config(config: tauri::State<RwLock<Config>>) -> Config {
|
||||||
config.read().clone()
|
config.read().clone()
|
||||||
}
|
}
|
||||||
@@ -47,19 +60,21 @@ pub fn get_config(config: tauri::State<RwLock<Config>>) -> Config {
|
|||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> {
|
pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let config_state = app.get_config();
|
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 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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn generate_qrcode(app: AppHandle) -> CommandResult<QrcodeData> {
|
pub async fn generate_qrcode(app: AppHandle) -> CommandResult<QrcodeData> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let qrcode_data = 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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_qrcode_status(app: AppHandle, qrcode_key: String) -> CommandResult<QrcodeStatus> {
|
pub async fn get_qrcode_status(app: AppHandle, qrcode_key: String) -> CommandResult<QrcodeStatus> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let qrcode_status = 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)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult<UserInfo> {
|
pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult<UserInfo> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let user_info = 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)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[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(
|
pub async fn get_bangumi_info(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
params: GetBangumiInfoParams,
|
params: GetBangumiInfoParams,
|
||||||
@@ -139,6 +158,7 @@ pub async fn get_bangumi_info(
|
|||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all, fields(bvid = params.get_bvid(), aid = params.get_aid()))]
|
||||||
pub async fn get_normal_info(
|
pub async fn get_normal_info(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
params: GetNormalInfoParams,
|
params: GetNormalInfoParams,
|
||||||
@@ -153,6 +173,7 @@ pub async fn get_normal_info(
|
|||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_user_video_info(
|
pub async fn get_user_video_info(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
params: GetUserVideoInfoParams,
|
params: GetUserVideoInfoParams,
|
||||||
@@ -167,6 +188,7 @@ pub async fn get_user_video_info(
|
|||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_fav_folders(app: AppHandle, uid: i64) -> CommandResult<FavFolders> {
|
pub async fn get_fav_folders(app: AppHandle, uid: i64) -> CommandResult<FavFolders> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let fav_folders = 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)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_fav_info(app: AppHandle, params: GetFavInfoParams) -> CommandResult<FavInfo> {
|
pub async fn get_fav_info(app: AppHandle, params: GetFavInfoParams) -> CommandResult<FavInfo> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let fav_info = 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)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<WatchLaterInfo> {
|
pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<WatchLaterInfo> {
|
||||||
let bili_client = app.get_bili_client();
|
let bili_client = app.get_bili_client();
|
||||||
let watch_later_info = 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)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_bangumi_follow_info(
|
pub async fn get_bangumi_follow_info(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
params: GetBangumiFollowInfoParams,
|
params: GetBangumiFollowInfoParams,
|
||||||
@@ -214,6 +239,7 @@ pub async fn get_bangumi_follow_info(
|
|||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn get_history_info(
|
pub async fn get_history_info(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
params: GetHistoryInfoParams,
|
params: GetHistoryInfoParams,
|
||||||
@@ -229,15 +255,16 @@ pub async fn get_history_info(
|
|||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn create_download_tasks(app: AppHandle, params: CreateDownloadTaskParams) {
|
pub fn create_download_tasks(app: AppHandle, params: CreateDownloadTaskParams) {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager.create_download_tasks(¶ms);
|
download_manager.create_download_tasks(¶ms);
|
||||||
tracing::debug!("下载任务创建成功");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn pause_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
pub fn pause_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager.pause_download_tasks(&task_ids);
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn resume_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
pub fn resume_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager.resume_download_tasks(&task_ids);
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn delete_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
pub fn delete_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager.delete_download_tasks(&task_ids);
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn restart_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
pub fn restart_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager.restart_download_tasks(&task_ids);
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[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<()> {
|
pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
|
||||||
let download_manager = app.get_download_manager();
|
let download_manager = app.get_download_manager();
|
||||||
download_manager
|
download_manager
|
||||||
@@ -281,6 +321,7 @@ pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
|
|||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn search(app: AppHandle, params: SearchParams) -> CommandResult<SearchResult> {
|
pub async fn search(app: AppHandle, params: SearchParams) -> CommandResult<SearchResult> {
|
||||||
use SearchParams::{Bangumi, Cheese, Fav, Normal, UserVideo};
|
use SearchParams::{Bangumi, Cheese, Fav, Normal, UserVideo};
|
||||||
let bili_client = app.get_bili_client();
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn get_logs_dir_size(app: AppHandle) -> CommandResult<u64> {
|
pub fn get_logs_dir_size(app: AppHandle) -> CommandResult<u64> {
|
||||||
let logs_dir = logger::logs_dir(&app)
|
let logs_dir = logger::logs_dir(&app)
|
||||||
.context("获取日志目录失败")
|
.wrap_err("获取日志目录失败")
|
||||||
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?;
|
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?;
|
||||||
let logs_dir_size = std::fs::read_dir(&logs_dir)
|
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))?
|
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.filter_map(|entry| entry.metadata().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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> {
|
pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> {
|
||||||
app.opener()
|
app.opener()
|
||||||
.reveal_item_in_dir(path)
|
.reveal_item_in_dir(path)
|
||||||
.context(format!("在文件管理器中打开`{path}`失败"))
|
.wrap_err(format!("在文件管理器中打开`{path}`失败"))
|
||||||
.map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
|
.map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all, fields(bvid = bvid, cid = cid))]
|
||||||
pub async fn get_skip_segments(
|
pub async fn get_skip_segments(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
bvid: String,
|
bvid: String,
|
||||||
@@ -393,3 +437,142 @@ pub async fn get_skip_segments(
|
|||||||
.map_err(|err| CommandError::from("获取跳过片段失败", err))?;
|
.map_err(|err| CommandError::from("获取跳过片段失败", err))?;
|
||||||
Ok(skip_segments)
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
danmaku_xml_to_ass::canvas::CanvasConfig,
|
danmaku_xml_to_ass::canvas::CanvasConfig,
|
||||||
@@ -42,10 +43,13 @@ pub struct Config {
|
|||||||
pub chunk_concurrency: usize,
|
pub chunk_concurrency: usize,
|
||||||
pub chunk_download_interval_sec: u64,
|
pub chunk_download_interval_sec: u64,
|
||||||
pub danmaku_config: CanvasConfig,
|
pub danmaku_config: CanvasConfig,
|
||||||
|
pub file_exist_action: FileExistAction,
|
||||||
|
pub auto_start_download_task: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
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 app_data_dir = app.path().app_data_dir()?;
|
||||||
let config_path = app_data_dir.join("config.json");
|
let config_path = app_data_dir.join("config.json");
|
||||||
|
|
||||||
@@ -65,7 +69,8 @@ impl Config {
|
|||||||
Ok(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 app_data_dir = app.path().app_data_dir()?;
|
||||||
let config_path = app_data_dir.join("config.json");
|
let config_path = app_data_dir.join("config.json");
|
||||||
let config_string = serde_json::to_string_pretty(self)?;
|
let config_string = serde_json::to_string_pretty(self)?;
|
||||||
@@ -151,6 +156,8 @@ impl Config {
|
|||||||
chunk_concurrency: 16,
|
chunk_concurrency: 16,
|
||||||
chunk_download_interval_sec: 0,
|
chunk_download_interval_sec: 0,
|
||||||
danmaku_config: CanvasConfig::default(),
|
danmaku_config: CanvasConfig::default(),
|
||||||
|
file_exist_action: FileExistAction::Overwrite,
|
||||||
|
auto_start_download_task: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,3 +169,10 @@ pub enum ProxyMode {
|
|||||||
System,
|
System,
|
||||||
Custom,
|
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 std::{cmp::Ordering, fs::File};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use ass_writer::AssWriter;
|
use ass_writer::AssWriter;
|
||||||
use canvas::CanvasConfig;
|
use canvas::CanvasConfig;
|
||||||
use danmaku::{Danmaku, DanmakuType};
|
use danmaku::{Danmaku, DanmakuType};
|
||||||
|
use eyre::eyre;
|
||||||
|
use tracing::instrument;
|
||||||
use yaserde::{YaDeserialize, YaSerialize};
|
use yaserde::{YaDeserialize, YaSerialize};
|
||||||
|
|
||||||
#[derive(YaSerialize, YaDeserialize)]
|
#[derive(YaSerialize, YaDeserialize)]
|
||||||
@@ -28,12 +29,13 @@ pub struct DanmakuXmlITag {
|
|||||||
pub elems: Vec<DamakuXmlDTag>,
|
pub elems: Vec<DamakuXmlDTag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn xml_to_ass(
|
pub fn xml_to_ass(
|
||||||
xml: &str,
|
xml: &str,
|
||||||
ass_file: File,
|
ass_file: File,
|
||||||
title: String,
|
title: String,
|
||||||
config: CanvasConfig,
|
config: CanvasConfig,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
|
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
|
||||||
let mut canvas = config.canvas();
|
let mut canvas = config.canvas();
|
||||||
|
|
||||||
@@ -54,24 +56,26 @@ pub fn xml_to_ass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
trait ToDanmakuType {
|
trait ToDanmakuType {
|
||||||
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType>;
|
fn to_danmaku_type(&self) -> eyre::Result<DanmakuType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToDanmakuType for u32 {
|
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 {
|
match self {
|
||||||
1 => Ok(DanmakuType::Float),
|
1 => Ok(DanmakuType::Float),
|
||||||
4 => Ok(DanmakuType::Bottom),
|
4 => Ok(DanmakuType::Bottom),
|
||||||
5 => Ok(DanmakuType::Top),
|
5 => Ok(DanmakuType::Top),
|
||||||
6 => Ok(DanmakuType::Reverse),
|
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 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();
|
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 mut p_attr = elem.p.split(',');
|
||||||
|
|
||||||
let Some(timeline_s) = p_attr.next().and_then(|s| s.parse::<f64>().ok()) else {
|
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
|
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(|s| s.parse::<u32>().ok())
|
||||||
.and_then(|num| num.to_danmaku_type().ok())
|
.and_then(|num| num.to_danmaku_type().ok())
|
||||||
else {
|
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 {
|
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 {
|
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
|
// rgb 是个数字,类似 0x010203
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{BufWriter, Write};
|
use std::io::{BufWriter, Write};
|
||||||
|
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use super::canvas::CanvasConfig;
|
use super::canvas::CanvasConfig;
|
||||||
use super::drawable::{DrawEffect, Drawable};
|
use super::drawable::{DrawEffect, Drawable};
|
||||||
|
|
||||||
@@ -99,7 +100,8 @@ pub struct AssWriter<W: Write> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<W: Write> AssWriter<W> {
|
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 {
|
let mut this = AssWriter {
|
||||||
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
||||||
f: BufWriter::with_capacity(10 << 20, f),
|
f: BufWriter::with_capacity(10 << 20, f),
|
||||||
@@ -112,7 +114,8 @@ impl<W: Write> AssWriter<W> {
|
|||||||
Ok(this)
|
Ok(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&mut self) -> Result<()> {
|
#[instrument(level = "error", skip_all)]
|
||||||
|
pub fn init(&mut self) -> eyre::Result<()> {
|
||||||
write!(
|
write!(
|
||||||
self.f,
|
self.f,
|
||||||
"\
|
"\
|
||||||
@@ -147,7 +150,8 @@ impl<W: Write> AssWriter<W> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&mut self, drawable: Drawable) -> Result<()> {
|
#[instrument(level = "error", skip_all)]
|
||||||
|
pub fn write(&mut self, drawable: Drawable) -> eyre::Result<()> {
|
||||||
writeln!(
|
writeln!(
|
||||||
self.f,
|
self.f,
|
||||||
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
// 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();
|
let text = text.trim();
|
||||||
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
||||||
Cow::from(text.replace('\n', "\\N"))
|
Cow::from(text.replace('\n', "\\N"))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::{
|
|||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tokio::{sync::SemaphorePermit, time::sleep};
|
use tokio::{sync::SemaphorePermit, time::sleep};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState},
|
downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState},
|
||||||
@@ -23,7 +24,17 @@ pub struct DownloadChunkTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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();
|
let download_chunk_task = self.download_chunk();
|
||||||
tokio::pin!(download_chunk_task);
|
tokio::pin!(download_chunk_task);
|
||||||
|
|
||||||
@@ -53,10 +64,13 @@ impl DownloadChunkTask {
|
|||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
if let Some(permit) = permit.take() {
|
if let Some(permit) = permit.take() {
|
||||||
drop(permit);
|
drop(permit);
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// FIXME: 直接返回chunk_index存在进度误标风险
|
||||||
|
// 上层会将这个分片标记为已下载,而分片其实是被打断的
|
||||||
|
// 应该把返回值改成 enum DownloadChunkResult { Downloaded(idx), Interrupted }
|
||||||
|
// 然后由上层处理
|
||||||
_ = restart_receiver.changed() => break Ok(self.chunk_index),
|
_ = restart_receiver.changed() => break Ok(self.chunk_index),
|
||||||
|
|
||||||
_ = delete_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 bili_client = self.download_task.app.get_bili_client();
|
||||||
let chunk_data = bili_client
|
let chunk_data = bili_client
|
||||||
.get_media_chunk(&self.url, self.start, self.end)
|
.get_media_chunk(&self.url, self.start, self.end)
|
||||||
@@ -94,10 +109,11 @@ impl DownloadChunkTask {
|
|||||||
Ok(self.chunk_index)
|
Ok(self.chunk_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn acquire_chunk_permit<'a>(
|
async fn acquire_chunk_permit<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
permit: &mut Option<SemaphorePermit<'a>>,
|
permit: &mut Option<SemaphorePermit<'a>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
*permit = match permit.take() {
|
*permit = match permit.take() {
|
||||||
// 如果有permit,则直接用
|
// 如果有permit,则直接用
|
||||||
Some(permit) => Some(permit),
|
Some(permit) => Some(permit),
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicU64, Ordering},
|
|
||||||
Arc,
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
},
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::{WrapErr, eyre};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
events::DownloadEvent,
|
events::DownloadEvent,
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
extensions::{AppHandleExt, EyreReportToMessage},
|
||||||
types::create_download_task_params::CreateDownloadTaskParams,
|
types::{
|
||||||
|
create_download_task_params::CreateDownloadTaskParams,
|
||||||
|
restart_download_task_params::RestartDownloadTaskParams,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -56,10 +60,11 @@ impl DownloadManager {
|
|||||||
manager
|
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()?;
|
let task_dir = self.get_task_dir()?;
|
||||||
std::fs::create_dir_all(&task_dir)
|
std::fs::create_dir_all(&task_dir)
|
||||||
.context(format!("创建下载任务目录`{}`失败", task_dir.display()))?;
|
.wrap_err(format!("创建下载任务目录`{}`失败", task_dir.display()))?;
|
||||||
|
|
||||||
let mut tasks = self.download_tasks.write();
|
let mut tasks = self.download_tasks.write();
|
||||||
for entry in std::fs::read_dir(&task_dir)?.filter_map(Result::ok) {
|
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>) {
|
pub fn pause_download_tasks(&self, task_ids: &Vec<String>) {
|
||||||
let tasks = self.download_tasks.read();
|
let tasks = self.download_tasks.read();
|
||||||
for task_id in task_ids {
|
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 Some(task) = tasks.get(task_id) else {
|
||||||
|
let err = eyre!("未找到ID对应的下载任务");
|
||||||
let err_title = "暂停下载任务失败";
|
let err_title = "暂停下载任务失败";
|
||||||
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = err_msg);
|
tracing::error!(err_title, message);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
task.set_state(DownloadTaskState::Paused);
|
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>) {
|
pub fn resume_download_tasks(&self, task_ids: &Vec<String>) {
|
||||||
let tasks = self.download_tasks.read();
|
let tasks = self.download_tasks.read();
|
||||||
for task_id in task_ids {
|
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 Some(task) = tasks.get(task_id) else {
|
||||||
|
let err = eyre!("未找到ID对应的下载任务");
|
||||||
let err_title = "继续下载任务失败";
|
let err_title = "继续下载任务失败";
|
||||||
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = err_msg);
|
tracing::error!(err_title, message);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
task.set_state(DownloadTaskState::Pending);
|
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>) {
|
pub fn delete_download_tasks(&self, task_ids: &Vec<String>) {
|
||||||
let mut tasks = self.download_tasks.write();
|
let mut tasks = self.download_tasks.write();
|
||||||
for task_id in task_ids {
|
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 Some(task) = tasks.remove(task_id) else {
|
||||||
|
let err = eyre!("未找到ID对应的下载任务");
|
||||||
let err_title = "删除下载任务失败";
|
let err_title = "删除下载任务失败";
|
||||||
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = err_msg);
|
tracing::error!(err_title, message);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// TODO: 应该先发删除新号再删文件
|
||||||
|
// 因为发信号失败会把任务重新塞回去
|
||||||
|
// 目前先删文件会导致发信号失败时出现 任务还在但文件没了的情况
|
||||||
if let Err(err) = self.delete_progress_file(task_id) {
|
if let Err(err) = self.delete_progress_file(task_id) {
|
||||||
let err_title = "删除下载任务失败";
|
let err_title = "删除下载任务失败";
|
||||||
let err_msg = format!("删除ID为`{task_id}`的下载任务文件失败: {err}");
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = err_msg);
|
tracing::error!(err_title, message);
|
||||||
tasks.insert(task_id.clone(), task);
|
tasks.insert(task_id.clone(), task);
|
||||||
continue;
|
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_title = "删除下载任务失败";
|
||||||
let err = err.context(format!("通知ID为`{task_id}`的下载任务删除失败"));
|
let message = err.to_message();
|
||||||
let string_chain = err.to_string_chain();
|
tracing::error!(err_title, message);
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
tasks.insert(task_id.clone(), task);
|
tasks.insert(task_id.clone(), task);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("已通知ID为`{task_id}`的下载任务删除");
|
tracing::debug!("已通知ID对应的下载任务删除");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn restart_download_tasks(&self, task_ids: &Vec<String>) {
|
pub fn restart_download_tasks(&self, task_ids: &Vec<String>) {
|
||||||
let tasks = self.download_tasks.read();
|
let tasks = self.download_tasks.read();
|
||||||
for task_id in task_ids {
|
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 Some(task) = tasks.get(task_id) else {
|
||||||
|
let err = eyre!("未找到ID对应的下载任务");
|
||||||
let err_title = "重来下载任务失败";
|
let err_title = "重来下载任务失败";
|
||||||
let err_msg = format!("未找到ID为`{task_id}`的下载任务");
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = err_msg);
|
tracing::error!(err_title, message);
|
||||||
continue;
|
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_title = "重来下载任务失败";
|
||||||
let err = err.context(format!("通知ID为`{task_id}`的下载任务重来失败"));
|
let err = err.wrap_err("通知ID对应的下载任务重来失败");
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
continue;
|
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>) {
|
async fn emit_download_speed_loop(app: AppHandle, byte_per_sec: Arc<AtomicU64>) {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
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 app_data_dir = self.app.path().app_data_dir()?;
|
||||||
let task_dir = app_data_dir.join(".下载任务");
|
let task_dir = app_data_dir.join(".下载任务");
|
||||||
Ok(task_dir)
|
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_dir = self.get_task_dir()?;
|
||||||
let task_file = task_dir.join(format!("{task_id}.json"));
|
let task_file = task_dir.join(format!("{task_id}.json"));
|
||||||
if task_file.exists() {
|
if task_file.exists() {
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use eyre::{OptionExt, WrapErr, eyre};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_specta::Event;
|
||||||
|
use tracing::instrument;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
downloader::tasks::{
|
downloader::{
|
||||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
download_task::DownloadTask,
|
||||||
json_task::JsonTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
tasks::{
|
||||||
video_process_task::VideoProcessTask, video_task::VideoTask,
|
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,
|
extensions::AppHandleExt,
|
||||||
|
plugin::hook_context::{
|
||||||
|
AfterPrepareContext, BeforeVideoProcessContext, HookContext, OnCompletedContext,
|
||||||
|
},
|
||||||
types::{
|
types::{
|
||||||
audio_quality::AudioQuality,
|
audio_quality::AudioQuality,
|
||||||
bangumi_info::BangumiInfo,
|
bangumi_info::BangumiInfo,
|
||||||
@@ -60,15 +70,18 @@ pub struct DownloadProgress {
|
|||||||
pub json_task: JsonTask,
|
pub json_task: JsonTask,
|
||||||
pub create_ts: u64,
|
pub create_ts: u64,
|
||||||
pub completed_ts: Option<u64>,
|
pub completed_ts: Option<u64>,
|
||||||
|
pub is_drm: bool,
|
||||||
|
pub is_preview: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadProgress {
|
impl DownloadProgress {
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn from_normal(
|
pub fn from_normal(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
info: &NormalInfo,
|
info: &NormalInfo,
|
||||||
aid: i64,
|
aid: i64,
|
||||||
cid: Option<i64>,
|
cid: Option<i64>,
|
||||||
) -> anyhow::Result<Vec<Self>> {
|
) -> eyre::Result<Vec<Self>> {
|
||||||
let config = app.get_config().read().clone();
|
let config = app.get_config().read().clone();
|
||||||
|
|
||||||
if let Some(ugc_season) = &info.ugc_season {
|
if let Some(ugc_season) = &info.ugc_season {
|
||||||
@@ -79,10 +92,11 @@ impl DownloadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_wrap)]
|
#[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 (episode, episode_order) = info.get_episode_with_order(ep_id)?;
|
||||||
let Some(duration) = episode.duration else {
|
let Some(duration) = episode.duration else {
|
||||||
return Err(anyhow!("找不到ep_id为`{ep_id}`的番剧的时长"));
|
return Err(eyre!("duration为None"));
|
||||||
};
|
};
|
||||||
// 将毫秒转换为秒
|
// 将毫秒转换为秒
|
||||||
let duration = duration / 1000;
|
let duration = duration / 1000;
|
||||||
@@ -90,7 +104,6 @@ impl DownloadProgress {
|
|||||||
let config = app.get_config().read().clone();
|
let config = app.get_config().read().clone();
|
||||||
|
|
||||||
let tasks = Tasks::new(&config, &episode.cover);
|
let tasks = Tasks::new(&config, &episode.cover);
|
||||||
|
|
||||||
let (up_name, up_uid, up_avatar) = if let Some(up_info) = &info.up_info {
|
let (up_name, up_uid, up_avatar) = if let Some(up_info) = &info.up_info {
|
||||||
(
|
(
|
||||||
Some(up_info.uname.clone()),
|
Some(up_info.uname.clone()),
|
||||||
@@ -103,7 +116,7 @@ impl DownloadProgress {
|
|||||||
|
|
||||||
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
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(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Bangumi,
|
episode_type: EpisodeType::Bangumi,
|
||||||
aid: episode.aid,
|
aid: episode.aid,
|
||||||
@@ -132,21 +145,20 @@ impl DownloadProgress {
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(&config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
Ok(progress)
|
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
|
let episode = info
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ep| ep.id == ep_id)
|
.find(|ep| ep.id == ep_id)
|
||||||
.context(format!("找不到ep_id为`{ep_id}`的课程"))?;
|
.ok_or_eyre("找不到ep_id对应的课程")?;
|
||||||
|
|
||||||
let config = app.get_config().read().clone();
|
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 create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||||
|
|
||||||
let mut progress = Self {
|
let progress = Self {
|
||||||
task_id: Uuid::new_v4().to_string(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Cheese,
|
episode_type: EpisodeType::Cheese,
|
||||||
aid: episode.aid,
|
aid: episode.aid,
|
||||||
@@ -183,23 +195,153 @@ impl DownloadProgress {
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(&config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
Ok(progress)
|
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_selected = self.video_task.selected;
|
||||||
let video_completed = self.video_task.completed;
|
let video_completed = self.video_task.completed;
|
||||||
let audio_selected = self.audio_task.selected;
|
let audio_selected = self.audio_task.selected;
|
||||||
let audio_completed = self.audio_task.completed;
|
let audio_completed = self.audio_task.completed;
|
||||||
|
|
||||||
if (!video_selected && !audio_selected) || (video_completed && audio_completed) {
|
if (!video_selected && !audio_selected) || (video_completed && audio_completed) {
|
||||||
// 如果视频和音频都没有选中,或者都已经完成,则不需要准备
|
// 如果视频和音频都没有选中,或者都已经完成,则更新需要格式化的字段就返回
|
||||||
|
self.update_fmt_fields(app)
|
||||||
|
.wrap_err("更新需要格式化的字段失败")?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +350,14 @@ impl DownloadProgress {
|
|||||||
match self.episode_type {
|
match self.episode_type {
|
||||||
EpisodeType::Normal => {
|
EpisodeType::Normal => {
|
||||||
let Some(bvid) = &self.bvid else {
|
let Some(bvid) = &self.bvid else {
|
||||||
return Err(anyhow!("progress中的bvid为None,无法获取视频链接"));
|
return Err(eyre!("progress中的bvid为None,无法获取视频链接"));
|
||||||
};
|
};
|
||||||
let media_url = bili_client
|
let media_url = bili_client
|
||||||
.get_normal_url(bvid, self.cid)
|
.get_normal_url(bvid, self.cid)
|
||||||
.await
|
.await
|
||||||
.context("获取视频链接失败")?;
|
.wrap_err("获取视频链接失败")?;
|
||||||
|
|
||||||
|
self.is_preview = !media_url.durl.is_empty() && media_url.dash.video.is_empty();
|
||||||
|
|
||||||
if video_selected && !video_completed {
|
if video_selected && !video_completed {
|
||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
@@ -222,14 +366,16 @@ impl DownloadProgress {
|
|||||||
|
|
||||||
if audio_selected && !audio_completed {
|
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 => {
|
EpisodeType::Bangumi => {
|
||||||
let media_url = bili_client
|
let media_url = bili_client
|
||||||
.get_bangumi_url(self.cid)
|
.get_bangumi_url(self.cid)
|
||||||
.await
|
.await
|
||||||
.context("获取番剧视频链接失败")?;
|
.wrap_err("获取番剧视频链接失败")?;
|
||||||
|
|
||||||
|
self.is_preview = media_url.is_preview != 0;
|
||||||
|
|
||||||
if video_selected && !video_completed {
|
if video_selected && !video_completed {
|
||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
@@ -238,17 +384,20 @@ impl DownloadProgress {
|
|||||||
|
|
||||||
if audio_selected && !audio_completed {
|
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 => {
|
EpisodeType::Cheese => {
|
||||||
let Some(ep_id) = self.ep_id else {
|
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
|
let media_url = bili_client
|
||||||
.get_cheese_url(ep_id)
|
.get_cheese_url(ep_id)
|
||||||
.await
|
.await
|
||||||
.context("获取课程视频链接失败")?;
|
.wrap_err("获取课程视频链接失败")?;
|
||||||
|
|
||||||
|
self.is_drm = media_url.is_drm;
|
||||||
|
self.is_preview = media_url.is_preview != 0;
|
||||||
|
|
||||||
if video_selected && !video_completed {
|
if video_selected && !video_completed {
|
||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
@@ -257,18 +406,23 @@ impl DownloadProgress {
|
|||||||
|
|
||||||
if audio_selected && !audio_completed {
|
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(())
|
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 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.episode_dir = episode_dir;
|
||||||
self.filename = filename;
|
self.filename = filename;
|
||||||
@@ -294,10 +448,14 @@ impl DownloadProgress {
|
|||||||
up_name: self.up_name.clone(),
|
up_name: self.up_name.clone(),
|
||||||
up_uid: self.up_uid,
|
up_uid: self.up_uid,
|
||||||
create_ts: self.create_ts,
|
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 progress = self.clone();
|
||||||
let file_name = format!("{}.json", progress.task_id);
|
let file_name = format!("{}.json", progress.task_id);
|
||||||
|
|
||||||
@@ -330,29 +488,22 @@ impl DownloadProgress {
|
|||||||
pub fn mark_uncompleted(&mut self) {
|
pub fn mark_uncompleted(&mut self) {
|
||||||
self.video_task.mark_uncompleted();
|
self.video_task.mark_uncompleted();
|
||||||
self.audio_task.mark_uncompleted();
|
self.audio_task.mark_uncompleted();
|
||||||
self.video_process_task.completed = false;
|
self.video_process_task.mark_uncompleted();
|
||||||
self.danmaku_task.completed = false;
|
self.danmaku_task.mark_uncompleted();
|
||||||
self.subtitle_task.completed = false;
|
self.subtitle_task.mark_uncompleted();
|
||||||
self.cover_task.completed = false;
|
self.cover_task.mark_uncompleted();
|
||||||
self.nfo_task.completed = false;
|
self.nfo_task.mark_uncompleted();
|
||||||
self.json_task.completed = false;
|
self.json_task.mark_uncompleted();
|
||||||
}
|
|
||||||
|
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
fn create_normal_progresses_for_single(
|
fn create_normal_progresses_for_single(
|
||||||
info: &NormalInfo,
|
info: &NormalInfo,
|
||||||
cid: Option<i64>,
|
cid: Option<i64>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> anyhow::Result<Vec<DownloadProgress>> {
|
) -> eyre::Result<Vec<DownloadProgress>> {
|
||||||
let tasks = Tasks::new(config, &info.pic);
|
let tasks = Tasks::new(config, &info.pic);
|
||||||
|
|
||||||
let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
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 {
|
if let Some(cid) = cid {
|
||||||
// 如果有cid,则说明是要下载单个分P
|
// 如果有cid,则说明是要下载单个分P
|
||||||
let Some(page) = info.pages.iter().find(|p| p.cid == cid) else {
|
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(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: info.aid,
|
aid: info.aid,
|
||||||
@@ -391,18 +542,16 @@ fn create_normal_progresses_for_single(
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
return Ok(vec![progress]);
|
return Ok(vec![progress]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.pages.len() == 1 {
|
if info.pages.len() == 1 {
|
||||||
// 如果只有一个分P,则直接创建一个progress
|
// 如果只有一个分P,则直接创建一个progress
|
||||||
let mut progress = DownloadProgress {
|
let progress = DownloadProgress {
|
||||||
task_id: Uuid::new_v4().to_string(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: info.aid,
|
aid: info.aid,
|
||||||
@@ -431,18 +580,16 @@ fn create_normal_progresses_for_single(
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
return Ok(vec![progress]);
|
return Ok(vec![progress]);
|
||||||
}
|
}
|
||||||
// 如果有多个分P,则为每个分P创建一个progress
|
// 如果有多个分P,则为每个分P创建一个progress
|
||||||
let mut progresses = Vec::new();
|
let mut progresses = Vec::new();
|
||||||
for page in &info.pages {
|
for page in &info.pages {
|
||||||
let mut progress = DownloadProgress {
|
let progress = DownloadProgress {
|
||||||
task_id: Uuid::new_v4().to_string(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: info.aid,
|
aid: info.aid,
|
||||||
@@ -471,30 +618,29 @@ fn create_normal_progresses_for_single(
|
|||||||
json_task: tasks.json.clone(),
|
json_task: tasks.json.clone(),
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
progresses.push(progress);
|
progresses.push(progress);
|
||||||
}
|
}
|
||||||
Ok(progresses)
|
Ok(progresses)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
fn create_normal_progresses_for_season(
|
fn create_normal_progresses_for_season(
|
||||||
ugc_season: &UgcSeason,
|
ugc_season: &UgcSeason,
|
||||||
info: &NormalInfo,
|
info: &NormalInfo,
|
||||||
aid: i64,
|
aid: i64,
|
||||||
cid: Option<i64>,
|
cid: Option<i64>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> anyhow::Result<Vec<DownloadProgress>> {
|
) -> eyre::Result<Vec<DownloadProgress>> {
|
||||||
let section_index = ugc_season
|
let section_index = ugc_season
|
||||||
.sections
|
.sections
|
||||||
.iter()
|
.iter()
|
||||||
.position(|s| s.episodes.iter().any(|e| e.aid == aid))
|
.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];
|
let section = &ugc_season.sections[section_index];
|
||||||
#[allow(clippy::cast_possible_wrap)]
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
let (ep, episode_order) = section
|
let (ep, episode_order) = section
|
||||||
@@ -503,7 +649,7 @@ fn create_normal_progresses_for_season(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, e)| (e, i as i64 + 1))
|
.map(|(i, e)| (e, i as i64 + 1))
|
||||||
.find(|(e, _)| e.aid == aid)
|
.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);
|
let tasks = Tasks::new(config, &ep.arc.pic);
|
||||||
|
|
||||||
@@ -512,9 +658,9 @@ fn create_normal_progresses_for_season(
|
|||||||
if let Some(cid) = cid {
|
if let Some(cid) = cid {
|
||||||
// 如果有cid,则说明是要下载单个分P
|
// 如果有cid,则说明是要下载单个分P
|
||||||
let Some(page) = ep.pages.iter().find(|p| p.cid == cid) else {
|
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(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: ep.aid,
|
aid: ep.aid,
|
||||||
@@ -543,18 +689,16 @@ fn create_normal_progresses_for_season(
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
return Ok(vec![progress]);
|
return Ok(vec![progress]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ep.pages.len() == 1 {
|
if ep.pages.len() == 1 {
|
||||||
// 如果只有一个分P,则直接创建一个progress
|
// 如果只有一个分P,则直接创建一个progress
|
||||||
let mut progress = DownloadProgress {
|
let progress = DownloadProgress {
|
||||||
task_id: Uuid::new_v4().to_string(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: ep.aid,
|
aid: ep.aid,
|
||||||
@@ -583,19 +727,17 @@ fn create_normal_progresses_for_season(
|
|||||||
json_task: tasks.json,
|
json_task: tasks.json,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
return Ok(vec![progress]);
|
return Ok(vec![progress]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有多个分P,则为每个分P创建一个progress
|
// 如果有多个分P,则为每个分P创建一个progress
|
||||||
let mut progresses = Vec::new();
|
let mut progresses = Vec::new();
|
||||||
for page in &ep.pages {
|
for page in &ep.pages {
|
||||||
let mut progress = DownloadProgress {
|
let progress = DownloadProgress {
|
||||||
task_id: Uuid::new_v4().to_string(),
|
task_id: Uuid::new_v4().to_string(),
|
||||||
episode_type: EpisodeType::Normal,
|
episode_type: EpisodeType::Normal,
|
||||||
aid: ep.aid,
|
aid: ep.aid,
|
||||||
@@ -624,12 +766,10 @@ fn create_normal_progresses_for_season(
|
|||||||
json_task: tasks.json.clone(),
|
json_task: tasks.json.clone(),
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
|
is_drm: false,
|
||||||
|
is_preview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
progress
|
|
||||||
.update_fmt_fields(config)
|
|
||||||
.context("更新需要格式化的字段失败")?;
|
|
||||||
|
|
||||||
progresses.push(progress);
|
progresses.push(progress);
|
||||||
}
|
}
|
||||||
Ok(progresses)
|
Ok(progresses)
|
||||||
@@ -656,6 +796,7 @@ impl Tasks {
|
|||||||
content_length: 0,
|
content_length: 0,
|
||||||
chunks: Vec::new(),
|
chunks: Vec::new(),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let audio = AudioTask {
|
let audio = AudioTask {
|
||||||
@@ -665,6 +806,7 @@ impl Tasks {
|
|||||||
content_length: 0,
|
content_length: 0,
|
||||||
chunks: Vec::new(),
|
chunks: Vec::new(),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let video_process = VideoProcessTask {
|
let video_process = VideoProcessTask {
|
||||||
@@ -672,6 +814,7 @@ impl Tasks {
|
|||||||
embed_chapter_selected: config.embed_chapter,
|
embed_chapter_selected: config.embed_chapter,
|
||||||
embed_skip_selected: config.embed_skip,
|
embed_skip_selected: config.embed_skip,
|
||||||
completed: false,
|
completed: false,
|
||||||
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let danmaku = DanmakuTask {
|
let danmaku = DanmakuTask {
|
||||||
@@ -679,6 +822,7 @@ impl Tasks {
|
|||||||
ass_selected: config.download_ass_danmaku,
|
ass_selected: config.download_ass_danmaku,
|
||||||
json_selected: config.download_json_danmaku,
|
json_selected: config.download_json_danmaku,
|
||||||
completed: false,
|
completed: false,
|
||||||
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let subtitle = SubtitleTask {
|
let subtitle = SubtitleTask {
|
||||||
@@ -695,6 +839,7 @@ impl Tasks {
|
|||||||
let nfo = NfoTask {
|
let nfo = NfoTask {
|
||||||
selected: config.download_nfo,
|
selected: config.download_nfo,
|
||||||
completed: false,
|
completed: false,
|
||||||
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = JsonTask {
|
let json = JsonTask {
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
use std::{
|
use std::{sync::Arc, time::Duration};
|
||||||
sync::Arc,
|
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::WrapErr;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{watch, SemaphorePermit},
|
sync::{SemaphorePermit, watch},
|
||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
downloader::episode_type::EpisodeType,
|
||||||
events::DownloadEvent,
|
events::DownloadEvent,
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
extensions::{AppHandleExt, EyreReportToMessage},
|
||||||
types::create_download_task_params::CreateDownloadTaskParams,
|
types::create_download_task_params::CreateDownloadTaskParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,10 +26,13 @@ pub struct DownloadTask {
|
|||||||
pub cancel_sender: watch::Sender<()>,
|
pub cancel_sender: watch::Sender<()>,
|
||||||
pub delete_sender: watch::Sender<()>,
|
pub delete_sender: watch::Sender<()>,
|
||||||
pub task_id: String,
|
pub task_id: String,
|
||||||
|
pub trace_fields: DownloadTaskTraceFields,
|
||||||
pub progress: RwLock<DownloadProgress>,
|
pub progress: RwLock<DownloadProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadTask {
|
impl DownloadTask {
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn from_params(app: &AppHandle, params: &CreateDownloadTaskParams) -> Vec<Arc<Self>> {
|
pub fn from_params(app: &AppHandle, params: &CreateDownloadTaskParams) -> Vec<Arc<Self>> {
|
||||||
use CreateDownloadTaskParams::{Bangumi, Cheese, Normal};
|
use CreateDownloadTaskParams::{Bangumi, Cheese, Normal};
|
||||||
|
|
||||||
@@ -38,15 +40,24 @@ impl DownloadTask {
|
|||||||
match params {
|
match params {
|
||||||
Normal(params) => {
|
Normal(params) => {
|
||||||
for &(aid, cid) in ¶ms.aid_cid_pairs {
|
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)
|
let progress = match DownloadProgress::from_normal(app, ¶ms.info, aid, cid)
|
||||||
{
|
{
|
||||||
Ok(progress) => progress,
|
Ok(progress) => progress,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let cid = cid.map_or("None".to_string(), |id| id.to_string());
|
let err_title = "创建普通视频的下载进度失败";
|
||||||
let ids_string = format!("aid: {aid}, cid: {cid}");
|
let message = err.to_message();
|
||||||
let err_title = format!("{ids_string} 创建普通视频的下载进度失败");
|
tracing::error!(err_title, message);
|
||||||
let string_chain = err.to_string_chain();
|
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -56,13 +67,21 @@ impl DownloadTask {
|
|||||||
}
|
}
|
||||||
Bangumi(params) => {
|
Bangumi(params) => {
|
||||||
for ep_id in ¶ms.ep_ids {
|
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) {
|
let progress = match DownloadProgress::from_bangumi(app, ¶ms.info, *ep_id) {
|
||||||
Ok(progress) => progress,
|
Ok(progress) => progress,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let ids_string = format!("ep_id: {ep_id}");
|
let err_title = "创建番剧的下载进度失败";
|
||||||
let err_title = format!("{ids_string} 创建番剧的下载进度失败");
|
let message = err.to_message();
|
||||||
let string_chain = err.to_string_chain();
|
tracing::error!(err_title, message);
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -72,13 +91,21 @@ impl DownloadTask {
|
|||||||
}
|
}
|
||||||
Cheese(params) => {
|
Cheese(params) => {
|
||||||
for ep_id in ¶ms.ep_ids {
|
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) {
|
let progress = match DownloadProgress::from_cheese(app, ¶ms.info, *ep_id) {
|
||||||
Ok(progress) => progress,
|
Ok(progress) => progress,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let ids_string = format!("ep_id: {ep_id}");
|
let err_title = "创建课程的下载进度失败";
|
||||||
let err_title = format!("{ids_string} 创建课程的下载进度失败");
|
let message = err.to_message();
|
||||||
let string_chain = err.to_string_chain();
|
tracing::error!(err_title, message);
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -90,15 +117,38 @@ impl DownloadTask {
|
|||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
for progress in progresses {
|
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) {
|
if let Err(err) = progress.save(app, true) {
|
||||||
let ids_string = progress.get_ids_string();
|
let err_title = "保存下载进度到文件失败";
|
||||||
let episode_title = &progress.episode_title;
|
let message = err.to_message();
|
||||||
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
|
tracing::error!(err_title, message);
|
||||||
let string_chain = err.to_string_chain();
|
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (restart_sender, _) = watch::channel(());
|
||||||
let (cancel_sender, _) = watch::channel(());
|
let (cancel_sender, _) = watch::channel(());
|
||||||
let (delete_sender, _) = watch::channel(());
|
let (delete_sender, _) = watch::channel(());
|
||||||
@@ -110,6 +160,7 @@ impl DownloadTask {
|
|||||||
cancel_sender,
|
cancel_sender,
|
||||||
delete_sender,
|
delete_sender,
|
||||||
task_id: progress.task_id.clone(),
|
task_id: progress.task_id.clone(),
|
||||||
|
trace_fields: DownloadTaskTraceFields::from(&progress),
|
||||||
progress: RwLock::new(progress),
|
progress: RwLock::new(progress),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +190,7 @@ impl DownloadTask {
|
|||||||
cancel_sender,
|
cancel_sender,
|
||||||
delete_sender,
|
delete_sender,
|
||||||
task_id: progress.task_id.clone(),
|
task_id: progress.task_id.clone(),
|
||||||
|
trace_fields: DownloadTaskTraceFields::from(&progress),
|
||||||
progress: RwLock::new(progress),
|
progress: RwLock::new(progress),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,8 +199,26 @@ impl DownloadTask {
|
|||||||
task
|
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>) {
|
async fn process(self: Arc<Self>) {
|
||||||
let task_id = &self.task_id;
|
|
||||||
let state = *self.state_sender.borrow();
|
let state = *self.state_sender.borrow();
|
||||||
let progress = self.progress.read().clone();
|
let progress = self.progress.read().clone();
|
||||||
let _ = DownloadEvent::TaskCreate { state, progress }.emit(&self.app);
|
let _ = DownloadEvent::TaskCreate { state, progress }.emit(&self.app);
|
||||||
@@ -179,7 +249,7 @@ impl DownloadTask {
|
|||||||
download_task_option = None;
|
download_task_option = None;
|
||||||
if let Some(permit) = permit.take() {
|
if let Some(permit) = permit.take() {
|
||||||
drop(permit);
|
drop(permit);
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
() = self.acquire_task_permit(&mut permit), if state_is_pending => {},
|
() = self.acquire_task_permit(&mut permit), if state_is_pending => {},
|
||||||
@@ -190,7 +260,7 @@ impl DownloadTask {
|
|||||||
|
|
||||||
_ = restart_receiver.changed() => {
|
_ = restart_receiver.changed() => {
|
||||||
self.handle_restart_notify();
|
self.handle_restart_notify();
|
||||||
tracing::debug!("ID为`{task_id}`的下载任务已重来");
|
tracing::debug!("下载任务已重来");
|
||||||
download_task_option = None;
|
download_task_option = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,52 +278,32 @@ impl DownloadTask {
|
|||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("ID为`{task_id}`的下载任务已删除");
|
tracing::debug!("下载任务已删除");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn download(self: &Arc<Self>) {
|
async fn download(self: &Arc<Self>) {
|
||||||
let mut progress = self.progress.read().clone();
|
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() {
|
if progress.is_completed() {
|
||||||
tracing::info!("{ids_string} 跳过`{episode_title}`的下载,因为它已经完成");
|
tracing::info!("跳过下载,因为下载任务已完成");
|
||||||
self.set_state(DownloadTaskState::Completed);
|
self.set_state(DownloadTaskState::Completed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("{ids_string} 开始准备`{episode_title}`的下载");
|
tracing::debug!("开始下载");
|
||||||
let _ = DownloadEvent::ProgressPreparing {
|
if let Err(err) = progress
|
||||||
task_id: self.task_id.clone(),
|
.process(self)
|
||||||
}
|
|
||||||
.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)
|
|
||||||
.await
|
.await
|
||||||
.context("[继续]失败的任务可以断点续传")
|
.wrap_err("[继续]失败的任务可以断点续传")
|
||||||
{
|
{
|
||||||
let err_title = format!("{ids_string} `{episode_title}`下载失败");
|
let err_title = "下载失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
|
|
||||||
self.set_state(DownloadTaskState::Failed);
|
self.set_state(DownloadTaskState::Failed);
|
||||||
|
|
||||||
@@ -263,103 +313,7 @@ impl DownloadTask {
|
|||||||
self.sleep_between_task().await;
|
self.sleep_between_task().await;
|
||||||
|
|
||||||
self.set_state(DownloadTaskState::Completed);
|
self.set_state(DownloadTaskState::Completed);
|
||||||
tracing::info!("{ids_string} `{episode_title}`下载完成");
|
tracing::info!("下载成功");
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sleep_between_task(&self) {
|
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>>) {
|
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 = match permit.take() {
|
||||||
// 如果有permit,则直接用
|
// 如果有permit,则直接用
|
||||||
Some(permit) => Some(permit),
|
Some(permit) => Some(permit),
|
||||||
@@ -394,14 +344,13 @@ impl DownloadTask {
|
|||||||
.task_sem
|
.task_sem
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(eyre::Report::from)
|
||||||
{
|
{
|
||||||
Ok(permit) => Some(permit),
|
Ok(permit) => Some(permit),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title =
|
let err_title = "获取下载任务的permit失败";
|
||||||
format!("{ids_string} `{episode_title}`获取下载任务的permit失败");
|
let message = err.to_message();
|
||||||
let string_chain = err.to_string_chain();
|
tracing::error!(err_title, message);
|
||||||
tracing::error!(err_title, message = string_chain);
|
|
||||||
|
|
||||||
self.set_state(DownloadTaskState::Failed);
|
self.set_state(DownloadTaskState::Failed);
|
||||||
|
|
||||||
@@ -417,16 +366,17 @@ impl DownloadTask {
|
|||||||
if let Err(err) = self
|
if let Err(err) = self
|
||||||
.state_sender
|
.state_sender
|
||||||
.send(DownloadTaskState::Downloading)
|
.send(DownloadTaskState::Downloading)
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(eyre::Report::from)
|
||||||
{
|
{
|
||||||
let err_title = format!("{ids_string} `{episode_title}`发送状态`Downloading`失败");
|
let err_title = "发送状态`Downloading`失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
|
|
||||||
self.set_state(DownloadTaskState::Failed);
|
self.set_state(DownloadTaskState::Failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn handle_state_change<'a>(
|
async fn handle_state_change<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
permit: &mut Option<SemaphorePermit<'a>>,
|
permit: &mut Option<SemaphorePermit<'a>>,
|
||||||
@@ -440,14 +390,14 @@ impl DownloadTask {
|
|||||||
// 稍微等一下再释放permit
|
// 稍微等一下再释放permit
|
||||||
// 避免大批量暂停时,本应暂停的任务因拿到permit而稍微下载一小段(虽然最终会被暂停)
|
// 避免大批量暂停时,本应暂停的任务因拿到permit而稍微下载一小段(虽然最终会被暂停)
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
let task_id = &self.task_id;
|
tracing::debug!("下载任务已暂停");
|
||||||
tracing::debug!("ID为`{task_id}`的下载任务已暂停");
|
|
||||||
if let Some(permit) = permit.take() {
|
if let Some(permit) = permit.take() {
|
||||||
drop(permit);
|
drop(permit);
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
fn handle_restart_notify(&self) {
|
fn handle_restart_notify(&self) {
|
||||||
self.update_progress(|p| {
|
self.update_progress(|p| {
|
||||||
p.mark_uncompleted();
|
p.mark_uncompleted();
|
||||||
@@ -455,24 +405,43 @@ impl DownloadTask {
|
|||||||
self.set_state(DownloadTaskState::Pending);
|
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) {
|
pub fn set_state(&self, state: DownloadTaskState) {
|
||||||
let (episode_title, ids_string) = {
|
if let Err(err) = self.state_sender.send(state).map_err(eyre::Report::from) {
|
||||||
let progress = self.progress.read();
|
let err_title = format!("发送状态`{state:?}`失败");
|
||||||
(progress.episode_title.clone(), progress.get_ids_string())
|
let message = err.to_message();
|
||||||
};
|
tracing::error!(err_title, message);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) {
|
pub fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) {
|
||||||
// 修改数据
|
// 修改数据
|
||||||
let updated_progress = {
|
let updated_progress = {
|
||||||
let mut progress = self.progress.write();
|
let mut progress = self.progress.write();
|
||||||
update_fn(&mut progress);
|
update_fn(&mut progress);
|
||||||
|
// TODO: 这里应该返回 progress.clone()
|
||||||
|
// 专门用一个 {} 框出来就是为了避免在emit和save期间仍持有写锁
|
||||||
|
// 然而这里弄错了progress的类型
|
||||||
|
// 错把progress当成了DownloadProgress,实则类型为RwLockWriteGuard
|
||||||
progress
|
progress
|
||||||
};
|
};
|
||||||
// 发送更新事件并保存到文件
|
// 发送更新事件并保存到文件
|
||||||
@@ -482,11 +451,45 @@ impl DownloadTask {
|
|||||||
.emit(&self.app);
|
.emit(&self.app);
|
||||||
|
|
||||||
if let Err(err) = updated_progress.save(&self.app, false) {
|
if let Err(err) = updated_progress.save(&self.app, false) {
|
||||||
let ids_string = updated_progress.get_ids_string();
|
let err_title = "保存下载进度到文件失败";
|
||||||
let episode_title = &updated_progress.episode_title;
|
let message = err.to_message();
|
||||||
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
|
tracing::error!(err_title, message);
|
||||||
let string_chain = err.to_string_chain();
|
}
|
||||||
tracing::error!(err_title, message = string_chain);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tauri::AppHandle;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::{download_progress::DownloadProgress, episode_type::EpisodeType},
|
downloader::{download_progress::DownloadProgress, episode_type::EpisodeType},
|
||||||
@@ -23,15 +24,16 @@ pub trait GetOrInitEpisodeInfo {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
) -> eyre::Result<&'a mut EpisodeInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn get_or_init<'a>(
|
async fn get_or_init<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
) -> eyre::Result<&'a mut EpisodeInfo> {
|
||||||
if let Some(info) = self {
|
if let Some(info) = self {
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
@@ -44,23 +46,23 @@ impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
|||||||
let info = bili_client
|
let info = bili_client
|
||||||
.get_normal_info(GetNormalInfoParams::Aid(aid))
|
.get_normal_info(GetNormalInfoParams::Aid(aid))
|
||||||
.await
|
.await
|
||||||
.context("获取普通视频信息失败")?;
|
.wrap_err("获取普通视频信息失败")?;
|
||||||
EpisodeInfo::Normal(info)
|
EpisodeInfo::Normal(info)
|
||||||
}
|
}
|
||||||
EpisodeType::Bangumi => {
|
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
|
let info = bili_client
|
||||||
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
|
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
|
||||||
.await
|
.await
|
||||||
.context("获取番剧信息失败")?;
|
.wrap_err("获取番剧信息失败")?;
|
||||||
EpisodeInfo::Bangumi(info, ep_id)
|
EpisodeInfo::Bangumi(info, ep_id)
|
||||||
}
|
}
|
||||||
EpisodeType::Cheese => {
|
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
|
let info = bili_client
|
||||||
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
|
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
|
||||||
.await
|
.await
|
||||||
.context("获取课程信息失败")?;
|
.wrap_err("获取课程信息失败")?;
|
||||||
EpisodeInfo::Cheese(info, ep_id)
|
EpisodeInfo::Cheese(info, ep_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::{OptionExt, WrapErr};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
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;
|
use super::episode_type::EpisodeType;
|
||||||
|
|
||||||
@@ -26,21 +31,22 @@ pub struct FmtParams {
|
|||||||
pub up_name: Option<String>,
|
pub up_name: Option<String>,
|
||||||
pub up_uid: Option<i64>,
|
pub up_uid: Option<i64>,
|
||||||
pub create_ts: u64,
|
pub create_ts: u64,
|
||||||
|
pub video_quality: VideoQuality,
|
||||||
|
pub codec_type: CodecType,
|
||||||
|
pub audio_quality: AudioQuality,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FmtParams {
|
impl FmtParams {
|
||||||
pub fn get_episode_dir_and_filename(
|
#[instrument(level = "error", skip_all)]
|
||||||
&self,
|
pub fn get_episode_dir_and_filename(&self, config: &Config) -> eyre::Result<(PathBuf, String)> {
|
||||||
config: &Config,
|
|
||||||
) -> anyhow::Result<(PathBuf, String)> {
|
|
||||||
use strfmt::strfmt;
|
use strfmt::strfmt;
|
||||||
|
|
||||||
let mut json_value =
|
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
|
let json_map = json_value
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.context("FmtParams不是JSON对象")?;
|
.ok_or_eyre("FmtParams不是JSON对象")?;
|
||||||
// 格式化时间字段
|
// 格式化时间字段
|
||||||
format_time_fields(json_map, &config.time_fmt);
|
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 dir_fmt_parts: Vec<&str> = dir_fmt.split('/').collect();
|
||||||
let mut dir_names = Vec::new();
|
let mut dir_names = Vec::new();
|
||||||
for fmt in dir_fmt_parts {
|
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);
|
let dir_name = filename_filter(&dir_name);
|
||||||
if !dir_name.is_empty() {
|
if !dir_name.is_empty() {
|
||||||
dir_names.push(dir_name);
|
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();
|
let mut episode_dir = config.download_dir.clone();
|
||||||
for dir_name in dir_names {
|
for dir_name in dir_names {
|
||||||
@@ -88,16 +94,16 @@ impl FmtParams {
|
|||||||
|
|
||||||
#[allow(clippy::cast_possible_wrap)]
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
fn format_time_fields(json_map: &mut Map<String, Value>, time_fmt: &str) {
|
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) = json_map.get("pub_ts").and_then(Value::as_i64)
|
||||||
if let Some(ts_string) = ts_to_string(ts, time_fmt) {
|
&& let Some(ts_string) = ts_to_string(ts, time_fmt)
|
||||||
json_map.insert("pub_ts".to_string(), Value::String(ts_string));
|
{
|
||||||
}
|
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) = json_map.get("create_ts").and_then(Value::as_u64)
|
||||||
if let Some(ts_string) = ts_to_string(ts as i64, time_fmt) {
|
&& let Some(ts_string) = ts_to_string(ts as i64, time_fmt)
|
||||||
json_map.insert("create_ts".to_string(), Value::String(ts_string));
|
{
|
||||||
}
|
json_map.insert("create_ts".to_string(), Value::String(ts_string));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use eyre::{WrapErr, eyre};
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
use tracing::{Instrument, instrument};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::FileExistAction,
|
||||||
downloader::{
|
downloader::{
|
||||||
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||||
download_task::DownloadTask, media_chunk::MediaChunk,
|
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||||
},
|
},
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
extensions::{AppHandleExt, EyreReportToMessage},
|
||||||
types::{
|
types::{
|
||||||
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
||||||
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
||||||
@@ -28,6 +30,7 @@ use crate::{
|
|||||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct AudioTask {
|
pub struct AudioTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -35,14 +38,11 @@ pub struct AudioTask {
|
|||||||
pub content_length: u64,
|
pub content_length: u64,
|
||||||
pub chunks: Vec<MediaChunk>,
|
pub chunks: Vec<MediaChunk>,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub skipped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioTask {
|
impl AudioTask {
|
||||||
pub async fn prepare_normal(
|
pub async fn prepare_normal(&mut self, app: &AppHandle, media_url: &NormalMediaUrl) {
|
||||||
&mut self,
|
|
||||||
app: &AppHandle,
|
|
||||||
media_url: &NormalMediaUrl,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
|
|
||||||
if let Some(medias) = &media_url.dash.audio {
|
if let Some(medias) = &media_url.dash.audio {
|
||||||
@@ -54,7 +54,7 @@ impl AudioTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -62,7 +62,9 @@ impl AudioTask {
|
|||||||
id,
|
id,
|
||||||
url_with_content_length,
|
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.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -83,7 +85,9 @@ impl AudioTask {
|
|||||||
id,
|
id,
|
||||||
url_with_content_length,
|
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.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
MediaForPrepare {
|
MediaForPrepare {
|
||||||
id,
|
id,
|
||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
join_set.spawn(get_url_with_content_length_task.in_current_span());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias);
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prepare_bangumi(
|
pub async fn prepare_bangumi(&mut self, app: &AppHandle, media_url: &BangumiMediaUrl) {
|
||||||
&mut self,
|
|
||||||
app: &AppHandle,
|
|
||||||
media_url: &BangumiMediaUrl,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let Some(dash) = &media_url.dash else {
|
let Some(dash) = &media_url.dash else {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(medias) = &dash.audio else {
|
let Some(medias) = &dash.audio else {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if medias.is_empty() {
|
if medias.is_empty() {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
@@ -152,50 +156,50 @@ impl AudioTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
MediaForPrepare {
|
MediaForPrepare {
|
||||||
id,
|
id,
|
||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
join_set.spawn(get_url_with_content_length_task.in_current_span());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias);
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prepare_cheese(
|
pub async fn prepare_cheese(&mut self, app: &AppHandle, media_url: &CheeseMediaUrl) {
|
||||||
&mut self,
|
|
||||||
app: &AppHandle,
|
|
||||||
media_url: &CheeseMediaUrl,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let Some(dash) = &media_url.dash else {
|
let Some(dash) = &media_url.dash else {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(medias) = &dash.audio else {
|
let Some(medias) = &dash.audio else {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if medias.is_empty() {
|
if medias.is_empty() {
|
||||||
// 如果没有音频,则直接返回
|
// 如果没有音频,则直接返回
|
||||||
self.completed = true;
|
self.completed = true;
|
||||||
return Ok(());
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
@@ -208,46 +212,52 @@ impl AudioTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
MediaForPrepare {
|
MediaForPrepare {
|
||||||
id,
|
id,
|
||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
join_set.spawn(get_url_with_content_length_task.in_current_span());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias);
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) -> anyhow::Result<()> {
|
fn prepare(&mut self, app: &AppHandle, medias: &[MediaForPrepare]) {
|
||||||
if medias.is_empty() {
|
if medias.is_empty() {
|
||||||
return Err(anyhow!("获取音频地址失败"));
|
self.completed = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let quality_priority = app.get_config().read().audio_quality_priority.clone();
|
// 如果`audio_quality`为`Unknown`,则更倾向于使用优先级选择
|
||||||
let priority_map: HashMap<&AudioQuality, usize> = quality_priority
|
let prefer_select_by_priority = self.audio_quality == AudioQuality::Unknown;
|
||||||
.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)
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
self.audio_quality = media.id.into();
|
||||||
|
|
||||||
@@ -278,8 +288,6 @@ impl AudioTask {
|
|||||||
self.content_length = content_length;
|
self.content_length = content_length;
|
||||||
self.chunks = chunks;
|
self.chunks = chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_uncompleted(&mut self) {
|
pub fn mark_uncompleted(&mut self) {
|
||||||
@@ -287,38 +295,51 @@ impl AudioTask {
|
|||||||
self.chunks.iter_mut().for_each(|chunk| {
|
self.chunks.iter_mut().for_each(|chunk| {
|
||||||
chunk.completed = false;
|
chunk.completed = false;
|
||||||
});
|
});
|
||||||
|
self.skipped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
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!(
|
let temp_file_path = episode_dir.join(format!(
|
||||||
"{filename}.m4a.com.lanyeeee.bilibili-video-downloader"
|
"{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()
|
OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&temp_file_path)?
|
.open(&temp_file_path)?
|
||||||
} else {
|
} else {
|
||||||
// 如果文件不存在,创建它并预分配空间
|
// 如果临时文件不能重用,则创建个新的
|
||||||
let file = File::create(&temp_file_path)?;
|
let file = File::create(&temp_file_path)?;
|
||||||
file.allocate(audio_task.content_length)?;
|
file.allocate(audio_task.content_length)?;
|
||||||
file
|
file
|
||||||
@@ -339,25 +360,31 @@ impl AudioTask {
|
|||||||
download_task: download_task.clone(),
|
download_task: download_task.clone(),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
url: audio_task.url.to_string(),
|
url: audio_task.url.clone(),
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
chunk_index,
|
chunk_index,
|
||||||
};
|
};
|
||||||
|
|
||||||
join_set.spawn(async move {
|
let chunk_order = chunk_index + 1;
|
||||||
download_chunk_task.process().await.context(format!(
|
let chunk_task = async move {
|
||||||
"分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})"
|
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 {
|
||||||
match download_video_result {
|
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),
|
Ok(i) => download_task.update_progress(|p| p.audio_task.chunks[i].completed = true),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败");
|
let err_title = "音频的一个分片下载失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,32 +397,31 @@ impl AudioTask {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|chunk| chunk.completed);
|
.all(|chunk| chunk.completed);
|
||||||
if !download_completed {
|
if !download_completed {
|
||||||
return Err(anyhow!(
|
return Err(eyre!(
|
||||||
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||||
temp_file_path.display()
|
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()
|
temp_file_path.display()
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
if !is_audio_file_complete {
|
if !is_audio_file_complete {
|
||||||
download_task.update_progress(|p| p.video_task.mark_uncompleted());
|
download_task.update_progress(|p| p.audio_task.mark_uncompleted());
|
||||||
return Err(anyhow!(
|
return Err(eyre!(
|
||||||
"音频文件`{}`不完整,[继续]会重新下载所有分片",
|
"音频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||||
temp_file_path.display()
|
temp_file_path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重命名临时文件
|
// 重命名临时文件
|
||||||
let m4a_path = episode_dir.join(format!("{filename}.m4a"));
|
|
||||||
if m4a_path.exists() {
|
if m4a_path.exists() {
|
||||||
std::fs::remove_file(&m4a_path)
|
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(),
|
temp_file_path.display(),
|
||||||
m4a_path.display()
|
m4a_path.display()
|
||||||
@@ -412,3 +438,35 @@ struct MediaForPrepare {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub url_with_content_length: Vec<(String, u64)>,
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::WrapErr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||||
@@ -10,6 +11,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct CoverTask {
|
pub struct CoverTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -17,26 +19,31 @@ pub struct CoverTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CoverTask {
|
impl CoverTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
let bili_client = download_task.app.get_bili_client();
|
let bili_client = download_task.app.get_bili_client();
|
||||||
let (cover_data, ext) = bili_client
|
let (cover_data, ext) = bili_client
|
||||||
.get_cover_data_and_ext(&progress.cover_task.url)
|
.get_cover_data_and_ext(&progress.cover_task.url)
|
||||||
.await
|
.await
|
||||||
.context("获取封面失败")?;
|
.wrap_err("获取封面失败")?;
|
||||||
|
|
||||||
let save_path = episode_dir.join(format!("{filename}.{ext}"));
|
let save_path = episode_dir.join(format!("{filename}.{ext}"));
|
||||||
std::fs::write(&save_path, cover_data)
|
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);
|
download_task.update_progress(|p| p.cover_task.completed = true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::{fs::File, sync::Arc};
|
use std::{fs::File, sync::Arc};
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::WrapErr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::FileExistAction,
|
||||||
danmaku_xml_to_ass::xml_to_ass,
|
danmaku_xml_to_ass::xml_to_ass,
|
||||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||||
extensions::AppHandleExt,
|
extensions::AppHandleExt,
|
||||||
@@ -12,57 +14,82 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct DanmakuTask {
|
pub struct DanmakuTask {
|
||||||
pub xml_selected: bool,
|
pub xml_selected: bool,
|
||||||
pub ass_selected: bool,
|
pub ass_selected: bool,
|
||||||
pub json_selected: bool,
|
pub json_selected: bool,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub skipped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DanmakuTask {
|
impl DanmakuTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
self.skipped = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
|
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let danmaku_task = &progress.danmaku_task;
|
let danmaku_task = &progress.danmaku_task;
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
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 bili_client = download_task.app.get_bili_client();
|
||||||
let replies = bili_client
|
let replies = bili_client
|
||||||
.get_danmaku(progress.aid, progress.cid, progress.duration)
|
.get_danmaku(progress.aid, progress.cid, progress.duration)
|
||||||
.await
|
.await
|
||||||
.context("获取弹幕失败")?;
|
.wrap_err("获取弹幕失败")?;
|
||||||
|
|
||||||
let xml = replies
|
let xml = replies
|
||||||
.to_xml(progress.cid)
|
.to_xml(progress.cid)
|
||||||
.context("将弹幕转换为XML失败")?;
|
.wrap_err("将弹幕转换为XML失败")?;
|
||||||
|
|
||||||
if danmaku_task.xml_selected {
|
if danmaku_task.xml_selected {
|
||||||
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
|
|
||||||
std::fs::write(&xml_path, &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 {
|
if danmaku_task.ass_selected {
|
||||||
let config = download_task.app.get_config().read().danmaku_config.clone();
|
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)
|
let ass_file = File::create(&ass_path)
|
||||||
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
|
.wrap_err(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
|
||||||
let title = filename.to_string();
|
let title = filename.clone();
|
||||||
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
|
xml_to_ass(&xml, ass_file, title, config).wrap_err("将弹幕XML转换为ASS失败")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if danmaku_task.json_selected {
|
if danmaku_task.json_selected {
|
||||||
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
|
let json_string = serde_json::to_string(&replies).wrap_err("将弹幕转换为JSON失败")?;
|
||||||
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
|
|
||||||
std::fs::write(&json_path, json_string)
|
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);
|
download_task.update_progress(|p| p.danmaku_task.completed = true);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::WrapErr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::downloader::{
|
use crate::downloader::{
|
||||||
download_progress::DownloadProgress,
|
download_progress::DownloadProgress,
|
||||||
@@ -11,22 +12,28 @@ use crate::downloader::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct JsonTask {
|
pub struct JsonTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JsonTask {
|
impl JsonTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
episode_info: &mut Option<EpisodeInfo>,
|
episode_info: &mut Option<EpisodeInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
let episode_info = episode_info
|
let episode_info = episode_info
|
||||||
@@ -36,17 +43,17 @@ impl JsonTask {
|
|||||||
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
||||||
let json_string = match episode_info {
|
let json_string = match episode_info {
|
||||||
EpisodeInfo::Normal(info) => {
|
EpisodeInfo::Normal(info) => {
|
||||||
serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")?
|
serde_json::to_string(&info).wrap_err("将普通视频信息转换为JSON失败")?
|
||||||
}
|
}
|
||||||
EpisodeInfo::Bangumi(info, _ep_id) => {
|
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) => {
|
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)
|
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);
|
download_task.update_progress(|p| p.json_task.completed = true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||||
|
use eyre::{OptionExt, WrapErr, eyre};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
use yaserde::{YaDeserialize, YaSerialize};
|
use yaserde::{YaDeserialize, YaSerialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::FileExistAction,
|
||||||
downloader::{
|
downloader::{
|
||||||
download_progress::DownloadProgress,
|
download_progress::DownloadProgress,
|
||||||
download_task::DownloadTask,
|
download_task::DownloadTask,
|
||||||
@@ -19,121 +21,212 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct NfoTask {
|
pub struct NfoTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub skipped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NfoTask {
|
impl NfoTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
self.skipped = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
episode_info: &mut Option<EpisodeInfo>,
|
episode_info: &mut Option<EpisodeInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
|
||||||
|
|
||||||
let episode_info = episode_info
|
let episode_info = episode_info
|
||||||
.get_or_init(&download_task.app, progress)
|
.get_or_init(&download_task.app, progress)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let bili_client = download_task.app.get_bili_client();
|
|
||||||
|
|
||||||
match episode_info {
|
match episode_info {
|
||||||
EpisodeInfo::Normal(info) => {
|
EpisodeInfo::Normal(info) => {
|
||||||
let tags = bili_client
|
self.process_normal(download_task, progress, info).await?;
|
||||||
.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()
|
|
||||||
))?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EpisodeInfo::Bangumi(info, ep_id) => {
|
EpisodeInfo::Bangumi(info, ep_id) => {
|
||||||
let tvshow_nfo = info
|
self.process_bangumi(download_task, progress, info, ep_id)
|
||||||
.to_tvshow_nfo()
|
.await?;
|
||||||
.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()))?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EpisodeInfo::Cheese(info, ep_id) => {
|
EpisodeInfo::Cheese(info, ep_id) => {
|
||||||
let tvshow_nfo = info
|
self.process_cheese(download_task, progress, info, ep_id)
|
||||||
.to_tvshow_nfo()
|
.await?;
|
||||||
.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()))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
download_task.update_progress(|p| p.nfo_task.completed = true);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -208,7 +301,8 @@ struct EpisodeDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NormalInfo {
|
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![
|
let genre = vec![
|
||||||
"Bilibili视频".to_string(),
|
"Bilibili视频".to_string(),
|
||||||
self.tname.clone(),
|
self.tname.clone(),
|
||||||
@@ -223,7 +317,7 @@ impl NormalInfo {
|
|||||||
|
|
||||||
let ts = self.pubdate;
|
let ts = self.pubdate;
|
||||||
let date_time = DateTime::from_timestamp(ts, 0)
|
let date_time = DateTime::from_timestamp(ts, 0)
|
||||||
.context(format!("将视频发布时间戳转换为日期时间失败: {ts}"))?
|
.ok_or_eyre(format!("将视频发布时间戳转换为日期时间失败: {ts}"))?
|
||||||
.with_timezone(&chrono::Local);
|
.with_timezone(&chrono::Local);
|
||||||
|
|
||||||
let set = self.ugc_season.as_ref().map(|ugc_season| Set {
|
let set = self.ugc_season.as_ref().map(|ugc_season| Set {
|
||||||
@@ -266,16 +360,17 @@ impl NormalInfo {
|
|||||||
..Default::default()
|
..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)
|
Ok(nfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BangumiInfo {
|
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 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}"),
|
format!("将番剧发布时间字符串转换为日期时间失败: {time_str}"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -303,30 +398,31 @@ impl BangumiInfo {
|
|||||||
..Default::default()
|
..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)
|
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 (episode, episode_order) = self.get_episode_with_order(ep_id)?;
|
||||||
|
|
||||||
let ts = episode.pub_time;
|
let ts = episode.pub_time;
|
||||||
let date_time = DateTime::from_timestamp(ts, 0)
|
let date_time = DateTime::from_timestamp(ts, 0)
|
||||||
.context(format!("将番剧发布时间戳转换为日期时间失败: {ts}"))?
|
.ok_or_eyre(format!("将番剧发布时间戳转换为日期时间失败: {ts}"))?
|
||||||
.with_timezone(&chrono::Local);
|
.with_timezone(&chrono::Local);
|
||||||
|
|
||||||
let title = episode
|
let title = episode
|
||||||
.show_title
|
.show_title
|
||||||
.clone()
|
.clone()
|
||||||
.context("episode.show_title为None")?;
|
.ok_or_eyre("episode.show_title为None")?;
|
||||||
|
|
||||||
let plot = episode
|
let plot = episode
|
||||||
.share_copy
|
.share_copy
|
||||||
.clone()
|
.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 {
|
let episode_details = EpisodeDetails {
|
||||||
title,
|
title,
|
||||||
@@ -349,7 +445,7 @@ impl BangumiInfo {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let nfo =
|
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)
|
Ok(nfo)
|
||||||
}
|
}
|
||||||
@@ -398,11 +494,12 @@ impl BangumiInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CheeseInfo {
|
impl CheeseInfo {
|
||||||
pub fn to_tvshow_nfo(&self) -> anyhow::Result<String> {
|
#[instrument(level = "error", skip_all)]
|
||||||
let episode = self.episodes.first().context("episodes列表为空")?;
|
pub fn to_tvshow_nfo(&self) -> eyre::Result<String> {
|
||||||
|
let episode = self.episodes.first().ok_or_eyre("episodes列表为空")?;
|
||||||
let ts = episode.release_date;
|
let ts = episode.release_date;
|
||||||
let date_time = DateTime::from_timestamp(ts, 0)
|
let date_time = DateTime::from_timestamp(ts, 0)
|
||||||
.context(format!("将课程的发布时间戳转换为日期时间失败: {ts}"))?
|
.ok_or_eyre(format!("将课程的发布时间戳转换为日期时间失败: {ts}"))?
|
||||||
.with_timezone(&chrono::Local);
|
.with_timezone(&chrono::Local);
|
||||||
|
|
||||||
let status = match self.release_status.as_str() {
|
let status = match self.release_status.as_str() {
|
||||||
@@ -429,21 +526,22 @@ impl CheeseInfo {
|
|||||||
..Default::default()
|
..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)
|
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
|
let episode = self
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ep| ep.id == ep_id)
|
.find(|ep| ep.id == ep_id)
|
||||||
.context(format!("找不到ep_id为`{ep_id}`的课程"))?;
|
.ok_or_eyre("找不到ep_id对应的课程")?;
|
||||||
|
|
||||||
let ts = episode.release_date;
|
let ts = episode.release_date;
|
||||||
let date_time = DateTime::from_timestamp(ts, 0)
|
let date_time = DateTime::from_timestamp(ts, 0)
|
||||||
.context(format!("将课程发布时间戳转换为日期时间失败: {ts}"))?
|
.ok_or_eyre(format!("将课程发布时间戳转换为日期时间失败: {ts}"))?
|
||||||
.with_timezone(&chrono::Local);
|
.with_timezone(&chrono::Local);
|
||||||
|
|
||||||
let episode_details = EpisodeDetails {
|
let episode_details = EpisodeDetails {
|
||||||
@@ -467,7 +565,7 @@ impl CheeseInfo {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let nfo =
|
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)
|
Ok(nfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Context;
|
use eyre::WrapErr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||||
@@ -12,22 +13,28 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct SubtitleTask {
|
pub struct SubtitleTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubtitleTask {
|
impl SubtitleTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
@@ -43,7 +50,7 @@ impl SubtitleTask {
|
|||||||
let subtitle = bili_client
|
let subtitle = bili_client
|
||||||
.get_subtitle(&url)
|
.get_subtitle(&url)
|
||||||
.await
|
.await
|
||||||
.context("获取字幕失败")?;
|
.wrap_err("获取字幕失败")?;
|
||||||
|
|
||||||
let mut srt_content = String::new();
|
let mut srt_content = String::new();
|
||||||
for (i, b) in subtitle.body.iter().enumerate() {
|
for (i, b) in subtitle.body.iter().enumerate() {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use eyre::{WrapErr, eyre};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::{
|
downloader::{
|
||||||
@@ -17,55 +18,63 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct VideoProcessTask {
|
pub struct VideoProcessTask {
|
||||||
pub merge_selected: bool,
|
pub merge_selected: bool,
|
||||||
pub embed_chapter_selected: bool,
|
pub embed_chapter_selected: bool,
|
||||||
pub embed_skip_selected: bool,
|
pub embed_skip_selected: bool,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub skipped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoProcessTask {
|
impl VideoProcessTask {
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
self.skipped = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected
|
!self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected
|
||||||
|| self.completed
|
|| self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let embed_selected = self.embed_chapter_selected || self.embed_skip_selected;
|
let embed_selected = self.embed_chapter_selected || self.embed_skip_selected;
|
||||||
|
|
||||||
if self.merge_selected && embed_selected {
|
if self.merge_selected && embed_selected {
|
||||||
self.merge_and_embed(download_task, progress, player_info)
|
self.merge_and_embed(download_task, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("自动合并+嵌入章节元数据失败")?;
|
.wrap_err("自动合并+嵌入章节元数据失败")?;
|
||||||
} else if self.merge_selected {
|
} else if self.merge_selected {
|
||||||
println!("merge1");
|
|
||||||
self.merge(download_task, progress)
|
self.merge(download_task, progress)
|
||||||
.await
|
.await
|
||||||
.context("自动合并失败")?;
|
.wrap_err("自动合并失败")?;
|
||||||
} else if embed_selected {
|
} else if embed_selected {
|
||||||
self.embed(download_task, progress, player_info)
|
self.embed(download_task, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("嵌入章节元数据失败")?;
|
.wrap_err("嵌入章节元数据失败")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn merge_and_embed(
|
async fn merge_and_embed(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
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"));
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
if !video_path.exists() {
|
if !video_path.exists() {
|
||||||
@@ -78,14 +87,14 @@ impl VideoProcessTask {
|
|||||||
// 如果音频文件不存在,则只嵌入章节元数据
|
// 如果音频文件不存在,则只嵌入章节元数据
|
||||||
self.embed(download_task, progress, player_info)
|
self.embed(download_task, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("嵌入章节元数据失败")?;
|
.wrap_err("嵌入章节元数据失败")?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata_path = self
|
let metadata_path = self
|
||||||
.create_chapter_metadata(&download_task.app, progress, player_info)
|
.create_chapter_metadata(&download_task.app, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("创建章节元数据失败")?;
|
.wrap_err("创建章节元数据失败")?;
|
||||||
|
|
||||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||||
|
|
||||||
@@ -95,7 +104,10 @@ impl VideoProcessTask {
|
|||||||
let metadata_path_clone = metadata_path.clone();
|
let metadata_path_clone = metadata_path.clone();
|
||||||
let output_path_clone = output_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);
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
command.arg("-i").arg(video_path_clone);
|
command.arg("-i").arg(video_path_clone);
|
||||||
@@ -128,24 +140,24 @@ impl VideoProcessTask {
|
|||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
let err = eyre!(format!("STDOUT: {stdout}"))
|
||||||
.context(format!("STDERR: {stderr}"))
|
.wrap_err(format!("STDERR: {stderr}"))
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
std::fs::remove_file(&video_path)
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
std::fs::remove_file(&audio_path)
|
std::fs::remove_file(&audio_path)
|
||||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
.wrap_err(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
std::fs::rename(&output_path, &video_path).wrap_err(format!(
|
||||||
"将`{}`重命名为`{}`失败",
|
"将`{}`重命名为`{}`失败",
|
||||||
output_path.display(),
|
output_path.display(),
|
||||||
video_path.display()
|
video_path.display()
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
if let Some(metadata_path) = metadata_path {
|
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()
|
metadata_path.display()
|
||||||
))?;
|
))?;
|
||||||
@@ -156,11 +168,12 @@ impl VideoProcessTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn merge(
|
async fn merge(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
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 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 (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
let video_path_clone = video_path.clone();
|
let video_path_clone = video_path.clone();
|
||||||
let audio_path_clone = audio_path.clone();
|
let audio_path_clone = audio_path.clone();
|
||||||
let output_path_clone = output_path.clone();
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
let current_span = tracing::Span::current();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let _enter = current_span.enter();
|
||||||
|
|
||||||
let mut command = std::process::Command::new(ffmpeg_program);
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
command.arg("-i").arg(video_path_clone);
|
command.arg("-i").arg(video_path_clone);
|
||||||
@@ -213,17 +229,17 @@ impl VideoProcessTask {
|
|||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
let err = eyre!(format!("STDOUT: {stdout}"))
|
||||||
.context(format!("STDERR: {stderr}"))
|
.wrap_err(format!("STDERR: {stderr}"))
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
std::fs::remove_file(&video_path)
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
std::fs::remove_file(&audio_path)
|
std::fs::remove_file(&audio_path)
|
||||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
.wrap_err(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
std::fs::rename(&output_path, &video_path).wrap_err(format!(
|
||||||
"将`{}`重命名为`{}`失败",
|
"将`{}`重命名为`{}`失败",
|
||||||
output_path.display(),
|
output_path.display(),
|
||||||
video_path.display()
|
video_path.display()
|
||||||
@@ -234,15 +250,16 @@ impl VideoProcessTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn embed(
|
async fn embed(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
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"));
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
if !video_path.exists() {
|
if !video_path.exists() {
|
||||||
@@ -255,7 +272,7 @@ impl VideoProcessTask {
|
|||||||
let metadata_path = self
|
let metadata_path = self
|
||||||
.create_chapter_metadata(&download_task.app, progress, player_info)
|
.create_chapter_metadata(&download_task.app, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("创建章节元数据失败")?;
|
.wrap_err("创建章节元数据失败")?;
|
||||||
|
|
||||||
let Some(metadata_path) = metadata_path else {
|
let Some(metadata_path) = metadata_path else {
|
||||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
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 metadata_path_clone = metadata_path.clone();
|
||||||
let output_path_clone = output_path.clone();
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
let current_span = tracing::Span::current();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let _enter = current_span.enter();
|
||||||
|
|
||||||
let mut command = std::process::Command::new(ffmpeg_program);
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
command.arg("-i").arg(video_path_clone);
|
command.arg("-i").arg(video_path_clone);
|
||||||
@@ -295,20 +315,20 @@ impl VideoProcessTask {
|
|||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
let err = eyre!(format!("STDOUT: {stdout}"))
|
||||||
.context(format!("STDERR: {stderr}"))
|
.wrap_err(format!("STDERR: {stderr}"))
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
.wrap_err("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
std::fs::remove_file(&video_path)
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
.wrap_err(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
std::fs::rename(&output_path, &video_path).wrap_err(format!(
|
||||||
"将`{}`重命名为`{}`失败",
|
"将`{}`重命名为`{}`失败",
|
||||||
output_path.display(),
|
output_path.display(),
|
||||||
video_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()
|
metadata_path.display()
|
||||||
))?;
|
))?;
|
||||||
@@ -318,12 +338,13 @@ impl VideoProcessTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn create_chapter_metadata(
|
async fn create_chapter_metadata(
|
||||||
&self,
|
&self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<Option<PathBuf>> {
|
) -> eyre::Result<Option<PathBuf>> {
|
||||||
let mut chapter_segments = ChapterSegments {
|
let mut chapter_segments = ChapterSegments {
|
||||||
segments: Vec::new(),
|
segments: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -362,7 +383,7 @@ impl VideoProcessTask {
|
|||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||||
std::fs::write(&metadata_path, metadata_content)
|
std::fs::write(&metadata_path, metadata_content)
|
||||||
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
.wrap_err(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||||
|
|
||||||
Ok(Some(metadata_path))
|
Ok(Some(metadata_path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use eyre::{OptionExt, WrapErr, eyre};
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
use tracing::{Instrument, instrument};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::FileExistAction,
|
||||||
downloader::{
|
downloader::{
|
||||||
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||||
download_task::DownloadTask, media_chunk::MediaChunk,
|
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||||
},
|
},
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
extensions::{AppHandleExt, EyreReportToMessage},
|
||||||
types::{
|
types::{
|
||||||
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
|
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
|
||||||
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
|
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
|
||||||
@@ -28,6 +30,7 @@ use crate::{
|
|||||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct VideoTask {
|
pub struct VideoTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -36,14 +39,16 @@ pub struct VideoTask {
|
|||||||
pub content_length: u64,
|
pub content_length: u64,
|
||||||
pub chunks: Vec<MediaChunk>,
|
pub chunks: Vec<MediaChunk>,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub skipped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoTask {
|
impl VideoTask {
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn prepare_normal(
|
pub async fn prepare_normal(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
media_url: &NormalMediaUrl,
|
media_url: &NormalMediaUrl,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
|
|
||||||
for media in &media_url.dash.video {
|
for media in &media_url.dash.video {
|
||||||
@@ -55,7 +60,7 @@ impl VideoTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
MediaForPrepare {
|
MediaForPrepare {
|
||||||
@@ -63,27 +68,56 @@ impl VideoTask {
|
|||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
codecid,
|
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();
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn prepare_bangumi(
|
pub async fn prepare_bangumi(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
media_url: &BangumiMediaUrl,
|
media_url: &BangumiMediaUrl,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
@@ -98,7 +132,7 @@ impl VideoTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -107,7 +141,9 @@ impl VideoTask {
|
|||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
codecid,
|
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.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -130,26 +166,33 @@ impl VideoTask {
|
|||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
codecid,
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn prepare_cheese(
|
pub async fn prepare_cheese(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
media_url: &CheeseMediaUrl,
|
media_url: &CheeseMediaUrl,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
@@ -164,7 +207,7 @@ impl VideoTask {
|
|||||||
urls.extend_from_slice(&media.backup_url);
|
urls.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.base_url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -173,7 +216,9 @@ impl VideoTask {
|
|||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
codecid,
|
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.extend_from_slice(&media.backup_url);
|
||||||
urls.push(media.url.clone());
|
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 bili_client = app.get_bili_client();
|
||||||
let url_with_content_length =
|
let url_with_content_length =
|
||||||
bili_client.get_url_with_content_length(urls).await;
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
@@ -196,59 +241,52 @@ impl VideoTask {
|
|||||||
url_with_content_length,
|
url_with_content_length,
|
||||||
codecid,
|
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() {
|
if !media.url_with_content_length.is_empty() {
|
||||||
medias.push(media);
|
medias.push(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.prepare(app, medias)?;
|
self.prepare(app, &medias)?;
|
||||||
|
|
||||||
Ok(())
|
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() {
|
if medias.is_empty() {
|
||||||
return Err(anyhow!("获取音频地址失败"));
|
return Err(eyre!("获取视频地址失败,medias为空"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (video_quality_priority, codec_type_priority) = {
|
let video_quality_is_unknown = self.video_quality == VideoQuality::Unknown;
|
||||||
let config = app.get_config().inner().read();
|
let codec_type_is_unknown = self.codec_type == CodecType::Unknown;
|
||||||
(
|
|
||||||
config.video_quality_priority.clone(),
|
if video_quality_is_unknown != codec_type_is_unknown {
|
||||||
config.codec_type_priority.clone(),
|
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
|
let media = selected_media.ok_or_eyre("获取视频地址失败,medias为空")?;
|
||||||
.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];
|
|
||||||
|
|
||||||
self.video_quality = media.id.into();
|
self.video_quality = media.id.into();
|
||||||
self.codec_type = media.codecid.into();
|
self.codec_type = media.codecid.into();
|
||||||
@@ -289,40 +327,51 @@ impl VideoTask {
|
|||||||
self.chunks.iter_mut().for_each(|chunk| {
|
self.chunks.iter_mut().for_each(|chunk| {
|
||||||
chunk.completed = false;
|
chunk.completed = false;
|
||||||
});
|
});
|
||||||
|
self.skipped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!self.selected || self.completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
&self,
|
&self,
|
||||||
download_task: &Arc<DownloadTask>,
|
download_task: &Arc<DownloadTask>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
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!(
|
let temp_file_path = episode_dir.join(format!(
|
||||||
"{filename}.mp4.com.lanyeeee.bilibili-video-downloader"
|
"{filename}.mp4.com.lanyeeee.bilibili-video-downloader"
|
||||||
));
|
));
|
||||||
|
|
||||||
let (video_task, episode_title, ids_string) = {
|
let should_reuse_temp_file = temp_file_path
|
||||||
let progress = download_task.progress.read();
|
.metadata()
|
||||||
(
|
.map(|m| m.len() == video_task.content_length)
|
||||||
progress.video_task.clone(),
|
.unwrap_or(false);
|
||||||
progress.episode_title.clone(),
|
|
||||||
progress.get_ids_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let file = if temp_file_path.exists() {
|
let file = if should_reuse_temp_file {
|
||||||
// 如果临时文件已存在,则打开它
|
// 如果临时文件可以重用,则直接打开它
|
||||||
OpenOptions::new()
|
OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&temp_file_path)?
|
.open(&temp_file_path)?
|
||||||
} else {
|
} else {
|
||||||
// 如果临时文件不存在,创建它并预分配空间
|
// 如果临时文件不能重用,则创建个新的
|
||||||
let file = File::create(&temp_file_path)?;
|
let file = File::create(&temp_file_path)?;
|
||||||
file.allocate(video_task.content_length)?;
|
file.allocate(video_task.content_length)?;
|
||||||
file
|
file
|
||||||
@@ -332,7 +381,7 @@ impl VideoTask {
|
|||||||
let chunk_count = video_task.chunks.len();
|
let chunk_count = video_task.chunks.len();
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
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 {
|
if chunk.completed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -343,27 +392,31 @@ impl VideoTask {
|
|||||||
download_task: download_task.clone(),
|
download_task: download_task.clone(),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
url: video_task.url.to_string(),
|
url: video_task.url.clone(),
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
chunk_index: i,
|
chunk_index,
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunk_order = i + 1;
|
let chunk_order = chunk_index + 1;
|
||||||
|
let chunk_task = async move {
|
||||||
join_set.spawn(async move {
|
download_chunk_task.process().await.wrap_err(format!(
|
||||||
download_chunk_task.process().await.context(format!(
|
|
||||||
"分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})"
|
"分片`{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 {
|
match download_video_result {
|
||||||
Ok(i) => download_task.update_progress(|p| p.video_task.chunks[i].completed = true),
|
Ok(i) => download_task.update_progress(|p| p.video_task.chunks[i].completed = true),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title = format!("{ids_string} `{episode_title}`视频的一个分片下载失败");
|
let err_title = "视频的一个分片下载失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,32 +429,31 @@ impl VideoTask {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|chunk| chunk.completed);
|
.all(|chunk| chunk.completed);
|
||||||
if !download_completed {
|
if !download_completed {
|
||||||
return Err(anyhow!(
|
return Err(eyre!(
|
||||||
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||||
temp_file_path.display()
|
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()
|
temp_file_path.display()
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
if !is_video_file_complete {
|
if !is_video_file_complete {
|
||||||
download_task.update_progress(|p| p.video_task.mark_uncompleted());
|
download_task.update_progress(|p| p.video_task.mark_uncompleted());
|
||||||
return Err(anyhow!(
|
return Err(eyre!(
|
||||||
"视频文件`{}`不完整,[继续]会重新下载所有分片",
|
"视频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||||
temp_file_path.display()
|
temp_file_path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重命名临时文件
|
// 重命名临时文件
|
||||||
let mp4_path = episode_dir.join(format!("{filename}.mp4"));
|
|
||||||
if mp4_path.exists() {
|
if mp4_path.exists() {
|
||||||
std::fs::remove_file(&mp4_path)
|
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(),
|
temp_file_path.display(),
|
||||||
mp4_path.display()
|
mp4_path.display()
|
||||||
@@ -413,8 +465,61 @@ impl VideoTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct MediaForPrepare {
|
struct MediaForPrepare {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub url_with_content_length: Vec<(String, u64)>,
|
pub url_with_content_length: Vec<(String, u64)>,
|
||||||
pub codecid: i64,
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
use crate::extensions::AnyhowErrorToStringChain;
|
use tracing_error::SpanTrace;
|
||||||
|
|
||||||
pub type CommandResult<T> = Result<T, CommandError>;
|
pub type CommandResult<T> = Result<T, CommandError>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct CommandError {
|
pub struct CommandError {
|
||||||
pub err_title: String,
|
pub err_title: String,
|
||||||
pub err_message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandError {
|
impl CommandError {
|
||||||
pub fn from<E>(err_title: &str, err: E) -> Self
|
pub fn from<E>(err_title: &str, err: E) -> Self
|
||||||
where
|
where
|
||||||
E: Into<anyhow::Error>,
|
E: Into<eyre::Report>,
|
||||||
{
|
{
|
||||||
let string_chain = err.into().to_string_chain();
|
let message = format!("{:?}", err.into());
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
Self {
|
Self {
|
||||||
err_title: err_title.to_string(),
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
|
|
||||||
use crate::{
|
use crate::downloader::{
|
||||||
downloader::{download_progress::DownloadProgress, download_task_state::DownloadTaskState},
|
download_progress::DownloadProgress, download_task_state::DownloadTaskState,
|
||||||
types::log_level::LogLevel,
|
|
||||||
};
|
};
|
||||||
|
use crate::types::plugin_info::PluginInfo;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LogEvent {
|
pub struct LogEvent {
|
||||||
pub timestamp: String,
|
pub json_raw: 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
@@ -54,3 +47,11 @@ pub enum DownloadEvent {
|
|||||||
progress: DownloadProgress,
|
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 parking_lot::RwLock;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bili_client::BiliClient,
|
bili_client::BiliClient,
|
||||||
config::Config,
|
config::Config,
|
||||||
downloader::{download_manager::DownloadManager, download_progress::DownloadProgress},
|
downloader::{download_manager::DownloadManager, download_progress::DownloadProgress},
|
||||||
|
plugin::plugin_manager::PluginManager,
|
||||||
types::player_info::PlayerInfo,
|
types::player_info::PlayerInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub trait AnyhowErrorToStringChain {
|
pub trait EyreReportToMessage {
|
||||||
/// 将 `anyhow::Error` 转换为chain格式
|
fn to_message(&self) -> String;
|
||||||
/// # Example
|
|
||||||
/// 0: error message\
|
|
||||||
/// 1: error message\
|
|
||||||
/// 2: error message
|
|
||||||
fn to_string_chain(&self) -> String;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnyhowErrorToStringChain for anyhow::Error {
|
impl EyreReportToMessage for eyre::Report {
|
||||||
fn to_string_chain(&self) -> String {
|
fn to_message(&self) -> String {
|
||||||
use std::fmt::Write;
|
format!("{self:?}")
|
||||||
self.chain()
|
|
||||||
.enumerate()
|
|
||||||
.fold(String::new(), |mut output, (i, e)| {
|
|
||||||
let _ = writeln!(output, "{i}: {e}");
|
|
||||||
output
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait AppHandleExt {
|
pub trait AppHandleExt {
|
||||||
fn get_config(&self) -> State<RwLock<Config>>;
|
fn get_config(&self) -> State<'_, RwLock<Config>>;
|
||||||
fn get_bili_client(&self) -> State<BiliClient>;
|
fn get_bili_client(&self) -> State<'_, BiliClient>;
|
||||||
fn get_download_manager(&self) -> State<DownloadManager>;
|
fn get_download_manager(&self) -> State<'_, DownloadManager>;
|
||||||
|
fn get_plugin_manager(&self) -> State<'_, PluginManager>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppHandleExt for tauri::AppHandle {
|
impl AppHandleExt for AppHandle {
|
||||||
fn get_config(&self) -> State<RwLock<Config>> {
|
fn get_config(&self) -> State<'_, RwLock<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>()
|
self.state::<BiliClient>()
|
||||||
}
|
}
|
||||||
fn get_download_manager(&self) -> State<DownloadManager> {
|
fn get_download_manager(&self) -> State<'_, DownloadManager> {
|
||||||
self.state::<DownloadManager>()
|
self.state::<DownloadManager>()
|
||||||
}
|
}
|
||||||
|
fn get_plugin_manager(&self) -> State<'_, PluginManager> {
|
||||||
|
self.state::<PluginManager>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait GetOrInitPlayerInfo {
|
pub trait GetOrInitPlayerInfo {
|
||||||
@@ -53,15 +48,16 @@ pub trait GetOrInitPlayerInfo {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<&'a mut PlayerInfo>;
|
) -> eyre::Result<&'a mut PlayerInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetOrInitPlayerInfo for Option<PlayerInfo> {
|
impl GetOrInitPlayerInfo for Option<PlayerInfo> {
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn get_or_init<'a>(
|
async fn get_or_init<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
) -> anyhow::Result<&'a mut PlayerInfo> {
|
) -> eyre::Result<&'a mut PlayerInfo> {
|
||||||
if let Some(info) = self {
|
if let Some(info) = self {
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
@@ -70,7 +66,7 @@ impl GetOrInitPlayerInfo for Option<PlayerInfo> {
|
|||||||
let info = bili_client
|
let info = bili_client
|
||||||
.get_player_info(progress.aid, progress.cid)
|
.get_player_info(progress.aid, progress.cid)
|
||||||
.await
|
.await
|
||||||
.context("获取播放器信息失败")?;
|
.wrap_err("获取播放器信息失败")?;
|
||||||
|
|
||||||
Ok(self.insert(info))
|
Ok(self.insert(info))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,35 +7,52 @@ mod errors;
|
|||||||
mod events;
|
mod events;
|
||||||
mod extensions;
|
mod extensions;
|
||||||
mod logger;
|
mod logger;
|
||||||
|
mod plugin;
|
||||||
mod types;
|
mod types;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod wbi;
|
mod wbi;
|
||||||
|
#[allow(warnings)]
|
||||||
mod protobuf {
|
mod protobuf {
|
||||||
include!("./bilibili.community.service.dm.v1.rs");
|
include!("./bilibili.community.service.dm.v1.rs");
|
||||||
}
|
}
|
||||||
|
|
||||||
use anyhow::Context;
|
use commands::{
|
||||||
use commands::*;
|
add_plugin, create_download_tasks, delete_download_tasks, generate_qrcode,
|
||||||
use config::Config;
|
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 parking_lot::RwLock;
|
||||||
use tauri::{Manager, Wry};
|
use tauri::{Manager, Wry};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bili_client::BiliClient,
|
bili_client::BiliClient,
|
||||||
|
commands::open_log_file,
|
||||||
|
config::Config,
|
||||||
downloader::download_manager::DownloadManager,
|
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> {
|
fn generate_context() -> tauri::Context<Wry> {
|
||||||
tauri::generate_context!()
|
tauri::generate_context!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::missing_panics_doc)]
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
install_custom_eyre_handler().unwrap();
|
||||||
|
|
||||||
let builder = tauri_specta::Builder::<Wry>::new()
|
let builder = tauri_specta::Builder::<Wry>::new()
|
||||||
.commands(tauri_specta::collect_commands![
|
.commands(tauri_specta::collect_commands![
|
||||||
get_config,
|
get_config,
|
||||||
save_config,
|
save_config,
|
||||||
|
get_plugin_infos,
|
||||||
generate_qrcode,
|
generate_qrcode,
|
||||||
get_qrcode_status,
|
get_qrcode_status,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
@@ -52,13 +69,24 @@ pub fn run() {
|
|||||||
resume_download_tasks,
|
resume_download_tasks,
|
||||||
delete_download_tasks,
|
delete_download_tasks,
|
||||||
restart_download_tasks,
|
restart_download_tasks,
|
||||||
|
restart_download_task,
|
||||||
restore_download_tasks,
|
restore_download_tasks,
|
||||||
search,
|
search,
|
||||||
get_logs_dir_size,
|
get_logs_dir_size,
|
||||||
show_path_in_file_manager,
|
show_path_in_file_manager,
|
||||||
get_skip_segments,
|
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)]
|
#[cfg(debug_assertions)]
|
||||||
builder
|
builder
|
||||||
@@ -73,7 +101,9 @@ pub fn run() {
|
|||||||
|
|
||||||
// 解决Ubuntu24.04窗口全白的问题
|
// 解决Ubuntu24.04窗口全白的问题
|
||||||
#[cfg(target_os = "linux")]
|
#[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()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -86,9 +116,9 @@ pub fn run() {
|
|||||||
let app_data_dir = app
|
let app_data_dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.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目录`{:?}`失败",
|
||||||
app_data_dir.display()
|
app_data_dir.display()
|
||||||
))?;
|
))?;
|
||||||
@@ -104,6 +134,9 @@ pub fn run() {
|
|||||||
|
|
||||||
logger::init(app.handle())?;
|
logger::init(app.handle())?;
|
||||||
|
|
||||||
|
let plugin_manager = PluginManager::new(app.handle())?;
|
||||||
|
app.manage(plugin_manager);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(generate_context())
|
.run(generate_context())
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
use std::{io::Write, sync::OnceLock};
|
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 notify::{RecommendedWatcher, Watcher};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
use tracing::{Level, Subscriber};
|
use tracing::{Instrument, Level, Subscriber, instrument};
|
||||||
use tracing_appender::{
|
use tracing_appender::{
|
||||||
non_blocking::WorkerGuard,
|
non_blocking::WorkerGuard,
|
||||||
rolling::{RollingFileAppender, Rotation},
|
rolling::{RollingFileAppender, Rotation},
|
||||||
};
|
};
|
||||||
|
use tracing_error::ErrorLayer;
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
filter::{filter_fn, FilterExt, Targets},
|
Layer, Registry,
|
||||||
fmt::{layer, time::LocalTime},
|
filter::{FilterExt, Targets, filter_fn},
|
||||||
|
fmt::{MakeWriter, format::JsonFields, layer, time::LocalTime},
|
||||||
layer::SubscriberExt,
|
layer::SubscriberExt,
|
||||||
registry::LookupSpan,
|
registry::LookupSpan,
|
||||||
util::SubscriberInitExt,
|
util::SubscriberInitExt,
|
||||||
Layer, Registry,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
events::LogEvent,
|
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LogEventWriter {
|
struct LogEventWriter {
|
||||||
@@ -29,17 +29,8 @@ struct LogEventWriter {
|
|||||||
|
|
||||||
impl Write for LogEventWriter {
|
impl Write for LogEventWriter {
|
||||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
let log_string = String::from_utf8_lossy(buf);
|
let json_raw = String::from_utf8_lossy(buf).to_string();
|
||||||
match serde_json::from_str::<LogEvent>(&log_string) {
|
let _ = LogEvent { json_raw }.emit(&self.app);
|
||||||
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失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(buf.len())
|
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();
|
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_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}"
|
"解析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_writer(std::io::stdout)
|
||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_file(true)
|
.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()
|
let log_event_layer = layer()
|
||||||
.with_writer(log_event_writer)
|
.with_writer(log_event_factory)
|
||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true)
|
.with_line_number(true)
|
||||||
@@ -85,6 +92,7 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
|
|||||||
.with(reloadable_file_layer)
|
.with(reloadable_file_layer)
|
||||||
.with(console_layer)
|
.with(console_layer)
|
||||||
.with(log_event_layer)
|
.with(log_event_layer)
|
||||||
|
.with(ErrorLayer::new(JsonFields::default()))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
|
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
|
||||||
@@ -92,8 +100,8 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
Box::new(move || {
|
Box::new(move || {
|
||||||
let (file_layer, guard) = create_file_layer(&app)?;
|
let (file_layer, guard) = create_file_layer(&app)?;
|
||||||
reload_handle.reload(file_layer).context("reload失败")?;
|
reload_handle.reload(file_layer).wrap_err("reload失败")?;
|
||||||
*GUARD.get().context("GUARD未初始化")?.lock() = guard;
|
*GUARD.get().ok_or_eyre("GUARD未初始化")?.lock() = guard;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -102,20 +110,23 @@ pub fn init(app: &AppHandle) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reload_file_logger() -> anyhow::Result<()> {
|
#[instrument(level = "error", skip_all)]
|
||||||
RELOAD_FN.get().context("RELOAD_FN未初始化")?()
|
pub fn reload_file_logger() -> eyre::Result<()> {
|
||||||
|
RELOAD_FN.get().ok_or_eyre("RELOAD_FN未初始化")?()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disable_file_logger() -> anyhow::Result<()> {
|
#[instrument(level = "error", skip_all)]
|
||||||
if let Some(guard) = GUARD.get().context("GUARD未初始化")?.lock().take() {
|
pub fn disable_file_logger() -> eyre::Result<()> {
|
||||||
|
if let Some(guard) = GUARD.get().ok_or_eyre("GUARD未初始化")?.lock().take() {
|
||||||
drop(guard);
|
drop(guard);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
fn create_file_layer<S>(
|
fn create_file_layer<S>(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> anyhow::Result<(Box<dyn Layer<S> + Send + Sync>, Option<WorkerGuard>)>
|
) -> eyre::Result<(Box<dyn Layer<S> + Send + Sync>, Option<WorkerGuard>)>
|
||||||
where
|
where
|
||||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||||
{
|
{
|
||||||
@@ -127,47 +138,53 @@ where
|
|||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true);
|
.with_line_number(true)
|
||||||
|
.json();
|
||||||
return Ok((Box::new(sink_layer), None));
|
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()
|
let file_appender = RollingFileAppender::builder()
|
||||||
.filename_prefix("bilibili-video-downloader")
|
.filename_prefix("bilibili-video-downloader")
|
||||||
.filename_suffix("log")
|
.filename_suffix("log")
|
||||||
.rotation(Rotation::DAILY)
|
.rotation(Rotation::DAILY)
|
||||||
.build(&logs_dir)
|
.build(&logs_dir)
|
||||||
.context("创建RollingFileAppender失败")?;
|
.wrap_err("创建RollingFileAppender失败")?;
|
||||||
let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender);
|
let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
let file_layer = layer()
|
let file_layer = layer()
|
||||||
.with_writer(non_blocking_appender)
|
.with_writer(non_blocking_appender)
|
||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true);
|
.with_line_number(true)
|
||||||
|
.json();
|
||||||
Ok((Box::new(file_layer), Some(guard)))
|
Ok((Box::new(file_layer), Some(guard)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "error", skip_all)]
|
||||||
async fn file_log_watcher(app: AppHandle) {
|
async fn file_log_watcher(app: AppHandle) {
|
||||||
let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
|
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| {
|
let event_handler = move |res| {
|
||||||
tauri::async_runtime::block_on(async {
|
let send_event_task = async {
|
||||||
if let Err(err) = sender.send(res).await.map_err(anyhow::Error::from) {
|
if let Err(err) = sender.send(res).await.map_err(eyre::Report::from) {
|
||||||
let err_title = "发送日志文件watcher事件失败";
|
let err_title = "发送日志文件watcher事件失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
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())
|
let mut watcher = match RecommendedWatcher::new(event_handler, notify::Config::default())
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(eyre::Report::from)
|
||||||
{
|
{
|
||||||
Ok(watcher) => watcher,
|
Ok(watcher) => watcher,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title = "创建日志文件watcher失败";
|
let err_title = "创建日志文件watcher失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -176,46 +193,47 @@ async fn file_log_watcher(app: AppHandle) {
|
|||||||
Ok(logs_dir) => logs_dir,
|
Ok(logs_dir) => logs_dir,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title = "日志文件watcher获取日志目录失败";
|
let err_title = "日志文件watcher获取日志目录失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = watcher
|
if let Err(err) = watcher
|
||||||
.watch(&logs_dir, notify::RecursiveMode::NonRecursive)
|
.watch(&logs_dir, notify::RecursiveMode::NonRecursive)
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(eyre::Report::from)
|
||||||
{
|
{
|
||||||
let err_title = "日志文件watcher监听日志目录失败";
|
let err_title = "日志文件watcher监听日志目录失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
tracing::error!(err_title, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(res) = receiver.recv().await {
|
while let Some(res) = receiver.recv().await {
|
||||||
match res.map_err(anyhow::Error::from) {
|
match res.map_err(eyre::Report::from) {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if let notify::EventKind::Remove(_) = event.kind {
|
if let notify::EventKind::Remove(_) = event.kind
|
||||||
if let Err(err) = reload_file_logger() {
|
&& let Err(err) = reload_file_logger()
|
||||||
let err_title = "重置日志文件失败";
|
{
|
||||||
let string_chain = err.to_string_chain();
|
let err_title = "重置日志文件失败";
|
||||||
tracing::error!(err_title, message = string_chain);
|
let message = err.to_message();
|
||||||
}
|
tracing::error!(err_title, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_title = "接收日志文件watcher事件失败";
|
let err_title = "接收日志文件watcher事件失败";
|
||||||
let string_chain = err.to_string_chain();
|
let message = err.to_message();
|
||||||
tracing::error!(err_title, message = string_chain);
|
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
|
let app_data_dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.context("获取app_data_dir目录失败")?;
|
.wrap_err("获取app_data_dir目录失败")?;
|
||||||
Ok(app_data_dir.join("日志"))
|
Ok(app_data_dir.join("日志"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
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 audio_quality;
|
||||||
|
pub mod available_media_formats;
|
||||||
pub mod bangumi_follow_info;
|
pub mod bangumi_follow_info;
|
||||||
pub mod bangumi_info;
|
pub mod bangumi_info;
|
||||||
pub mod bangumi_media_url;
|
pub mod bangumi_media_url;
|
||||||
|
pub mod bangumi_media_url_v2;
|
||||||
pub mod cheese_info;
|
pub mod cheese_info;
|
||||||
pub mod cheese_media_url;
|
pub mod cheese_media_url;
|
||||||
pub mod codec_type;
|
pub mod codec_type;
|
||||||
pub mod create_download_task_params;
|
pub mod create_download_task_params;
|
||||||
pub mod fav_folders;
|
pub mod fav_folders;
|
||||||
pub mod fav_info;
|
pub mod fav_info;
|
||||||
|
pub mod get_available_media_formats_params;
|
||||||
pub mod get_bangumi_follow_info_params;
|
pub mod get_bangumi_follow_info_params;
|
||||||
pub mod get_bangumi_info_params;
|
pub mod get_bangumi_info_params;
|
||||||
pub mod get_cheese_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_normal_info_params;
|
||||||
pub mod get_user_video_info_params;
|
pub mod get_user_video_info_params;
|
||||||
pub mod history_info;
|
pub mod history_info;
|
||||||
pub mod log_level;
|
pub mod log_metadata;
|
||||||
pub mod normal_info;
|
pub mod normal_info;
|
||||||
pub mod normal_media_url;
|
pub mod normal_media_url;
|
||||||
pub mod player_info;
|
pub mod player_info;
|
||||||
|
pub mod plugin_info;
|
||||||
pub mod qrcode_data;
|
pub mod qrcode_data;
|
||||||
pub mod qrcode_status;
|
pub mod qrcode_status;
|
||||||
|
pub mod restart_download_task_params;
|
||||||
pub mod search_params;
|
pub mod search_params;
|
||||||
pub mod search_result;
|
pub mod search_result;
|
||||||
pub mod skip_segments;
|
pub mod skip_segments;
|
||||||
@@ -22,10 +22,13 @@ pub enum AudioQuality {
|
|||||||
Unknown = -1,
|
Unknown = -1,
|
||||||
|
|
||||||
#[serde(rename = "64K")]
|
#[serde(rename = "64K")]
|
||||||
|
#[num_enum(alternatives = [100008])]
|
||||||
Audio64K = 30216,
|
Audio64K = 30216,
|
||||||
#[serde(rename = "132K")]
|
#[serde(rename = "132K")]
|
||||||
|
#[num_enum(alternatives = [100009])]
|
||||||
Audio132K = 30232,
|
Audio132K = 30232,
|
||||||
#[serde(rename = "192K")]
|
#[serde(rename = "192K")]
|
||||||
|
#[num_enum(alternatives = [100010])]
|
||||||
Audio192K = 30280,
|
Audio192K = 30280,
|
||||||
#[serde(rename = "Dolby")]
|
#[serde(rename = "Dolby")]
|
||||||
AudioDolby = 30250,
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -55,7 +56,8 @@ pub struct BangumiInfo {
|
|||||||
|
|
||||||
impl BangumiInfo {
|
impl BangumiInfo {
|
||||||
#[allow(clippy::cast_possible_wrap)]
|
#[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
|
let episode_with_order = self
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -69,19 +71,19 @@ impl BangumiInfo {
|
|||||||
} else {
|
} else {
|
||||||
// 如果在正片中没有找到对应的ep_id,则在section中查找
|
// 如果在正片中没有找到对应的ep_id,则在section中查找
|
||||||
let Some(sections) = &self.section else {
|
let Some(sections) = &self.section else {
|
||||||
return Err(anyhow!("找不到对应的ep_id为`{ep_id}`的番剧"));
|
return Err(eyre!("section为None"));
|
||||||
};
|
};
|
||||||
let section_index = sections
|
let section_index = sections
|
||||||
.iter()
|
.iter()
|
||||||
.position(|s| s.episodes.iter().any(|e| e.id == ep_id))
|
.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]
|
sections[section_index]
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, e)| (e, i as i64 + 1))
|
.map(|(i, e)| (e, i as i64 + 1))
|
||||||
.find(|(e, _)| e.id == ep_id)
|
.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)
|
Ok(episode_with_order)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
audio_quality::AudioQuality,
|
||||||
|
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct BangumiMediaUrl {
|
pub struct BangumiMediaUrl {
|
||||||
@@ -130,3 +135,43 @@ pub struct DurlDetailInBangumi {
|
|||||||
pub order: i64,
|
pub order: i64,
|
||||||
pub md5: String,
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
audio_quality::AudioQuality,
|
||||||
|
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CheeseMediaUrl {
|
pub struct CheeseMediaUrl {
|
||||||
@@ -18,6 +23,7 @@ pub struct CheeseMediaUrl {
|
|||||||
pub seek_type: String,
|
pub seek_type: String,
|
||||||
pub from: String,
|
pub from: String,
|
||||||
pub video_codecid: i64,
|
pub video_codecid: i64,
|
||||||
|
pub is_drm: bool,
|
||||||
pub no_rexcode: i64,
|
pub no_rexcode: i64,
|
||||||
pub format: String,
|
pub format: String,
|
||||||
pub support_formats: Vec<SupportFormatInCheese>,
|
pub support_formats: Vec<SupportFormatInCheese>,
|
||||||
@@ -122,3 +128,43 @@ pub struct DurlDetailInCheese {
|
|||||||
pub order: i64,
|
pub order: i64,
|
||||||
pub md5: String,
|
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),
|
EpId(i64),
|
||||||
SeasonId(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),
|
EpId(i64),
|
||||||
SeasonId(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),
|
Bvid(String),
|
||||||
Aid(i64),
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
audio_quality::AudioQuality,
|
||||||
|
available_media_formats::{AvailableMediaFormats, VideoQualityAndCodecType},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct NormalMediaUrl {
|
pub struct NormalMediaUrl {
|
||||||
@@ -16,6 +21,7 @@ pub struct NormalMediaUrl {
|
|||||||
pub video_codecid: i64,
|
pub video_codecid: i64,
|
||||||
pub seek_param: String,
|
pub seek_param: String,
|
||||||
pub seek_type: String,
|
pub seek_type: String,
|
||||||
|
pub durl: Vec<DurlInNormal>,
|
||||||
pub dash: DashInNormal,
|
pub dash: DashInNormal,
|
||||||
pub support_formats: Vec<SupportFormatInNormal>,
|
pub support_formats: Vec<SupportFormatInNormal>,
|
||||||
pub last_play_time: i64,
|
pub last_play_time: i64,
|
||||||
@@ -34,6 +40,18 @@ pub struct DashInNormal {
|
|||||||
pub flac: Option<Flac>,
|
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)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Flac {
|
pub struct Flac {
|
||||||
@@ -82,7 +100,7 @@ pub struct SupportFormatInNormal {
|
|||||||
pub new_description: String,
|
pub new_description: String,
|
||||||
pub display_desc: String,
|
pub display_desc: String,
|
||||||
pub superscript: String,
|
pub superscript: String,
|
||||||
pub codecs: Vec<String>,
|
pub codecs: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
@@ -90,3 +108,50 @@ pub struct SupportFormatInNormal {
|
|||||||
pub struct PlayConf {
|
pub struct PlayConf {
|
||||||
pub is_new_description: bool,
|
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,
|
user_video_info::UserVideoInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub enum SearchResult {
|
pub enum SearchResult {
|
||||||
Normal(NormalSearchResult),
|
Normal(NormalSearchResult),
|
||||||
|
|||||||
@@ -136,10 +136,6 @@ pub struct LabelInUserInfo {
|
|||||||
pub img_label_uri_hant_static: String,
|
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)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Wallet {
|
pub struct Wallet {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use byteorder::{BigEndian, ReadBytesExt};
|
use byteorder::{BigEndian, ReadBytesExt};
|
||||||
|
use eyre::{OptionExt, WrapErr, eyre};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
danmaku_xml_to_ass::{DamakuXmlDTag, DanmakuXmlITag},
|
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> {
|
#[instrument(level = "error", skip_all, fields(file_path = ?file_path))]
|
||||||
let file = File::open(file_path).context(format!("打开文件`{}`失败", file_path.display()))?;
|
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
|
let real_size = file
|
||||||
.metadata()
|
.metadata()
|
||||||
.context(format!("获取文件`{}`元数据失败", file_path.display()))?
|
.wrap_err(format!("获取文件`{}`元数据失败", file_path.display()))?
|
||||||
.len();
|
.len();
|
||||||
let mut reader = BufReader::new(file);
|
let mut reader = BufReader::new(file);
|
||||||
let mut total_size: u64 = 0;
|
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>() {
|
let box_size_field: BoxSizeField = match reader.read_u32::<BigEndian>() {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // 正常结束
|
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // 正常结束
|
||||||
Err(e) => return Err(anyhow!(e)),
|
Err(e) => return Err(eyre!(e)),
|
||||||
};
|
};
|
||||||
// 读取Box类型字段
|
// 读取Box类型字段
|
||||||
let mut box_type_bytes = [0u8; 4];
|
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 {
|
if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
return Err(anyhow!(e));
|
return Err(eyre!(e));
|
||||||
}
|
}
|
||||||
// 如果是第一个Box,检查是否是 'ftyp' Box
|
// 如果是第一个Box,检查是否是 'ftyp' Box
|
||||||
if is_first_box {
|
if is_first_box {
|
||||||
@@ -131,11 +133,12 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait ToXml {
|
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> {
|
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
|
let elems = self
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|reply| &reply.elems)
|
.flat_map(|reply| &reply.elems)
|
||||||
@@ -157,7 +160,7 @@ impl ToXml for Vec<DmSegMobileReply> {
|
|||||||
|
|
||||||
let i_tag = DanmakuXmlITag { chatid: cid, elems };
|
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)
|
Ok(xml)
|
||||||
}
|
}
|
||||||
@@ -177,11 +180,12 @@ pub fn seconds_to_srt_time(seconds: f64) -> String {
|
|||||||
format!("{h:02}:{m:02}:{s:02},{ms:03}")
|
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()
|
let ffmpeg_program = std::env::current_exe()
|
||||||
.context("获取当前可执行文件路径失败")?
|
.wrap_err("获取当前可执行文件路径失败")?
|
||||||
.parent()
|
.parent()
|
||||||
.context("获取当前可执行文件所在目录失败")?
|
.ok_or_eyre("获取当前可执行文件所在目录失败")?
|
||||||
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
||||||
|
|
||||||
Ok(ffmpeg_program)
|
Ok(ffmpeg_program)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use eyre::{OptionExt, WrapErr, eyre};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::bili_client::{BiliClient, BiliResp};
|
use crate::bili_client::{BiliClient, BiliResp};
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ struct WeiRespData {
|
|||||||
|
|
||||||
impl BiliClient {
|
impl BiliClient {
|
||||||
// 为请求参数进行 wbi 签名
|
// 为请求参数进行 wbi 签名
|
||||||
pub(crate) async fn wbi(&self, params: &mut Vec<(&str, String)>) -> anyhow::Result<()> {
|
#[instrument(level = "error", skip_all)]
|
||||||
let (img_key, sub_key) = self.get_wbi_keys().await.context("获取wbi keys失败")?;
|
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 mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
|
||||||
|
|
||||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||||
@@ -46,7 +48,8 @@ impl BiliClient {
|
|||||||
Ok(())
|
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
|
let request = self
|
||||||
.api_client
|
.api_client
|
||||||
.read()
|
.read()
|
||||||
@@ -58,27 +61,27 @@ impl BiliClient {
|
|||||||
let status = http_resp.status();
|
let status = http_resp.status();
|
||||||
let body = http_resp.text().await?;
|
let body = http_resp.text().await?;
|
||||||
if status != reqwest::StatusCode::OK {
|
if status != reqwest::StatusCode::OK {
|
||||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
return Err(eyre!("预料之外的状态码({status}): {body}"));
|
||||||
}
|
}
|
||||||
// 尝试将body解析为BiliResp
|
// 尝试将body解析为BiliResp
|
||||||
let bili_resp: 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是否存在
|
// 检查BiliResp的data是否存在
|
||||||
let Some(data) = bili_resp.data else {
|
let Some(data) = bili_resp.data else {
|
||||||
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
|
return Err(eyre!("BiliResp中不存在data字段: {bili_resp:?}"));
|
||||||
};
|
};
|
||||||
// 尝试将data解析为Data
|
// 尝试将data解析为Data
|
||||||
let data_str = data.to_string();
|
let data_str = data.to_string();
|
||||||
let wei_resp_data: WeiRespData =
|
let wei_resp_data: WeiRespData = serde_json::from_str(&data_str)
|
||||||
serde_json::from_str(&data_str).context(format!("将data解析为Data失败: {data_str}"))?;
|
.wrap_err(format!("将data解析为Data失败: {data_str}"))?;
|
||||||
|
|
||||||
let img_url = wei_resp_data.wbi_img.img_url;
|
let img_url = wei_resp_data.wbi_img.img_url;
|
||||||
let sub_url = wei_resp_data.wbi_img.sub_url;
|
let sub_url = wei_resp_data.wbi_img.sub_url;
|
||||||
|
|
||||||
let img_filename =
|
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 =
|
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))
|
Ok((img_filename, sub_filename))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "bilibili-video-downloader",
|
"productName": "bilibili-video-downloader",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "com.lanyeeee.bilibili-video-downloader",
|
"identifier": "com.lanyeeee.bilibili-video-downloader",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -16,7 +16,13 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": [
|
||||||
|
"nsis",
|
||||||
|
"app",
|
||||||
|
"dmg",
|
||||||
|
"deb",
|
||||||
|
"rpm"
|
||||||
|
],
|
||||||
"licenseFile": "../LICENSE",
|
"licenseFile": "../LICENSE",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
11
src/App.vue
11
src/App.vue
@@ -1,6 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppContent from './AppContent.vue'
|
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 = {
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { onMounted, ref, provide } from 'vue'
|
import { onMounted, ref, provide, useTemplateRef } from 'vue'
|
||||||
import { useStore } from './store.ts'
|
import { useStore } from './store.ts'
|
||||||
import LogDialog from './dialogs/LogDialog.vue'
|
import LogDialog from './dialogs/LogDialog.vue'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,7 @@ import DownloadPane from './panes/DownloadPane/DownloadPane.vue'
|
|||||||
import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts'
|
import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts'
|
||||||
import BangumiFollowPane from './panes/BangumiFollow/BangumiFollowPane.vue'
|
import BangumiFollowPane from './panes/BangumiFollow/BangumiFollowPane.vue'
|
||||||
import HistoryPane from './panes/HistoryPane/HistoryPane.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'
|
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 aboutDialogShowing = ref<boolean>(false)
|
||||||
const settingsDialogShowing = ref<boolean>(false)
|
const settingsDialogShowing = ref<boolean>(false)
|
||||||
|
|
||||||
const searchPaneRef = ref<InstanceType<typeof SearchPane>>()
|
const searchPaneRef = useTemplateRef('searchPaneRef')
|
||||||
const downloadButtonRef = ref<HTMLDivElement>()
|
const downloadButtonRef = useTemplateRef('downloadButtonRef')
|
||||||
|
|
||||||
provide(searchPaneRefKey, searchPaneRef)
|
provide(searchPaneRefKey, searchPaneRef)
|
||||||
provide(navDownloadButtonRefKey, downloadButtonRef)
|
provide(navDownloadButtonRefKey, downloadButtonRef)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ async saveConfig(config: Config) : Promise<Result<null, CommandError>> {
|
|||||||
else return { status: "error", error: e as any };
|
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>> {
|
async generateQrcode() : Promise<Result<QrcodeData, CommandError>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("generate_qrcode") };
|
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> {
|
async restartDownloadTasks(taskIds: string[]) : Promise<void> {
|
||||||
await TAURI_INVOKE("restart_download_tasks", { taskIds });
|
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>> {
|
async restoreDownloadTasks() : Promise<Result<null, CommandError>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("restore_download_tasks") };
|
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;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
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__<{
|
export const events = __makeEvents__<{
|
||||||
downloadEvent: DownloadEvent,
|
downloadEvent: DownloadEvent,
|
||||||
logEvent: LogEvent
|
logEvent: LogEvent,
|
||||||
|
pluginEvent: PluginEvent
|
||||||
}>({
|
}>({
|
||||||
downloadEvent: "download-event",
|
downloadEvent: "download-event",
|
||||||
logEvent: "log-event"
|
logEvent: "log-event",
|
||||||
|
pluginEvent: "plugin-event"
|
||||||
})
|
})
|
||||||
|
|
||||||
/** user-defined constants **/
|
/** user-defined constants **/
|
||||||
@@ -185,8 +241,9 @@ export type AreaInBangumi = { id: number; name: string }
|
|||||||
export type AreaInBangumiFollow = { 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 ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string }
|
||||||
export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
|
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 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 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 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 }
|
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 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 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 CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||||
export type CommandError = { err_title: string; err_message: string }
|
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 }
|
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 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 ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
|
||||||
export type ContentList = { bold: boolean; content: string; number: string }
|
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 CreateCheeseDownloadTaskParams = { ep_ids: number[]; info: CheeseInfo }
|
||||||
export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams }
|
export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams }
|
||||||
export type CreateNormalDownloadTaskParams = { info: NormalInfo; aid_cid_pairs: ([number, number | null])[] }
|
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 DescV2 = { raw_text: string; type: number; biz_id: number }
|
||||||
export type DeviceType = "All" | "PC" | "Mobile" | "Pad" | "TV"
|
export type DeviceType = "All" | "PC" | "Mobile" | "Pad" | "TV"
|
||||||
export type Dimension = { width: number; height: number; rotate: number }
|
export type Dimension = { width: number; height: number; rotate: number }
|
||||||
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
||||||
export type DimensionInWatchLater = { width: number; height: number; rotate: 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 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 DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||||
export type Ed = { end: number; start: number }
|
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 }
|
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 FavFolders = { count: number; list: Folder[] }
|
||||||
export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number }
|
export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number }
|
||||||
export type FavSearchResult = FavInfo
|
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 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 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;
|
export type GetBangumiFollowInfoParams = { vmid: number;
|
||||||
/**
|
/**
|
||||||
* 1: 番剧 2: 电视剧或电影
|
* 1: 番剧 2: 电视剧或电影
|
||||||
*/
|
*/
|
||||||
type: number; pn: number; follow_status: number }
|
type: number; pn: number; follow_status: number }
|
||||||
export type GetBangumiInfoParams = { EpId: number } | { SeasonId: number }
|
export type GetBangumiInfoParams = { EpId: number } | { SeasonId: number }
|
||||||
|
export type GetCheeseAvailableMediaFormatsParams = { ep_id: number }
|
||||||
export type GetCheeseInfoParams = { EpId: number } | { SeasonId: number }
|
export type GetCheeseInfoParams = { EpId: number } | { SeasonId: number }
|
||||||
export type GetFavInfoParams = { media_list_id: number; pn: 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 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 GetNormalInfoParams = { Bvid: string } | { Aid: number }
|
||||||
export type GetUserVideoInfoParams = { pn: number; mid: 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 }
|
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 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 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 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 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 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 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 }
|
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 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 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 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 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 NormalSearchResult = NormalInfo
|
||||||
export type Official = { role: number; title: string; desc: string; type: number }
|
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 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 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 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 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 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 }
|
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 RatingInBangumi = { count: number; score: number }
|
||||||
export type RatingInBangumiFollow = { score: number; count: 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 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 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 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 }
|
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 UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
|
||||||
export type UserVideoList = { vlist: EpInUserVideo[] }
|
export type UserVideoList = { vlist: EpInUserVideo[] }
|
||||||
export type UserVideoSearchResult = UserVideoInfo
|
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 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 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 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 }
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
import { InputInst, InputProps } from 'naive-ui'
|
import { InputProps, NInput, NEl } from 'naive-ui'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -19,7 +19,7 @@ const props = withDefaults(
|
|||||||
const value = defineModel<InputProps['value']>('value', { required: true })
|
const value = defineModel<InputProps['value']>('value', { required: true })
|
||||||
|
|
||||||
const focused = ref(false)
|
const focused = ref(false)
|
||||||
const NInputRef = ref<InputInst>()
|
const NInputRef = useTemplateRef('NInputRef')
|
||||||
|
|
||||||
const floating = computed(() => value.value !== '' || focused.value)
|
const floating = computed(() => value.value !== '' || focused.value)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import icon from '../../src-tauri/icons/128x128.png'
|
import icon from '../../src-tauri/icons/128x128.png'
|
||||||
|
import { NA, NDialog, NModal } from 'naive-ui'
|
||||||
|
|
||||||
const showing = defineModel<boolean>('showing', { required: true })
|
const showing = defineModel<boolean>('showing', { required: true })
|
||||||
const version = ref('')
|
const version = ref('')
|
||||||
@@ -40,7 +41,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col text-xs items-center text-gray-400">
|
<div class="flex flex-col text-xs items-center text-gray-400">
|
||||||
<div>
|
<div>
|
||||||
Copyright © 2025
|
Copyright © 2025-2026
|
||||||
<n-a href="https://github.com/lanyeeee" target="_blank">lanyeeee</n-a>
|
<n-a href="https://github.com/lanyeeee" target="_blank">lanyeeee</n-a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,13 +1,54 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { LogEvent, LogLevel, events, commands } from '../bindings.ts'
|
import { commands, events, JsonValue, LogLevel, LogMetadata } from '../bindings.ts'
|
||||||
import { useNotification } from 'naive-ui'
|
import {
|
||||||
import { onMounted, ref, watch, computed } from 'vue'
|
NButton,
|
||||||
import { appDataDir } from '@tauri-apps/api/path'
|
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 { path } from '@tauri-apps/api'
|
||||||
import { useStore } from '../store.ts'
|
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()
|
const store = useStore()
|
||||||
|
|
||||||
@@ -17,39 +58,28 @@ const showing = defineModel<boolean>('showing', { required: true })
|
|||||||
|
|
||||||
let nextLogRecordId = 1
|
let nextLogRecordId = 1
|
||||||
|
|
||||||
const logRecords = ref<LogRecord[]>([])
|
const logLevelOptions = [
|
||||||
const searchText = ref<string>('')
|
{ 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 selectedLevel = ref<LogLevel>('INFO')
|
||||||
const logsDirSize = ref<number>(0)
|
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 formatedLogsDirSize = computed<string>(() => {
|
||||||
const units = ['B', 'KB', 'MB']
|
const units = ['B', 'KB', 'MB']
|
||||||
let size = logsDirSize.value
|
let size = logsDirSize.value
|
||||||
@@ -63,9 +93,12 @@ const formatedLogsDirSize = computed<string>(() => {
|
|||||||
// 保留两位小数
|
// 保留两位小数
|
||||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredLogs = computed<LogRecord[]>(() => {
|
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 = {
|
const logLevelPriority = {
|
||||||
TRACE: 0,
|
TRACE: 0,
|
||||||
DEBUG: 1,
|
DEBUG: 1,
|
||||||
@@ -77,15 +110,48 @@ const filteredLogs = computed<LogRecord[]>(() => {
|
|||||||
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
|
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 然后按搜索文本筛选
|
// 然后按过滤文本筛选
|
||||||
if (searchText.value === '') {
|
if (filterText.value === '') {
|
||||||
return true
|
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 () => {
|
watch(showing, async () => {
|
||||||
if (showing.value) {
|
if (showing.value) {
|
||||||
const result = await commands.getLogsDirSize()
|
const result = await commands.getLogsDirSize()
|
||||||
@@ -97,61 +163,88 @@ watch(showing, async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
let unListenLogEvent: () => void | undefined
|
||||||
await events.logEvent.listen(async ({ payload: logEvent }) => {
|
onMounted(() => {
|
||||||
const logRecord: LogRecord = {
|
events.logEvent
|
||||||
...logEvent,
|
.listen(({ payload: logEvent }) => {
|
||||||
id: nextLogRecordId++,
|
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
|
||||||
formatedLog: formatLogEvent(logEvent),
|
|
||||||
}
|
|
||||||
logRecords.value.push(logRecord)
|
|
||||||
|
|
||||||
const { level, fields } = logEvent
|
const logRecord = logMetadataToLogRecord(logMetadata)
|
||||||
if (level === 'ERROR') {
|
liveLogRecords.value.push(logRecord)
|
||||||
notification.error({
|
triggerRef(liveLogRecords)
|
||||||
title: fields['err_title'] as string,
|
|
||||||
description: fields['message'] as string,
|
if (logRecord.level === 'ERROR') {
|
||||||
duration: 0,
|
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 {
|
function formatJsonValue(jsonValue: JsonValue): string {
|
||||||
const { timestamp, level, fields, target, filename, line_number } = logEvent
|
if (Array.isArray(jsonValue)) return `[${jsonValue.map(formatJsonValue).join(', ')}]`
|
||||||
const fields_str = Object.entries(fields)
|
if (typeof jsonValue === 'object' && jsonValue !== null)
|
||||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
return `{${Object.entries(jsonValue)
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.map(([k, v]) => `${k}: ${formatJsonValue(v)}`)
|
||||||
.join(' ')
|
.join(', ')}}`
|
||||||
return `${timestamp} ${level} ${target}: ${filename}:${line_number} ${fields_str}`
|
return typeof jsonValue === 'string' ? `"${jsonValue}"` : String(jsonValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLevelStyles(level: LogLevel) {
|
function logMetadataToLogRecord(meta: LogMetadata): LogRecord {
|
||||||
switch (level) {
|
const message = meta.fields['message'] as string
|
||||||
case 'TRACE':
|
|
||||||
return 'text-gray-400'
|
const extraFields = Object.entries(meta.fields)
|
||||||
case 'DEBUG':
|
.filter(([key]) => key !== 'message')
|
||||||
return 'text-green-400'
|
.map(([key, jsonValue]) => ({
|
||||||
case 'INFO':
|
key,
|
||||||
return 'text-blue-400'
|
value: formatJsonValue(jsonValue),
|
||||||
case 'WARN':
|
}))
|
||||||
return 'text-yellow-400'
|
|
||||||
case 'ERROR':
|
const spanLines = meta.spans
|
||||||
return 'text-red-400'
|
?.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 = [
|
function clearLiveLogRecords() {
|
||||||
{ value: 'TRACE', label: 'TRACE' },
|
liveLogRecords.value = []
|
||||||
{ value: 'DEBUG', label: 'DEBUG' },
|
|
||||||
{ value: 'INFO', label: 'INFO' },
|
|
||||||
{ value: 'WARN', label: 'WARN' },
|
|
||||||
{ value: 'ERROR', label: 'ERROR' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function clearLogRecords() {
|
|
||||||
logRecords.value = []
|
|
||||||
nextLogRecordId = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showLogsDirInFileManager() {
|
async function showLogsDirInFileManager() {
|
||||||
@@ -161,44 +254,259 @@ async function showLogsDirInFileManager() {
|
|||||||
console.error(result.error)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal v-model:show="showing" v-if="store.config !== undefined">
|
<n-modal v-model:show="showing" v-if="store.config !== undefined">
|
||||||
<n-dialog
|
<n-dialog :showIcon="false" @close="showing = false" style="width: 95%">
|
||||||
:showIcon="false"
|
<template #header>
|
||||||
:title="`日志目录总大小:${formatedLogsDirSize}`"
|
<div class="text-lg font-bold flex items-center gap-2">
|
||||||
@close="showing = false"
|
<span v-if="viewMode === 'live'">📡 实时日志</span>
|
||||||
style="width: 95%">
|
<span v-else>
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
📂 文件日志
|
||||||
<n-input-group class="w-100">
|
<n-tag class="ml-2" type="primary" size="small">
|
||||||
<n-input size="small" v-model:value="searchText" placeholder="搜素日志..." clearable />
|
{{ currentFileName }}
|
||||||
<n-select size="small" v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
|
</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>
|
</n-input-group>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 ml-auto items-center">
|
<n-button v-if="viewMode === 'file'" class="mr-2" type="primary" secondary @click="exitFileMode">
|
||||||
<n-button size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
返回实时日志
|
||||||
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-config-provider :theme="darkTheme" :theme-overrides="{ Scrollbar: { width: '8px' } }">
|
<div class="pt-2 flex flex-wrap items-center">
|
||||||
<n-virtual-list
|
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
||||||
class="h-[calc(100vh-300px)] overflow-hidden bg-gray-900"
|
<n-button class="ml-2" size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
||||||
:item-size="42"
|
<n-tag class="ml-1" size="small" :bordered="false">
|
||||||
item-resizable
|
{{ formatedLogsDirSize }}
|
||||||
:hoverable="false"
|
</n-tag>
|
||||||
:items="filteredLogs"
|
|
||||||
:scrollbar-props="{ trigger: 'none' }">
|
<n-button
|
||||||
<template #default="{ item: { level, formatedLog } }: { item: LogRecord }">
|
v-if="viewMode === 'live'"
|
||||||
<div :class="['py-1 px-3 hover:bg-white/10 whitespace-pre-wrap mr-4', getLevelStyles(level)]">
|
ghost
|
||||||
{{ formatedLog }}
|
class="ml-auto"
|
||||||
</div>
|
size="small"
|
||||||
</template>
|
type="error"
|
||||||
</n-virtual-list>
|
@click="clearLiveLogRecords">
|
||||||
</n-config-provider>
|
清空实时日志
|
||||||
<div class="pt-1 flex">
|
</n-button>
|
||||||
<n-button ghost class="ml-auto" size="small" type="error" @click="clearLogRecords">清空日志浏览器</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</n-dialog>
|
</n-dialog>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { commands, QrcodeData, QrcodeStatus } from '../bindings.ts'
|
import { commands, QrcodeData, QrcodeStatus } from '../bindings.ts'
|
||||||
import { ref, watch } from 'vue'
|
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 { useStore } from '../store.ts'
|
||||||
import icon from '../../src-tauri/icons/128x128.png'
|
import icon from '../../src-tauri/icons/128x128.png'
|
||||||
import FloatLabelInput from '../components/FloatLabelInput.vue'
|
import FloatLabelInput from '../components/FloatLabelInput.vue'
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { path } from '@tauri-apps/api'
|
|||||||
import { appDataDir } from '@tauri-apps/api/path'
|
import { appDataDir } from '@tauri-apps/api/path'
|
||||||
import { useStore } from '../../store.ts'
|
import { useStore } from '../../store.ts'
|
||||||
import DownloadSettings from './components/DownloadSettings.vue'
|
import DownloadSettings from './components/DownloadSettings.vue'
|
||||||
import ProxySettings from './components/ProxySettings.vue'
|
|
||||||
import FmtSettings from './components/FmtSettings.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 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()
|
const store = useStore()
|
||||||
|
|
||||||
@@ -52,9 +52,9 @@ async function showConfigInFileManager() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal v-if="store.config !== undefined" v-model:show="showing">
|
<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">
|
<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="下载内容">
|
<n-tab-pane name="download_settings" tab="下载内容">
|
||||||
<DownloadSettings />
|
<DownloadSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -64,15 +64,21 @@ async function showConfigInFileManager() {
|
|||||||
<n-tab-pane name="ass_danmaku_settings" tab="ass弹幕">
|
<n-tab-pane name="ass_danmaku_settings" tab="ass弹幕">
|
||||||
<AssDanmakuSettings />
|
<AssDanmakuSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="download_speed_settings" tab="下载速度">
|
<n-tab-pane name="network_settings" tab="网络">
|
||||||
<DownloadSpeedSettings />
|
<NetworkSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="proxy_settings" tab="代理">
|
<n-tab-pane name="plugin_settings" tab="插件">
|
||||||
<ProxySettings />
|
<PluginSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</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>
|
</div>
|
||||||
</n-dialog>
|
</n-dialog>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStore } from '../../../store.ts'
|
import { useStore } from '../../../store.ts'
|
||||||
|
import { NTooltip, NInputGroupLabel, NCheckbox, NInputNumber, NInput, NInputGroup } from 'naive-ui'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,44 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AudioQuality, VideoQuality, CodecType } from '../../../bindings.ts'
|
|
||||||
import { useStore } from '../../../store.ts'
|
import { useStore } from '../../../store.ts'
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
import ColorfulTag from '../../../components/ColorfulTag.vue'
|
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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="flex gap-2">
|
||||||
<span class="w-15 font-bold">主要内容</span>
|
<span class="w-15 font-bold">主要内容</span>
|
||||||
<n-checkbox class="w-22" v-model:checked="store.config.download_video">下载视频</n-checkbox>
|
<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>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<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">
|
<n-tooltip placement="top" trigger="hover">
|
||||||
<div>还会顺便下载poster和fanart(如果有的话)</div>
|
<div>还会顺便下载poster和fanart(如果有的话)</div>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -109,7 +80,7 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
|
|||||||
color="blue"
|
color="blue"
|
||||||
v-for="videoQuality in store.config.video_quality_priority"
|
v-for="videoQuality in store.config.video_quality_priority"
|
||||||
:key="videoQuality">
|
:key="videoQuality">
|
||||||
{{ videoQualityNameMap.get(videoQuality) || videoQuality }}
|
{{ getVideoQualityName(videoQuality) }}
|
||||||
</ColorfulTag>
|
</ColorfulTag>
|
||||||
</VueDraggable>
|
</VueDraggable>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +100,7 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
|
|||||||
color="blue"
|
color="blue"
|
||||||
v-for="audioQuality in store.config.audio_quality_priority"
|
v-for="audioQuality in store.config.audio_quality_priority"
|
||||||
:key="audioQuality">
|
:key="audioQuality">
|
||||||
{{ audioQualityNameMap.get(audioQuality) || audioQuality }}
|
{{ getAudioQualityName(audioQuality) }}
|
||||||
</ColorfulTag>
|
</ColorfulTag>
|
||||||
</VueDraggable>
|
</VueDraggable>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +119,26 @@ const codecTypeNameMap: Map<CodecType, string> = new Map([
|
|||||||
color="blue"
|
color="blue"
|
||||||
v-for="codecType in store.config.codec_type_priority"
|
v-for="codecType in store.config.codec_type_priority"
|
||||||
:key="codecType">
|
:key="codecType">
|
||||||
{{ codecTypeNameMap.get(codecType) || codecType }}
|
{{ getCodecTypeName(codecType, { AVC: 'AVC (H.264)', HEVC: 'HEVC (H.265)' }) }}
|
||||||
</ColorfulTag>
|
</ColorfulTag>
|
||||||
</VueDraggable>
|
</VueDraggable>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</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">
|
<script setup lang="tsx">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useStore } from '../../../store.ts'
|
import { useStore } from '../../../store.ts'
|
||||||
|
import { NA, NConfigProvider, NInput, NPopover, NTooltip } from 'naive-ui'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
@@ -65,7 +66,20 @@ function AvailableFmtFields() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded bg-gray-500 px-1 select-all">episode_order</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded bg-gray-500 px-1 select-all">part_title</span>
|
<span class="rounded bg-gray-500 px-1 select-all">part_title</span>
|
||||||
@@ -73,8 +87,22 @@ function AvailableFmtFields() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded bg-gray-500 px-1 select-all">part_order</span>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded bg-gray-500 px-1 select-all">up_name</span>
|
<span class="rounded bg-gray-500 px-1 select-all">up_name</span>
|
||||||
<span class="ml-2">up昵称</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="rounded bg-gray-500 px-1 select-all">create_ts</span>
|
||||||
<span class="ml-2">下载任务创建的时间</span>
|
<span class="ml-2">下载任务创建的时间</span>
|
||||||
</div>
|
</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 type { InjectionKey, Ref } from 'vue'
|
||||||
import SearchPane from './panes/SearchPane/SearchPane.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 'virtual:uno.css'
|
||||||
import 'lazysizes'
|
import 'lazysizes'
|
||||||
import 'lazysizes/plugins/parent-fit/ls.parent-fit'
|
import 'lazysizes/plugins/parent-fit/ls.parent-fit'
|
||||||
|
import VueScan, { type VueScanOptions } from 'z-vue-scan'
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
const isProduction = import.meta.env.PROD
|
||||||
|
if (!isProduction) {
|
||||||
|
app.use<VueScanOptions>(VueScan, {})
|
||||||
|
}
|
||||||
|
|
||||||
app.use(pinia).mount('#app')
|
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