mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-06-07 08:30:03 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bca2912e7 | ||
|
|
88c45edb74 | ||
|
|
07f6e18719 | ||
|
|
c8b24073bb | ||
|
|
a47de661f6 | ||
|
|
bc6ac4bcc3 | ||
|
|
605c55fdec | ||
|
|
dd86eb9e41 | ||
|
|
5ed41ef7b6 | ||
|
|
ae73e474c7 | ||
|
|
c0c8af9094 | ||
|
|
f49340a42b | ||
|
|
8372b05143 | ||
|
|
2ba7504359 | ||
|
|
39b735e525 | ||
|
|
f86c882d3d | ||
|
|
387d50030d | ||
|
|
91f8c6d50c | ||
|
|
3a7e30b2cf | ||
|
|
8ddb61d6ca | ||
|
|
697a926398 | ||
|
|
b184bbc469 | ||
|
|
46ddbf999f | ||
|
|
47ce4a6a62 | ||
|
|
2053ca439e | ||
|
|
7cafb754ae | ||
|
|
e1088cb8f5 | ||
|
|
703723dfaa | ||
|
|
fa27007eb9 |
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: 🐞 反馈 Bug
|
||||
description: 反馈遇到的问题
|
||||
labels: [bug]
|
||||
title: "[Bug] 修改我!未修改标题的issue将被自动关闭"
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 在提交BUG之前
|
||||
options:
|
||||
- label: 我尝试使用了最新版,我确定这个问题在最新版中依然存在
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desc
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 发生了什么情况?有什么现状?哪个视频(链接/Bv号/ep号)?问题能稳定触发吗?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 这是整个issue中**最重要**的部分
|
||||
placeholder: |
|
||||
复现步骤是影响issue处理效率的最大因素
|
||||
没有详细的复现步骤将导致问题难以被定位,开发者需要花费大量时间来回沟通以定位问题
|
||||
仅提供报错信息或截图而没有复现步骤,问题难以被定位,也就难以被解决
|
||||
详细的复现步骤也是对项目维护工作的尊重
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 正常情况下应该发生什么
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 实际上发生了什么
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 日志
|
||||
description: 请提供相关的日志信息
|
||||
placeholder: |
|
||||
如果相关日志比较短可以直接粘贴
|
||||
|
||||
如果相关日志很长,建议将相关日志保存为txt,然后点击文本框下面小长条上传文件
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: media
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: 问题复现时候的截图或录屏
|
||||
placeholder: 点击文本框下面小长条可以上传文件
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 工具版本号
|
||||
placeholder: v0.0.1
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: 其他
|
||||
description: 其他要补充的内容
|
||||
placeholder: 其他要补充的内容
|
||||
validations:
|
||||
required: false
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ❓ 提问与讨论
|
||||
url: https://github.com/lanyeeee/bilibili-video-downloader/discussions
|
||||
about: 如果有一般性问题或想发起讨论,请使用 GitHub Discussions
|
||||
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: 🚀 功能请求
|
||||
description: 想要请求添加某个功能
|
||||
labels: [enhancement]
|
||||
title: "[功能请求] 修改我!未修改标题的issue将被自动关闭"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
为了使我更好地帮助你,请提供以下信息。以及上方的标题
|
||||
- type: textarea
|
||||
id: reason
|
||||
attributes:
|
||||
label: 原因
|
||||
description: 为什么想要这个功能
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desc
|
||||
attributes:
|
||||
label: 功能简述
|
||||
description: 想要个怎样的功能
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logic
|
||||
attributes:
|
||||
label: 功能逻辑
|
||||
description: 如何互交、如何使用等
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: ref
|
||||
attributes:
|
||||
label: 实现参考
|
||||
description: 该功能可能的实现方式,或者其他已经实现该功能的应用等
|
||||
44
.github/workflows/close-default-title-issue.yml
vendored
Normal file
44
.github/workflows/close-default-title-issue.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Close Issues with Default Title
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-issue-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check issue title
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const defaultTitle = '修改我!';
|
||||
|
||||
if (issue.title.includes(defaultTitle)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '检测到此issue的标题未修改\n\n为了更好地管理项目,帮助维护者快速理解问题,方便其他用户检索,请您在编写issue时务必**修改标题**\n\n此issue将被自动关闭。请修改标题后重新提交一个issue。感谢您的理解与合作!'
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['invalid']
|
||||
});
|
||||
}
|
||||
3
components.d.ts
vendored
3
components.d.ts
vendored
@@ -10,12 +10,14 @@ 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']
|
||||
@@ -31,6 +33,7 @@ declare module 'vue' {
|
||||
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']
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"pinia": "^3.0.3",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-draggable-plus": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.17(typescript@5.6.3)
|
||||
vue-draggable-plus:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0(@types/sortablejs@1.15.8)
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.30.1
|
||||
@@ -738,6 +741,9 @@ packages:
|
||||
'@types/lodash@4.17.20':
|
||||
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
|
||||
|
||||
'@types/sortablejs@1.15.8':
|
||||
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.36.0':
|
||||
resolution: {integrity: sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1963,6 +1969,15 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-draggable-plus@0.6.0:
|
||||
resolution: {integrity: sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==}
|
||||
peerDependencies:
|
||||
'@types/sortablejs': ^1.15.0
|
||||
'@vue/composition-api': '*'
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@10.2.0:
|
||||
resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -2576,6 +2591,8 @@ snapshots:
|
||||
|
||||
'@types/lodash@4.17.20': {}
|
||||
|
||||
'@types/sortablejs@1.15.8': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.6.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
@@ -3966,6 +3983,10 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-draggable-plus@0.6.0(@types/sortablejs@1.15.8):
|
||||
dependencies:
|
||||
'@types/sortablejs': 1.15.8
|
||||
|
||||
vue-eslint-parser@10.2.0(eslint@9.30.1(jiti@2.4.2)):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -286,6 +286,7 @@ name = "bilibili-video-downloader"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -297,11 +298,13 @@ dependencies = [
|
||||
"num_enum",
|
||||
"parking_lot 0.12.4",
|
||||
"prost",
|
||||
"rand 0.9.1",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"strfmt",
|
||||
|
||||
@@ -25,6 +25,7 @@ tauri-plugin-dialog = "2"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_repr = "0.1"
|
||||
|
||||
specta = { version = "=2.0.0-rc.20", features = ["serde_json"] }
|
||||
tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] }
|
||||
@@ -53,6 +54,8 @@ yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
|
||||
float-ord = { version = "0.3.2" }
|
||||
memchr = { version = "2.7.5" }
|
||||
md-5 = { version = "0.10.6" }
|
||||
rand = { version = "0.9.1" }
|
||||
base64 = { version = "0.22.1" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use bytes::Bytes;
|
||||
use parking_lot::RwLock;
|
||||
use prost::Message;
|
||||
@@ -20,14 +21,18 @@ use crate::{
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
protobuf::DmSegMobileReply,
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo,
|
||||
bangumi_follow_info::BangumiFollowInfo, bangumi_info::BangumiInfo,
|
||||
bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo,
|
||||
cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo,
|
||||
get_bangumi_follow_info_params::GetBangumiFollowInfoParams,
|
||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams,
|
||||
get_user_video_info_params::GetUserVideoInfoParams, normal_info::NormalInfo,
|
||||
normal_media_url::NormalMediaUrl, player_info::PlayerInfo, qrcode_data::QrcodeData,
|
||||
qrcode_status::QrcodeStatus, subtitle::Subtitle, tags::Tags, user_info::UserInfo,
|
||||
user_video_info::UserVideoInfo, watch_later_info::WatchLaterInfo,
|
||||
get_fav_info_params::GetFavInfoParams, get_history_info_params::GetHistoryInfoParams,
|
||||
get_normal_info_params::GetNormalInfoParams,
|
||||
get_user_video_info_params::GetUserVideoInfoParams, history_info::HistoryInfo,
|
||||
normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo,
|
||||
qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, skip_segments::SkipSegments,
|
||||
subtitle::Subtitle, tags::Tags, user_info::UserInfo, user_video_info::UserVideoInfo,
|
||||
watch_later_info::WatchLaterInfo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -296,10 +301,28 @@ impl BiliClient {
|
||||
&self,
|
||||
params: GetUserVideoInfoParams,
|
||||
) -> anyhow::Result<UserVideoInfo> {
|
||||
const DM_IMG_INTER: &str = r#"{"ds":[],"wh":[0,0,0],"of":[0,0,0]}"#;
|
||||
|
||||
fn random_base64() -> String {
|
||||
let random_bytes: Vec<u8> = (0..48).map(|_| rand::random_range(32..=127)).collect();
|
||||
|
||||
general_purpose::STANDARD.encode(&random_bytes)
|
||||
}
|
||||
|
||||
let mut dm_img_str = random_base64();
|
||||
dm_img_str.truncate(dm_img_str.len() - 2);
|
||||
|
||||
let mut dm_cover_img_str = random_base64();
|
||||
dm_cover_img_str.truncate(dm_cover_img_str.len() - 2);
|
||||
|
||||
let mut params: Vec<(&str, String)> = vec![
|
||||
("pn", params.pn.to_string()),
|
||||
("ps", "42".to_string()),
|
||||
("mid", params.mid.to_string()),
|
||||
("dm_img_list", "[]".to_string()),
|
||||
("dm_img_str", dm_img_str),
|
||||
("dm_cover_img_str", dm_cover_img_str),
|
||||
("dm_img_inter", DM_IMG_INTER.to_string()),
|
||||
];
|
||||
self.wbi(&mut params).await?;
|
||||
|
||||
@@ -607,6 +630,97 @@ impl BiliClient {
|
||||
Ok(watch_later_info)
|
||||
}
|
||||
|
||||
pub async fn get_bangumi_follow_info(
|
||||
&self,
|
||||
params: GetBangumiFollowInfoParams,
|
||||
) -> anyhow::Result<BangumiFollowInfo> {
|
||||
// 发送获取番剧追踪信息的请求
|
||||
let params = json!({
|
||||
"vmid": params.vmid,
|
||||
"type": params.type_field,
|
||||
"pn": params.pn,
|
||||
"ps": 24,
|
||||
"follow_status": params.follow_status,
|
||||
});
|
||||
let request = self
|
||||
.api_client
|
||||
.read()
|
||||
.get("https://api.bilibili.com/x/space/bangumi/follow/list")
|
||||
.query(¶ms)
|
||||
.header("cookie", self.get_cookie());
|
||||
let http_resp = request.send().await?;
|
||||
// 检查http响应状态码
|
||||
let status = http_resp.status();
|
||||
let body = http_resp.text().await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
||||
}
|
||||
// 尝试将body解析为BiliResp
|
||||
let bili_resp: BiliResp =
|
||||
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
|
||||
// 检查BiliResp的code字段
|
||||
if bili_resp.code != 0 {
|
||||
return Err(anyhow!("预料之外的code: {bili_resp:?}"));
|
||||
}
|
||||
// 检查BiliResp的data是否存在
|
||||
let Some(data) = bili_resp.data else {
|
||||
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
|
||||
};
|
||||
// 尝试将data解析为BangumiFollowInfo
|
||||
let data_str = data.to_string();
|
||||
let bangumi_follow_info: BangumiFollowInfo = serde_json::from_str(&data_str)
|
||||
.context(format!("将data解析为BangumiFollowInfo失败: {data_str}"))?;
|
||||
|
||||
Ok(bangumi_follow_info)
|
||||
}
|
||||
|
||||
pub async fn get_history_info(
|
||||
&self,
|
||||
params: GetHistoryInfoParams,
|
||||
) -> anyhow::Result<HistoryInfo> {
|
||||
let device_type: i64 = params.device_type.into();
|
||||
let params = json!({
|
||||
"pn": params.pn,
|
||||
"keyword": params.keyword,
|
||||
"business": "archive",
|
||||
"add_time_start": params.add_time_start,
|
||||
"add_time_end": params.add_time_end,
|
||||
"arc_max_duration": params.arc_max_duration,
|
||||
"arc_min_duration": params.arc_min_duration,
|
||||
"device_type": device_type,
|
||||
});
|
||||
let request = self
|
||||
.api_client
|
||||
.read()
|
||||
.get("https://api.bilibili.com/x/web-interface/history/search")
|
||||
.query(¶ms)
|
||||
.header("cookie", self.get_cookie());
|
||||
let http_resp = request.send().await?;
|
||||
// 检查http响应状态码
|
||||
let status = http_resp.status();
|
||||
let body = http_resp.text().await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
||||
}
|
||||
// 尝试将body解析为BiliResp
|
||||
let bili_resp: BiliResp =
|
||||
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
|
||||
// 检查BiliResp的code字段
|
||||
if bili_resp.code != 0 {
|
||||
return Err(anyhow!("预料之外的code: {bili_resp:?}"));
|
||||
}
|
||||
// 检查BiliResp的data是否存在
|
||||
let Some(data) = bili_resp.data else {
|
||||
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
|
||||
};
|
||||
// 尝试将data解析为HistoryInfo
|
||||
let data_str = data.to_string();
|
||||
let history_info: HistoryInfo = serde_json::from_str(&data_str)
|
||||
.context(format!("将data解析为HistoryInfo失败: {data_str}"))?;
|
||||
|
||||
Ok(history_info)
|
||||
}
|
||||
|
||||
pub async fn get_media_chunk(
|
||||
&self,
|
||||
media_url: &str,
|
||||
@@ -804,6 +918,41 @@ impl BiliClient {
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub async fn get_skip_segments(
|
||||
&self,
|
||||
bvid: &str,
|
||||
cid: Option<i64>,
|
||||
) -> anyhow::Result<SkipSegments> {
|
||||
// 发送获取跳过片段的请求
|
||||
let mut params = json!({
|
||||
"videoID": bvid,
|
||||
"actionType": "skip",
|
||||
});
|
||||
if let Some(cid) = cid {
|
||||
params["cid"] = cid.into();
|
||||
}
|
||||
|
||||
let request = self
|
||||
.api_client
|
||||
.read()
|
||||
.get("https://bsbsb.top/api/skipSegments")
|
||||
.query(¶ms);
|
||||
let http_resp = request.send().await?;
|
||||
// 检查http响应状态码
|
||||
let status = http_resp.status();
|
||||
let body = http_resp.text().await?;
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Ok(SkipSegments(Vec::new()));
|
||||
} else if status != StatusCode::OK {
|
||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
||||
}
|
||||
// 尝试将body解析为SkipSegments
|
||||
let skip_segments: SkipSegments =
|
||||
serde_json::from_str(&body).context(format!("将body解析为SkipSegments失败: {body}"))?;
|
||||
|
||||
Ok(skip_segments)
|
||||
}
|
||||
|
||||
pub fn get_cookie(&self) -> String {
|
||||
let sessdata = self.app.get_config().read().sessdata.clone();
|
||||
format!("SESSDATA={sessdata}")
|
||||
|
||||
@@ -9,15 +9,19 @@ use crate::{
|
||||
extensions::AppHandleExt,
|
||||
logger,
|
||||
types::{
|
||||
bangumi_info::EpInBangumi,
|
||||
bangumi_follow_info::BangumiFollowInfo,
|
||||
bangumi_info::{BangumiInfo, EpInBangumi},
|
||||
create_download_task_params::CreateDownloadTaskParams,
|
||||
fav_folders::FavFolders,
|
||||
fav_info::FavInfo,
|
||||
get_bangumi_follow_info_params::GetBangumiFollowInfoParams,
|
||||
get_bangumi_info_params::GetBangumiInfoParams,
|
||||
get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_fav_info_params::GetFavInfoParams,
|
||||
get_history_info_params::GetHistoryInfoParams,
|
||||
get_normal_info_params::GetNormalInfoParams,
|
||||
get_user_video_info_params::GetUserVideoInfoParams,
|
||||
history_info::HistoryInfo,
|
||||
normal_info::NormalInfo,
|
||||
qrcode_data::QrcodeData,
|
||||
qrcode_status::QrcodeStatus,
|
||||
@@ -26,6 +30,7 @@ use crate::{
|
||||
BangumiSearchResult, CheeseSearchResult, FavSearchResult, NormalSearchResult,
|
||||
SearchResult, UserVideoSearchResult,
|
||||
},
|
||||
skip_segments::SkipSegments,
|
||||
user_info::UserInfo,
|
||||
user_video_info::UserVideoInfo,
|
||||
watch_later_info::WatchLaterInfo,
|
||||
@@ -118,6 +123,20 @@ pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult<Us
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn get_bangumi_info(
|
||||
app: AppHandle,
|
||||
params: GetBangumiInfoParams,
|
||||
) -> CommandResult<BangumiInfo> {
|
||||
let bili_client = app.get_bili_client();
|
||||
let bangumi_info = bili_client
|
||||
.get_bangumi_info(params)
|
||||
.await
|
||||
.map_err(|err| CommandError::from("获取番剧视频信息失败", err))?;
|
||||
Ok(bangumi_info)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn get_normal_info(
|
||||
@@ -179,6 +198,34 @@ pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult<Wa
|
||||
Ok(watch_later_info)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn get_bangumi_follow_info(
|
||||
app: AppHandle,
|
||||
params: GetBangumiFollowInfoParams,
|
||||
) -> CommandResult<BangumiFollowInfo> {
|
||||
let bili_client = app.get_bili_client();
|
||||
let bangumi_follow_info = bili_client
|
||||
.get_bangumi_follow_info(params)
|
||||
.await
|
||||
.map_err(|err| CommandError::from("获取追番信息失败", err))?;
|
||||
Ok(bangumi_follow_info)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn get_history_info(
|
||||
app: AppHandle,
|
||||
params: GetHistoryInfoParams,
|
||||
) -> CommandResult<HistoryInfo> {
|
||||
let bili_client = app.get_bili_client();
|
||||
let history_info = bili_client
|
||||
.get_history_info(params)
|
||||
.await
|
||||
.map_err(|err| CommandError::from("获取历史记录失败", err))?;
|
||||
Ok(history_info)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
@@ -331,3 +378,18 @@ pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()
|
||||
.map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn get_skip_segments(
|
||||
app: AppHandle,
|
||||
bvid: String,
|
||||
cid: Option<i64>,
|
||||
) -> CommandResult<SkipSegments> {
|
||||
let bili_client = app.get_bili_client();
|
||||
let skip_segments = bili_client
|
||||
.get_skip_segments(&bvid, cid)
|
||||
.await
|
||||
.map_err(|err| CommandError::from("获取跳过片段失败", err))?;
|
||||
Ok(skip_segments)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::danmaku_xml_to_ass::canvas::CanvasConfig;
|
||||
use crate::{
|
||||
danmaku_xml_to_ass::canvas::CanvasConfig,
|
||||
types::{audio_quality::AudioQuality, codec_type::CodecType, video_quality::VideoQuality},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
@@ -14,12 +16,14 @@ pub struct Config {
|
||||
pub download_dir: PathBuf,
|
||||
pub enable_file_logger: bool,
|
||||
pub sessdata: String,
|
||||
pub prefer_video_quality: PreferVideoQuality,
|
||||
pub prefer_codec_type: PreferCodecType,
|
||||
pub prefer_audio_quality: PreferAudioQuality,
|
||||
pub video_quality_priority: Vec<VideoQuality>,
|
||||
pub codec_type_priority: Vec<CodecType>,
|
||||
pub audio_quality_priority: Vec<AudioQuality>,
|
||||
pub download_video: bool,
|
||||
pub download_audio: bool,
|
||||
pub auto_merge: bool,
|
||||
pub embed_chapter: bool,
|
||||
pub embed_skip: bool,
|
||||
pub download_xml_danmaku: bool,
|
||||
pub download_ass_danmaku: bool,
|
||||
pub download_json_danmaku: bool,
|
||||
@@ -94,16 +98,41 @@ impl Config {
|
||||
fn default(app_data_dir: &Path) -> Config {
|
||||
const DEFAULT_FMT_FOR_PART: &str =
|
||||
"{collection_title}/{episode_title}/{episode_title}-P{part_order} {part_title}";
|
||||
let default_video_quality_priority = vec![
|
||||
VideoQuality::Video8K,
|
||||
VideoQuality::VideoDolby,
|
||||
VideoQuality::VideoHDR,
|
||||
VideoQuality::Video4K,
|
||||
VideoQuality::Video1080P60,
|
||||
VideoQuality::Video1080PPlus,
|
||||
VideoQuality::Video1080P,
|
||||
VideoQuality::VideoAiRepair,
|
||||
VideoQuality::Video720P60,
|
||||
VideoQuality::Video720P,
|
||||
VideoQuality::Video480P,
|
||||
VideoQuality::Video360P,
|
||||
VideoQuality::Video240P,
|
||||
];
|
||||
let default_audio_quality_priority = vec![
|
||||
AudioQuality::AudioHiRes,
|
||||
AudioQuality::AudioDolby,
|
||||
AudioQuality::Audio192K,
|
||||
AudioQuality::Audio132K,
|
||||
AudioQuality::Audio64K,
|
||||
];
|
||||
|
||||
Config {
|
||||
download_dir: app_data_dir.join("视频下载"),
|
||||
enable_file_logger: true,
|
||||
sessdata: String::new(),
|
||||
prefer_video_quality: PreferVideoQuality::Best,
|
||||
prefer_codec_type: PreferCodecType::AVC,
|
||||
prefer_audio_quality: PreferAudioQuality::Best,
|
||||
video_quality_priority: default_video_quality_priority,
|
||||
codec_type_priority: vec![CodecType::AVC, CodecType::HEVC, CodecType::AV1],
|
||||
audio_quality_priority: default_audio_quality_priority,
|
||||
download_video: true,
|
||||
download_audio: true,
|
||||
auto_merge: true,
|
||||
embed_chapter: true,
|
||||
embed_skip: true,
|
||||
download_xml_danmaku: true,
|
||||
download_ass_danmaku: true,
|
||||
download_json_danmaku: true,
|
||||
@@ -126,102 +155,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Type,
|
||||
IntoPrimitive,
|
||||
FromPrimitive,
|
||||
)]
|
||||
#[repr(i64)]
|
||||
pub enum PreferVideoQuality {
|
||||
#[default]
|
||||
Best = -1,
|
||||
|
||||
#[serde(rename = "240P")]
|
||||
Video240P = 6,
|
||||
#[serde(rename = "360P")]
|
||||
Video360P = 16,
|
||||
#[serde(rename = "480P")]
|
||||
Video480P = 32,
|
||||
#[serde(rename = "720P")]
|
||||
Video720P = 64,
|
||||
#[serde(rename = "720P60")]
|
||||
Video720P60 = 74,
|
||||
#[serde(rename = "1080P")]
|
||||
Video1080P = 80,
|
||||
#[serde(rename = "AiRepair")]
|
||||
VideoAiRepair = 100,
|
||||
#[serde(rename = "1080P+")]
|
||||
Video1080PPlus = 112,
|
||||
#[serde(rename = "1080P60")]
|
||||
Video1080P60 = 116,
|
||||
#[serde(rename = "4K")]
|
||||
Video4K = 120,
|
||||
#[serde(rename = "HDR")]
|
||||
VideoHDR = 125,
|
||||
#[serde(rename = "Dolby")]
|
||||
VideoDolby = 126,
|
||||
#[serde(rename = "8K")]
|
||||
Video8K = 127,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Type,
|
||||
IntoPrimitive,
|
||||
FromPrimitive,
|
||||
)]
|
||||
#[repr(i64)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum PreferCodecType {
|
||||
#[default]
|
||||
Unknown = -1,
|
||||
|
||||
AVC = 7,
|
||||
HEVC = 12,
|
||||
AV1 = 13,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Type,
|
||||
IntoPrimitive,
|
||||
FromPrimitive,
|
||||
)]
|
||||
#[repr(i64)]
|
||||
pub enum PreferAudioQuality {
|
||||
#[default]
|
||||
Best = -1,
|
||||
#[serde(rename = "64K")]
|
||||
Audio64K = 30216,
|
||||
#[serde(rename = "132K")]
|
||||
Audio132K = 30232,
|
||||
#[serde(rename = "192K")]
|
||||
Audio192K = 30280,
|
||||
#[serde(rename = "Dolby")]
|
||||
AudioDolby = 30250,
|
||||
#[serde(rename = "HiRes")]
|
||||
AudioHiRes = 30251,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub enum ProxyMode {
|
||||
#[default]
|
||||
|
||||
122
src-tauri/src/downloader/chapter_segments.rs
Normal file
122
src-tauri/src/downloader/chapter_segments.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
pub struct ChapterSegments {
|
||||
pub segments: Vec<ChapterSegment>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChapterSegment {
|
||||
pub title: String,
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
}
|
||||
|
||||
impl ChapterSegments {
|
||||
/// 插入一个新的章节片段
|
||||
///
|
||||
/// 此函数会处理新片段与现有片段的重叠情况:
|
||||
/// - 对于与新片段重叠的现有片段,会将其分割为非重叠的部分
|
||||
/// - 新片段会替换所有重叠区域
|
||||
/// - 最终结果会按开始时间排序
|
||||
///
|
||||
/// # 参数
|
||||
/// * `new_segment` - 要插入的新章节片段
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// // 假设现有片段: [0-10], [20-30]
|
||||
/// // 插入新片段: [5-25]
|
||||
/// // 结果: [0-5], [5-25], [25-30]
|
||||
/// ```
|
||||
pub fn insert(&mut self, new_segment: ChapterSegment) {
|
||||
// 创建一个新的 Vec 来存储处理后的片段
|
||||
// 预分配容量为当前片段数量 + 2,因为最坏情况下每个现有片段可能被分割成两部分,再加上新片段
|
||||
let mut processed_segments = Vec::with_capacity(self.segments.len() + 2);
|
||||
|
||||
for segment in &self.segments {
|
||||
if !Self::overlaps(segment, &new_segment) {
|
||||
// 如果当前片段与新片段没有重叠,直接将当前片段添加到结果中
|
||||
processed_segments.push(segment.clone());
|
||||
continue;
|
||||
}
|
||||
// 如果有重叠,需要分割当前片段,只保留不与新片段重叠的部分
|
||||
|
||||
// 处理左侧部分:当前片段开始到新片段开始之间的部分
|
||||
// left_end 是左侧部分的结束时间,取当前片段结束时间和新片段开始时间的较小值
|
||||
let left_end = segment.end.min(new_segment.start);
|
||||
if segment.start < left_end {
|
||||
// 只有当左侧部分确实存在时(start < end)才添加
|
||||
processed_segments.push(ChapterSegment {
|
||||
title: segment.title.clone(),
|
||||
start: segment.start,
|
||||
end: left_end,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理右侧部分:新片段结束到当前片段结束之间的部分
|
||||
// right_start 是右侧部分的开始时间,取当前片段开始时间和新片段结束时间的较大值
|
||||
let right_start = segment.start.max(new_segment.end);
|
||||
if right_start < segment.end {
|
||||
// 只有当右侧部分确实存在时(start < end)才添加
|
||||
processed_segments.push(ChapterSegment {
|
||||
title: segment.title.clone(),
|
||||
start: right_start,
|
||||
end: segment.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历完所有现有片段并处理完所有重叠后,将新的片段添加到结果列表中
|
||||
processed_segments.push(new_segment);
|
||||
|
||||
processed_segments.sort_by(|a, b| a.start.cmp(&b.start));
|
||||
|
||||
self.segments = processed_segments;
|
||||
}
|
||||
|
||||
pub fn generate_chapter_metadata(&self, video_duration: u64) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
fn write_segment(content: &mut String, title: &str, start: i64, end: i64) {
|
||||
let _ = writeln!(
|
||||
content,
|
||||
"[CHAPTER]\nTIMEBASE=1/1\nSTART={start}\nEND={end}\ntitle={title}\n"
|
||||
);
|
||||
}
|
||||
|
||||
let video_duration = i64::try_from(video_duration).unwrap_or(i64::MAX);
|
||||
|
||||
let mut metadata_content = ";FFMETADATA1\n".to_string();
|
||||
|
||||
let mut last_end = 0;
|
||||
for segment in &self.segments {
|
||||
// 检查当前片段的开始时间与上一个片段的结束时间之间是否有间隙
|
||||
if segment.start > last_end {
|
||||
// 如果有间隙,则插入一个标题为空格的空白片段
|
||||
write_segment(&mut metadata_content, " ", last_end, segment.start);
|
||||
}
|
||||
|
||||
// 写入当前片段
|
||||
write_segment(
|
||||
&mut metadata_content,
|
||||
&segment.title,
|
||||
segment.start,
|
||||
segment.end,
|
||||
);
|
||||
|
||||
// 更新上一个片段的结束时间
|
||||
last_end = segment.end;
|
||||
}
|
||||
|
||||
// 循环结束后,检查最后一个片段的结尾与视频总时长之间是否还有间隙
|
||||
if video_duration > last_end {
|
||||
// 如果有,则填充从 last_end 到视频结尾的剩余部分
|
||||
write_segment(&mut metadata_content, " ", last_end, video_duration);
|
||||
}
|
||||
|
||||
metadata_content
|
||||
}
|
||||
|
||||
/// 检查两个片段是否重叠。
|
||||
fn overlaps(s1: &ChapterSegment, s2: &ChapterSegment) -> bool {
|
||||
s1.start < s1.end && s2.start < s2.end && s1.start < s2.end && s2.start < s1.end
|
||||
}
|
||||
}
|
||||
118
src-tauri/src/downloader/download_chunk_task.rs
Normal file
118
src-tauri/src/downloader/download_chunk_task.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Seek, Write},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use tokio::{sync::SemaphorePermit, time::sleep};
|
||||
|
||||
use crate::{
|
||||
downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState},
|
||||
extensions::AppHandleExt,
|
||||
};
|
||||
|
||||
pub struct DownloadChunkTask {
|
||||
pub download_task: Arc<DownloadTask>,
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
pub url: String,
|
||||
pub file: Arc<Mutex<File>>,
|
||||
pub chunk_index: usize,
|
||||
}
|
||||
|
||||
impl DownloadChunkTask {
|
||||
pub async fn process(self) -> anyhow::Result<usize> {
|
||||
let download_chunk_task = self.download_chunk();
|
||||
tokio::pin!(download_chunk_task);
|
||||
|
||||
let mut state_receiver = self.download_task.state_sender.subscribe();
|
||||
state_receiver.mark_changed();
|
||||
|
||||
let mut restart_receiver = self.download_task.restart_sender.subscribe();
|
||||
let mut delete_receiver = self.download_task.delete_sender.subscribe();
|
||||
|
||||
let mut permit = None;
|
||||
|
||||
loop {
|
||||
let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading;
|
||||
tokio::select! {
|
||||
result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result,
|
||||
|
||||
result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => {
|
||||
match result {
|
||||
Ok(()) => {},
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
},
|
||||
|
||||
_ = state_receiver.changed() => {
|
||||
if *state_receiver.borrow() == DownloadTaskState::Paused {
|
||||
// 稍微等一下再释放permit
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
if let Some(permit) = permit.take() {
|
||||
drop(permit);
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_ = restart_receiver.changed() => break Ok(self.chunk_index),
|
||||
|
||||
_ = delete_receiver.changed() => break Ok(self.chunk_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_chunk(&self) -> anyhow::Result<usize> {
|
||||
let bili_client = self.download_task.app.get_bili_client();
|
||||
let chunk_data = bili_client
|
||||
.get_media_chunk(&self.url, self.start, self.end)
|
||||
.await?;
|
||||
|
||||
let len = chunk_data.len() as u64;
|
||||
self.download_task
|
||||
.app
|
||||
.get_download_manager()
|
||||
.byte_per_sec
|
||||
.fetch_add(len, std::sync::atomic::Ordering::Relaxed);
|
||||
// 将下载的内容写入文件
|
||||
{
|
||||
let mut file = self.file.lock();
|
||||
file.seek(std::io::SeekFrom::Start(self.start))?;
|
||||
file.write_all(&chunk_data)?;
|
||||
}
|
||||
|
||||
let chunk_download_interval_sec = self
|
||||
.download_task
|
||||
.app
|
||||
.get_config()
|
||||
.read()
|
||||
.chunk_download_interval_sec;
|
||||
sleep(Duration::from_secs(chunk_download_interval_sec)).await;
|
||||
|
||||
Ok(self.chunk_index)
|
||||
}
|
||||
|
||||
async fn acquire_chunk_permit<'a>(
|
||||
&'a self,
|
||||
permit: &mut Option<SemaphorePermit<'a>>,
|
||||
) -> anyhow::Result<()> {
|
||||
*permit = match permit.take() {
|
||||
// 如果有permit,则直接用
|
||||
Some(permit) => Some(permit),
|
||||
// 如果没有permit,则获取permit
|
||||
None => Some(
|
||||
self.download_task
|
||||
.app
|
||||
.get_download_manager()
|
||||
.inner()
|
||||
.media_chunk_sem
|
||||
.acquire()
|
||||
.await?,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ use crate::{
|
||||
config::Config,
|
||||
downloader::tasks::{
|
||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
||||
json_task::JsonTask, merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
||||
video_task::VideoTask,
|
||||
json_task::JsonTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
||||
video_process_task::VideoProcessTask, video_task::VideoTask,
|
||||
},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
@@ -52,7 +52,7 @@ pub struct DownloadProgress {
|
||||
pub filename: String,
|
||||
pub video_task: VideoTask,
|
||||
pub audio_task: AudioTask,
|
||||
pub merge_task: MergeTask,
|
||||
pub video_process_task: VideoProcessTask,
|
||||
pub subtitle_task: SubtitleTask,
|
||||
pub danmaku_task: DanmakuTask,
|
||||
pub cover_task: CoverTask,
|
||||
@@ -124,7 +124,7 @@ impl DownloadProgress {
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -175,7 +175,7 @@ impl DownloadProgress {
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -297,15 +297,19 @@ impl DownloadProgress {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> {
|
||||
pub fn save(&self, app: &AppHandle, allow_create: bool) -> anyhow::Result<()> {
|
||||
let progress = self.clone();
|
||||
let file_name = format!("{}.json", progress.task_id);
|
||||
|
||||
let app_data_dir = app.path().app_data_dir()?;
|
||||
let task_dir = app_data_dir.join(".下载任务");
|
||||
std::fs::create_dir_all(&task_dir)?;
|
||||
let tasks_dir = app_data_dir.join(".下载任务");
|
||||
std::fs::create_dir_all(&tasks_dir)?;
|
||||
|
||||
let save_path = tasks_dir.join(file_name);
|
||||
if !allow_create && !save_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let save_path = task_dir.join(file_name);
|
||||
let progress_json = serde_json::to_string(&progress)?;
|
||||
std::fs::write(save_path, progress_json)?;
|
||||
|
||||
@@ -315,7 +319,7 @@ impl DownloadProgress {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.video_task.is_completed()
|
||||
&& self.audio_task.is_completed()
|
||||
&& self.merge_task.is_completed()
|
||||
&& self.video_process_task.is_completed()
|
||||
&& self.danmaku_task.is_completed()
|
||||
&& self.subtitle_task.is_completed()
|
||||
&& self.cover_task.is_completed()
|
||||
@@ -326,7 +330,7 @@ impl DownloadProgress {
|
||||
pub fn mark_uncompleted(&mut self) {
|
||||
self.video_task.mark_uncompleted();
|
||||
self.audio_task.mark_uncompleted();
|
||||
self.merge_task.completed = false;
|
||||
self.video_process_task.completed = false;
|
||||
self.danmaku_task.completed = false;
|
||||
self.subtitle_task.completed = false;
|
||||
self.cover_task.completed = false;
|
||||
@@ -379,7 +383,7 @@ fn create_normal_progresses_for_single(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -419,7 +423,7 @@ fn create_normal_progresses_for_single(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -459,7 +463,7 @@ fn create_normal_progresses_for_single(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video.clone(),
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
video_process_task: tasks.video_process.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
@@ -531,7 +535,7 @@ fn create_normal_progresses_for_season(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -571,7 +575,7 @@ fn create_normal_progresses_for_season(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
video_process_task: tasks.video_process,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -612,7 +616,7 @@ fn create_normal_progresses_for_season(
|
||||
filename: String::new(),
|
||||
video_task: tasks.video.clone(),
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
video_process_task: tasks.video_process.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
@@ -634,7 +638,7 @@ fn create_normal_progresses_for_season(
|
||||
struct Tasks {
|
||||
video: VideoTask,
|
||||
audio: AudioTask,
|
||||
merge: MergeTask,
|
||||
video_process: VideoProcessTask,
|
||||
danmaku: DanmakuTask,
|
||||
subtitle: SubtitleTask,
|
||||
cover: CoverTask,
|
||||
@@ -663,8 +667,10 @@ impl Tasks {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
let merge = MergeTask {
|
||||
selected: config.auto_merge,
|
||||
let video_process = VideoProcessTask {
|
||||
merge_selected: config.auto_merge,
|
||||
embed_chapter_selected: config.embed_chapter,
|
||||
embed_skip_selected: config.embed_skip,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
@@ -699,7 +705,7 @@ impl Tasks {
|
||||
Self {
|
||||
video,
|
||||
audio,
|
||||
merge,
|
||||
video_process,
|
||||
danmaku,
|
||||
subtitle,
|
||||
cover,
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{Seek, Write},
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use fs4::fs_std::FileExt;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use anyhow::Context;
|
||||
use parking_lot::RwLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
sync::{watch, SemaphorePermit},
|
||||
task::JoinSet,
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
bili_client::BiliClient,
|
||||
danmaku_xml_to_ass::xml_to_ass,
|
||||
downloader::episode_type::EpisodeType,
|
||||
events::DownloadEvent,
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo,
|
||||
create_download_task_params::CreateDownloadTaskParams,
|
||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
|
||||
},
|
||||
utils::{self, ToXml},
|
||||
types::create_download_task_params::CreateDownloadTaskParams,
|
||||
};
|
||||
|
||||
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
||||
@@ -103,7 +90,7 @@ impl DownloadTask {
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for progress in progresses {
|
||||
if let Err(err) = progress.save(app) {
|
||||
if let Err(err) = progress.save(app, true) {
|
||||
let ids_string = progress.get_ids_string();
|
||||
let episode_title = &progress.episode_title;
|
||||
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
|
||||
@@ -183,7 +170,7 @@ impl DownloadTask {
|
||||
|
||||
let download_task = async {
|
||||
download_task_option
|
||||
.get_or_insert(Box::pin(self.download()))
|
||||
.get_or_insert_with(|| Box::pin(self.download()))
|
||||
.await;
|
||||
};
|
||||
|
||||
@@ -288,61 +275,77 @@ impl DownloadTask {
|
||||
episode_dir.display()
|
||||
))?;
|
||||
|
||||
if !progress.video_task.is_completed() && progress.video_task.content_length != 0 {
|
||||
// 如果视频任务被选中且未完成且有要下载的内容,则下载视频
|
||||
self.download_video(&progress)
|
||||
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 !progress.audio_task.is_completed() && progress.audio_task.content_length != 0 {
|
||||
// 如果音频任务被选中且未完成且有要下载的内容,则下载音频
|
||||
self.download_audio(&progress)
|
||||
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 !progress.merge_task.is_completed() {
|
||||
self.merge_video_audio(&progress)
|
||||
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}`视频和音频合并完成");
|
||||
.context(format!("{ids_string} `{filename}`视频处理失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`视频处理完成");
|
||||
}
|
||||
|
||||
if !progress.danmaku_task.is_completed() {
|
||||
self.download_danmaku(&progress)
|
||||
if !danmaku_task.is_completed() {
|
||||
danmaku_task
|
||||
.process(self, &progress)
|
||||
.await
|
||||
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
|
||||
}
|
||||
|
||||
if !progress.subtitle_task.is_completed() {
|
||||
self.download_subtitle(&progress)
|
||||
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 !progress.cover_task.is_completed() {
|
||||
self.download_cover(&progress)
|
||||
if !cover_task.is_completed() {
|
||||
cover_task
|
||||
.process(self, &progress)
|
||||
.await
|
||||
.context(format!("{ids_string} `{filename}`下载封面失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
||||
}
|
||||
|
||||
let mut episode_info = None;
|
||||
|
||||
if !progress.nfo_task.is_completed() {
|
||||
self.download_nfo(&progress, &mut episode_info)
|
||||
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 !progress.json_task.is_completed() {
|
||||
self.download_json(&progress, &mut episode_info)
|
||||
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元数据下载完成");
|
||||
@@ -359,560 +362,6 @@ impl DownloadTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_video(self: &Arc<Self>, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let temp_file_path = episode_dir.join(format!(
|
||||
"{filename}.mp4.com.lanyeeee.bilibili-video-downloader"
|
||||
));
|
||||
|
||||
let (video_task, episode_title, ids_string) = {
|
||||
let progress = self.progress.read();
|
||||
(
|
||||
progress.video_task.clone(),
|
||||
progress.episode_title.clone(),
|
||||
progress.get_ids_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let file = if temp_file_path.exists() {
|
||||
// 如果临时文件已存在,则打开它
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&temp_file_path)?
|
||||
} else {
|
||||
// 如果临时文件不存在,创建它并预分配空间
|
||||
let file = File::create(&temp_file_path)?;
|
||||
file.allocate(video_task.content_length)?;
|
||||
file
|
||||
};
|
||||
let file = Arc::new(Mutex::new(file));
|
||||
|
||||
let chunk_count = video_task.chunks.len();
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
for (i, chunk) in video_task.chunks.iter().enumerate() {
|
||||
if chunk.completed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (start, end) = (chunk.start, chunk.end);
|
||||
|
||||
let download_chunk_task = DownloadChunkTask {
|
||||
download_task: self.clone(),
|
||||
start,
|
||||
end,
|
||||
url: video_task.url.to_string(),
|
||||
file: file.clone(),
|
||||
chunk_index: i,
|
||||
};
|
||||
|
||||
let chunk_order = i + 1;
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_chunk_task.process().await.context(format!(
|
||||
"分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})"
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(Ok(download_video_result)) = join_set.join_next().await {
|
||||
match download_video_result {
|
||||
Ok(i) => self.update_progress(|p| p.video_task.chunks[i].completed = true),
|
||||
Err(err) => {
|
||||
let err_title = format!("{ids_string} `{episode_title}`视频的一个分片下载失败");
|
||||
let string_chain = err.to_string_chain();
|
||||
tracing::error!(err_title, message = string_chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检查视频是否已下载完成
|
||||
let download_completed = self
|
||||
.progress
|
||||
.read()
|
||||
.video_task
|
||||
.chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.completed);
|
||||
if !download_completed {
|
||||
return Err(anyhow!(
|
||||
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
|
||||
"检查视频文件`{}`是否完整失败",
|
||||
temp_file_path.display()
|
||||
))?;
|
||||
|
||||
if !is_video_file_complete {
|
||||
self.update_progress(|p| p.video_task.mark_uncompleted());
|
||||
return Err(anyhow!(
|
||||
"视频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 重命名临时文件
|
||||
let mp4_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if mp4_path.exists() {
|
||||
std::fs::remove_file(&mp4_path)
|
||||
.context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?;
|
||||
}
|
||||
std::fs::rename(&temp_file_path, &mp4_path).context(format!(
|
||||
"将临时文件`{}`重命名为`{}`失败",
|
||||
temp_file_path.display(),
|
||||
mp4_path.display()
|
||||
))?;
|
||||
|
||||
self.update_progress(|p| p.video_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_audio(self: &Arc<Self>, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let temp_file_path = episode_dir.join(format!(
|
||||
"{filename}.m4a.com.lanyeeee.bilibili-video-downloader"
|
||||
));
|
||||
let (audio_task, episode_title, ids_string) = {
|
||||
let progress = self.progress.read();
|
||||
(
|
||||
progress.audio_task.clone(),
|
||||
progress.episode_title.clone(),
|
||||
progress.get_ids_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let file = if temp_file_path.exists() {
|
||||
// 如果文件已存在,则打开它
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&temp_file_path)?
|
||||
} else {
|
||||
// 如果文件不存在,创建它并预分配空间
|
||||
let file = File::create(&temp_file_path)?;
|
||||
file.allocate(audio_task.content_length)?;
|
||||
file
|
||||
};
|
||||
let file = Arc::new(Mutex::new(file));
|
||||
|
||||
let chunk_count = audio_task.chunks.len();
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() {
|
||||
if chunk.completed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (start, end) = (chunk.start, chunk.end);
|
||||
|
||||
let download_chunk_task = DownloadChunkTask {
|
||||
download_task: self.clone(),
|
||||
start,
|
||||
end,
|
||||
url: audio_task.url.to_string(),
|
||||
file: file.clone(),
|
||||
chunk_index,
|
||||
};
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_chunk_task.process().await.context(format!(
|
||||
"分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})"
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(Ok(download_video_result)) = join_set.join_next().await {
|
||||
match download_video_result {
|
||||
Ok(i) => self.update_progress(|p| p.audio_task.chunks[i].completed = true),
|
||||
Err(err) => {
|
||||
let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败");
|
||||
let string_chain = err.to_string_chain();
|
||||
tracing::error!(err_title, message = string_chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let download_completed = self
|
||||
.progress
|
||||
.read()
|
||||
.audio_task
|
||||
.chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.completed);
|
||||
if !download_completed {
|
||||
return Err(anyhow!(
|
||||
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
|
||||
"检查音频文件`{}`是否完整失败",
|
||||
temp_file_path.display()
|
||||
))?;
|
||||
|
||||
if !is_audio_file_complete {
|
||||
self.update_progress(|p| p.video_task.mark_uncompleted());
|
||||
return Err(anyhow!(
|
||||
"音频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 重命名临时文件
|
||||
let m4a_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if m4a_path.exists() {
|
||||
std::fs::remove_file(&m4a_path)
|
||||
.context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?;
|
||||
}
|
||||
std::fs::rename(&temp_file_path, &m4a_path).context(format!(
|
||||
"将临时文件`{}`重命名为`{}`失败",
|
||||
temp_file_path.display(),
|
||||
m4a_path.display()
|
||||
))?;
|
||||
|
||||
self.update_progress(|p| p.audio_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn merge_video_audio(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
self.update_progress(|p| p.merge_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if !audio_path.exists() {
|
||||
self.update_progress(|p| p.merge_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||
|
||||
let ffmpeg_program = std::env::current_exe()
|
||||
.context("获取当前可执行文件路径失败")?
|
||||
.parent()
|
||||
.context("获取当前可执行文件所在目录失败")?
|
||||
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let audio_path_clone = audio_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command
|
||||
.arg("-i")
|
||||
.arg(video_path_clone)
|
||||
.arg("-i")
|
||||
.arg(audio_path_clone)
|
||||
.arg("-c")
|
||||
.arg("copy")
|
||||
.arg("-map")
|
||||
.arg("0:v:0")
|
||||
.arg("-map")
|
||||
.arg("1:a:0")
|
||||
.arg(output_path_clone)
|
||||
.arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::remove_file(&audio_path)
|
||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
|
||||
self.update_progress(|p| p.merge_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration);
|
||||
let danmaku_task = &progress.danmaku_task;
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
let replies = bili_client
|
||||
.get_danmaku(aid, cid, duration)
|
||||
.await
|
||||
.context("获取弹幕失败")?;
|
||||
|
||||
let xml = replies.to_xml(cid).context("将弹幕转换为XML失败")?;
|
||||
|
||||
if danmaku_task.xml_selected {
|
||||
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
|
||||
std::fs::write(&xml_path, &xml)
|
||||
.context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
|
||||
}
|
||||
|
||||
if danmaku_task.ass_selected {
|
||||
let config = self.app.get_config().read().danmaku_config.clone();
|
||||
let ass_path = episode_dir.join(format!("{filename}.弹幕.ass"));
|
||||
let ass_file = File::create(&ass_path)
|
||||
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
|
||||
let title = filename.to_string();
|
||||
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
|
||||
}
|
||||
|
||||
if danmaku_task.json_selected {
|
||||
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
|
||||
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
|
||||
std::fs::write(&json_path, json_string)
|
||||
.context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
|
||||
}
|
||||
|
||||
self.update_progress(|p| p.danmaku_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_subtitle(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
use std::fmt::Write;
|
||||
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let (aid, cid) = {
|
||||
let progress = self.progress.read();
|
||||
(progress.aid, progress.cid)
|
||||
};
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
let player_info = bili_client
|
||||
.get_player_info(aid, cid)
|
||||
.await
|
||||
.context("获取播放器信息失败")?;
|
||||
|
||||
let subtitle = &player_info.subtitle;
|
||||
for subtitle_detail in &subtitle.subtitles {
|
||||
let url = format!("http:{}", subtitle_detail.subtitle_url);
|
||||
let subtitle = bili_client
|
||||
.get_subtitle(&url)
|
||||
.await
|
||||
.context("获取字幕失败")?;
|
||||
|
||||
let mut srt_content = String::new();
|
||||
for (i, b) in subtitle.body.iter().enumerate() {
|
||||
let index = i + 1;
|
||||
let content = &b.content;
|
||||
let start_time = utils::seconds_to_srt_time(b.from);
|
||||
let end_time = utils::seconds_to_srt_time(b.to);
|
||||
let _ = writeln!(
|
||||
&mut srt_content,
|
||||
"{index}\n{start_time} --> {end_time}\n{content}\n"
|
||||
);
|
||||
}
|
||||
|
||||
let lan = utils::filename_filter(&subtitle_detail.lan);
|
||||
let save_path = episode_dir.join(format!("{filename}.{lan}.srt"));
|
||||
std::fs::write(save_path, srt_content)?;
|
||||
}
|
||||
|
||||
self.update_progress(|p| p.subtitle_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_cover(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
let (cover_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(&progress.cover_task.url)
|
||||
.await
|
||||
.context("获取封面失败")?;
|
||||
|
||||
let save_path = episode_dir.join(format!("{filename}.{ext}"));
|
||||
std::fs::write(&save_path, cover_data)
|
||||
.context(format!("保存封面到`{}`失败", save_path.display()))?;
|
||||
|
||||
self.update_progress(|p| p.cover_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_nfo(
|
||||
&self,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
|
||||
let episode_info = episode_info
|
||||
.get_or_init(&bili_client, aid, ep_id, episode_type)
|
||||
.await?;
|
||||
|
||||
match episode_info {
|
||||
EpisodeInfo::Normal(info) => {
|
||||
let tags = bili_client
|
||||
.get_tags(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) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将番剧信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将番剧信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存番剧NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", poster_path.display()))?;
|
||||
|
||||
let fanart_url = &info.bkg_cover;
|
||||
if !fanart_url.is_empty() {
|
||||
let (fanart_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(fanart_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let fanart_path = episode_dir.join(format!("fanart.{ext}"));
|
||||
std::fs::write(&fanart_path, fanart_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?;
|
||||
}
|
||||
}
|
||||
EpisodeInfo::Cheese(info, ep_id) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将课程信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将课程信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存课程NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取课程封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存课程封面到`{}`失败", poster_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
self.update_progress(|p| p.nfo_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_json(
|
||||
&self,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
|
||||
let episode_info = episode_info
|
||||
.get_or_init(&bili_client, aid, ep_id, episode_type)
|
||||
.await?;
|
||||
|
||||
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
||||
let json_string = match episode_info {
|
||||
EpisodeInfo::Normal(info) => {
|
||||
serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")?
|
||||
}
|
||||
EpisodeInfo::Bangumi(info, _ep_id) => {
|
||||
serde_json::to_string(&info).context("将番剧信息转换为JSON失败")?
|
||||
}
|
||||
EpisodeInfo::Cheese(info, _ep_id) => {
|
||||
serde_json::to_string(&info).context("将课程信息转换为JSON失败")?
|
||||
}
|
||||
};
|
||||
std::fs::write(&json_path, json_string)
|
||||
.context(format!("保存JSON到`{}`失败", json_path.display()))?;
|
||||
|
||||
self.update_progress(|p| p.json_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sleep_between_task(&self) {
|
||||
let task_id = &self.task_id;
|
||||
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
||||
@@ -1019,7 +468,7 @@ impl DownloadTask {
|
||||
}
|
||||
}
|
||||
|
||||
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 mut progress = self.progress.write();
|
||||
@@ -1032,7 +481,7 @@ impl DownloadTask {
|
||||
}
|
||||
.emit(&self.app);
|
||||
|
||||
if let Err(err) = updated_progress.save(&self.app) {
|
||||
if let Err(err) = updated_progress.save(&self.app, false) {
|
||||
let ids_string = updated_progress.get_ids_string();
|
||||
let episode_title = &updated_progress.episode_title;
|
||||
let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败");
|
||||
@@ -1041,166 +490,3 @@ impl DownloadTask {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadChunkTask {
|
||||
download_task: Arc<DownloadTask>,
|
||||
start: u64,
|
||||
end: u64,
|
||||
url: String,
|
||||
file: Arc<Mutex<File>>,
|
||||
chunk_index: usize,
|
||||
}
|
||||
|
||||
impl DownloadChunkTask {
|
||||
async fn process(self) -> anyhow::Result<usize> {
|
||||
let download_chunk_task = self.download_chunk();
|
||||
tokio::pin!(download_chunk_task);
|
||||
|
||||
let mut state_receiver = self.download_task.state_sender.subscribe();
|
||||
state_receiver.mark_changed();
|
||||
|
||||
let mut restart_receiver = self.download_task.restart_sender.subscribe();
|
||||
let mut delete_receiver = self.download_task.delete_sender.subscribe();
|
||||
|
||||
let mut permit = None;
|
||||
|
||||
loop {
|
||||
let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading;
|
||||
tokio::select! {
|
||||
result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result,
|
||||
|
||||
result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => {
|
||||
match result {
|
||||
Ok(()) => {},
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
},
|
||||
|
||||
_ = state_receiver.changed() => {
|
||||
if *state_receiver.borrow() == DownloadTaskState::Paused {
|
||||
// 稍微等一下再释放permit
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
if let Some(permit) = permit.take() {
|
||||
drop(permit);
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_ = restart_receiver.changed() => break Ok(self.chunk_index),
|
||||
|
||||
_ = delete_receiver.changed() => break Ok(self.chunk_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_chunk(&self) -> anyhow::Result<usize> {
|
||||
let bili_client = self.download_task.app.get_bili_client();
|
||||
let chunk_data = bili_client
|
||||
.get_media_chunk(&self.url, self.start, self.end)
|
||||
.await?;
|
||||
|
||||
let len = chunk_data.len() as u64;
|
||||
self.download_task
|
||||
.app
|
||||
.get_download_manager()
|
||||
.byte_per_sec
|
||||
.fetch_add(len, std::sync::atomic::Ordering::Relaxed);
|
||||
// 将下载的内容写入文件
|
||||
{
|
||||
let mut file = self.file.lock();
|
||||
file.seek(std::io::SeekFrom::Start(self.start))?;
|
||||
file.write_all(&chunk_data)?;
|
||||
}
|
||||
|
||||
let chunk_download_interval_sec = self
|
||||
.download_task
|
||||
.app
|
||||
.get_config()
|
||||
.read()
|
||||
.chunk_download_interval_sec;
|
||||
sleep(Duration::from_secs(chunk_download_interval_sec)).await;
|
||||
|
||||
Ok(self.chunk_index)
|
||||
}
|
||||
|
||||
async fn acquire_chunk_permit<'a>(
|
||||
&'a self,
|
||||
permit: &mut Option<SemaphorePermit<'a>>,
|
||||
) -> anyhow::Result<()> {
|
||||
*permit = match permit.take() {
|
||||
// 如果有permit,则直接用
|
||||
Some(permit) => Some(permit),
|
||||
// 如果没有permit,则获取permit
|
||||
None => Some(
|
||||
self.download_task
|
||||
.app
|
||||
.get_download_manager()
|
||||
.inner()
|
||||
.media_chunk_sem
|
||||
.acquire()
|
||||
.await?,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum EpisodeInfo {
|
||||
Normal(NormalInfo),
|
||||
Bangumi(BangumiInfo, i64),
|
||||
Cheese(CheeseInfo, i64),
|
||||
}
|
||||
|
||||
trait GetOrInitEpisodeInfo {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
bili_client: &BiliClient,
|
||||
aid: i64,
|
||||
ep_id: Option<i64>,
|
||||
episode_type: EpisodeType,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
||||
}
|
||||
|
||||
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
bili_client: &BiliClient,
|
||||
aid: i64,
|
||||
ep_id: Option<i64>,
|
||||
episode_type: EpisodeType,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
||||
if let Some(info) = self {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
let new_info = match episode_type {
|
||||
EpisodeType::Normal => {
|
||||
let info = bili_client
|
||||
.get_normal_info(GetNormalInfoParams::Aid(aid))
|
||||
.await
|
||||
.context("获取普通视频信息失败")?;
|
||||
EpisodeInfo::Normal(info)
|
||||
}
|
||||
EpisodeType::Bangumi => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取番剧信息失败")?;
|
||||
EpisodeInfo::Bangumi(info, ep_id)
|
||||
}
|
||||
EpisodeType::Cheese => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取课程信息失败")?;
|
||||
EpisodeInfo::Cheese(info, ep_id)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(self.insert(new_info))
|
||||
}
|
||||
}
|
||||
|
||||
70
src-tauri/src/downloader/episode_info.rs
Normal file
70
src-tauri/src/downloader/episode_info.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use anyhow::Context;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
downloader::{download_progress::DownloadProgress, episode_type::EpisodeType},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo,
|
||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum EpisodeInfo {
|
||||
Normal(NormalInfo),
|
||||
Bangumi(BangumiInfo, i64),
|
||||
Cheese(CheeseInfo, i64),
|
||||
}
|
||||
|
||||
pub trait GetOrInitEpisodeInfo {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
app: &AppHandle,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
||||
}
|
||||
|
||||
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
app: &AppHandle,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
||||
if let Some(info) = self {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
let bili_client = app.get_bili_client();
|
||||
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
||||
|
||||
let new_info = match episode_type {
|
||||
EpisodeType::Normal => {
|
||||
let info = bili_client
|
||||
.get_normal_info(GetNormalInfoParams::Aid(aid))
|
||||
.await
|
||||
.context("获取普通视频信息失败")?;
|
||||
EpisodeInfo::Normal(info)
|
||||
}
|
||||
EpisodeType::Bangumi => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取番剧信息失败")?;
|
||||
EpisodeInfo::Bangumi(info, ep_id)
|
||||
}
|
||||
EpisodeType::Cheese => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取课程信息失败")?;
|
||||
EpisodeInfo::Cheese(info, ep_id)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(self.insert(new_info))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
pub mod chapter_segments;
|
||||
pub mod download_chunk_task;
|
||||
pub mod download_manager;
|
||||
pub mod download_progress;
|
||||
pub mod download_task;
|
||||
pub mod download_task_state;
|
||||
pub mod episode_info;
|
||||
pub mod episode_type;
|
||||
pub mod fmt_params;
|
||||
pub mod media_chunk;
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{File, OpenOptions},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{anyhow, Context};
|
||||
use fs4::fs_std::FileExt;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::AppHandle;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
downloader::media_chunk::MediaChunk,
|
||||
extensions::AppHandleExt,
|
||||
downloader::{
|
||||
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||
},
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
types::{
|
||||
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
||||
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
||||
},
|
||||
utils,
|
||||
};
|
||||
|
||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||
@@ -104,11 +114,7 @@ impl AudioTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取音频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -164,11 +170,7 @@ impl AudioTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取音频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -224,37 +226,28 @@ impl AudioTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取音频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) {
|
||||
medias.sort_by_key(|m| Reverse(m.id.to_audio_quality_for_prepare()));
|
||||
let best_quality_id = medias[0].id;
|
||||
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) -> anyhow::Result<()> {
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取音频地址失败"));
|
||||
}
|
||||
|
||||
let prefer_quality = app.get_config().read().prefer_audio_quality;
|
||||
let prefer_quality_id: i64 = prefer_quality.into();
|
||||
let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id);
|
||||
let quality_filtered_medias: Vec<MediaForPrepare> = if prefer_quality_found {
|
||||
// 如果用户指定质量存在,则使用用户指定的质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == prefer_quality_id)
|
||||
.collect()
|
||||
} else {
|
||||
// 否则使用最高质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == best_quality_id)
|
||||
.collect()
|
||||
};
|
||||
let quality_priority = app.get_config().read().audio_quality_priority.clone();
|
||||
let priority_map: HashMap<&AudioQuality, usize> = quality_priority
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, quality)| (quality, index))
|
||||
.collect();
|
||||
medias.sort_by_key(|media| {
|
||||
let quality: AudioQuality = media.id.into();
|
||||
priority_map.get(&quality).unwrap_or(&usize::MAX)
|
||||
});
|
||||
|
||||
let media = &quality_filtered_medias[0];
|
||||
let media = &medias[0];
|
||||
|
||||
self.audio_quality = media.id.into();
|
||||
|
||||
@@ -285,6 +278,8 @@ impl AudioTask {
|
||||
self.content_length = content_length;
|
||||
self.chunks = chunks;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_uncompleted(&mut self) {
|
||||
@@ -297,6 +292,119 @@ impl AudioTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let temp_file_path = episode_dir.join(format!(
|
||||
"{filename}.m4a.com.lanyeeee.bilibili-video-downloader"
|
||||
));
|
||||
let (audio_task, episode_title, ids_string) = {
|
||||
(
|
||||
progress.audio_task.clone(),
|
||||
progress.episode_title.clone(),
|
||||
progress.get_ids_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let file = if temp_file_path.exists() {
|
||||
// 如果文件已存在,则打开它
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&temp_file_path)?
|
||||
} else {
|
||||
// 如果文件不存在,创建它并预分配空间
|
||||
let file = File::create(&temp_file_path)?;
|
||||
file.allocate(audio_task.content_length)?;
|
||||
file
|
||||
};
|
||||
let file = Arc::new(Mutex::new(file));
|
||||
|
||||
let chunk_count = audio_task.chunks.len();
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() {
|
||||
if chunk.completed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (start, end) = (chunk.start, chunk.end);
|
||||
|
||||
let download_chunk_task = DownloadChunkTask {
|
||||
download_task: download_task.clone(),
|
||||
start,
|
||||
end,
|
||||
url: audio_task.url.to_string(),
|
||||
file: file.clone(),
|
||||
chunk_index,
|
||||
};
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_chunk_task.process().await.context(format!(
|
||||
"分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})"
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(Ok(download_video_result)) = join_set.join_next().await {
|
||||
match download_video_result {
|
||||
Ok(i) => download_task.update_progress(|p| p.audio_task.chunks[i].completed = true),
|
||||
Err(err) => {
|
||||
let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败");
|
||||
let string_chain = err.to_string_chain();
|
||||
tracing::error!(err_title, message = string_chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let download_completed = download_task
|
||||
.progress
|
||||
.read()
|
||||
.audio_task
|
||||
.chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.completed);
|
||||
if !download_completed {
|
||||
return Err(anyhow!(
|
||||
"音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
|
||||
"检查音频文件`{}`是否完整失败",
|
||||
temp_file_path.display()
|
||||
))?;
|
||||
|
||||
if !is_audio_file_complete {
|
||||
download_task.update_progress(|p| p.video_task.mark_uncompleted());
|
||||
return Err(anyhow!(
|
||||
"音频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 重命名临时文件
|
||||
let m4a_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if m4a_path.exists() {
|
||||
std::fs::remove_file(&m4a_path)
|
||||
.context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?;
|
||||
}
|
||||
std::fs::rename(&temp_file_path, &m4a_path).context(format!(
|
||||
"将临时文件`{}`重命名为`{}`失败",
|
||||
temp_file_path.display(),
|
||||
m4a_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.audio_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -304,30 +412,3 @@ struct MediaForPrepare {
|
||||
pub id: i64,
|
||||
pub url_with_content_length: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum AudioQualityForPrepare {
|
||||
Audio64K,
|
||||
Audio132K,
|
||||
Audio192K,
|
||||
AudioDolby,
|
||||
AudioHiRes,
|
||||
}
|
||||
|
||||
trait ToAudioQualityForPrepare {
|
||||
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare>;
|
||||
}
|
||||
|
||||
impl ToAudioQualityForPrepare for i64 {
|
||||
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare> {
|
||||
let audio_quality: AudioQuality = self.into();
|
||||
match audio_quality {
|
||||
AudioQuality::Audio64K => Some(AudioQualityForPrepare::Audio64K),
|
||||
AudioQuality::Audio132K => Some(AudioQualityForPrepare::Audio132K),
|
||||
AudioQuality::Audio192K => Some(AudioQualityForPrepare::Audio192K),
|
||||
AudioQuality::AudioDolby => Some(AudioQualityForPrepare::AudioDolby),
|
||||
AudioQuality::AudioHiRes => Some(AudioQualityForPrepare::AudioHiRes),
|
||||
AudioQuality::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::{
|
||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||
extensions::AppHandleExt,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct CoverTask {
|
||||
pub selected: bool,
|
||||
@@ -12,4 +20,26 @@ impl CoverTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let bili_client = download_task.app.get_bili_client();
|
||||
let (cover_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(&progress.cover_task.url)
|
||||
.await
|
||||
.context("获取封面失败")?;
|
||||
|
||||
let save_path = episode_dir.join(format!("{filename}.{ext}"));
|
||||
std::fs::write(&save_path, cover_data)
|
||||
.context(format!("保存封面到`{}`失败", save_path.display()))?;
|
||||
|
||||
download_task.update_progress(|p| p.cover_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
use std::{fs::File, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::{
|
||||
danmaku_xml_to_ass::xml_to_ass,
|
||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||
extensions::AppHandleExt,
|
||||
utils::ToXml,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct DanmakuTask {
|
||||
@@ -14,4 +24,49 @@ impl DanmakuTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let danmaku_task = &progress.danmaku_task;
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let bili_client = download_task.app.get_bili_client();
|
||||
let replies = bili_client
|
||||
.get_danmaku(progress.aid, progress.cid, progress.duration)
|
||||
.await
|
||||
.context("获取弹幕失败")?;
|
||||
|
||||
let xml = replies
|
||||
.to_xml(progress.cid)
|
||||
.context("将弹幕转换为XML失败")?;
|
||||
|
||||
if danmaku_task.xml_selected {
|
||||
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
|
||||
std::fs::write(&xml_path, &xml)
|
||||
.context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
|
||||
}
|
||||
|
||||
if danmaku_task.ass_selected {
|
||||
let config = download_task.app.get_config().read().danmaku_config.clone();
|
||||
let ass_path = episode_dir.join(format!("{filename}.弹幕.ass"));
|
||||
let ass_file = File::create(&ass_path)
|
||||
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
|
||||
let title = filename.to_string();
|
||||
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
|
||||
}
|
||||
|
||||
if danmaku_task.json_selected {
|
||||
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
|
||||
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
|
||||
std::fs::write(&json_path, json_string)
|
||||
.context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
|
||||
}
|
||||
|
||||
download_task.update_progress(|p| p.danmaku_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::downloader::{
|
||||
download_progress::DownloadProgress,
|
||||
download_task::DownloadTask,
|
||||
episode_info::{EpisodeInfo, GetOrInitEpisodeInfo},
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct JsonTask {
|
||||
pub selected: bool,
|
||||
@@ -11,4 +20,36 @@ impl JsonTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let episode_info = episode_info
|
||||
.get_or_init(&download_task.app, progress)
|
||||
.await?;
|
||||
|
||||
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
||||
let json_string = match episode_info {
|
||||
EpisodeInfo::Normal(info) => {
|
||||
serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")?
|
||||
}
|
||||
EpisodeInfo::Bangumi(info, _ep_id) => {
|
||||
serde_json::to_string(&info).context("将番剧信息转换为JSON失败")?
|
||||
}
|
||||
EpisodeInfo::Cheese(info, _ep_id) => {
|
||||
serde_json::to_string(&info).context("将课程信息转换为JSON失败")?
|
||||
}
|
||||
};
|
||||
std::fs::write(&json_path, json_string)
|
||||
.context(format!("保存JSON到`{}`失败", json_path.display()))?;
|
||||
|
||||
download_task.update_progress(|p| p.json_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct MergeTask {
|
||||
pub selected: bool,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
impl MergeTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ pub mod audio_task;
|
||||
pub mod cover_task;
|
||||
pub mod danmaku_task;
|
||||
pub mod json_task;
|
||||
pub mod merge_task;
|
||||
pub mod nfo_task;
|
||||
pub mod subtitle_task;
|
||||
pub mod video_process_task;
|
||||
pub mod video_task;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use yaserde::{YaDeserialize, YaSerialize};
|
||||
|
||||
use crate::types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
|
||||
use crate::{
|
||||
downloader::{
|
||||
download_progress::DownloadProgress,
|
||||
download_task::DownloadTask,
|
||||
episode_info::{EpisodeInfo, GetOrInitEpisodeInfo},
|
||||
},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -18,6 +28,116 @@ impl NfoTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let episode_info = episode_info
|
||||
.get_or_init(&download_task.app, progress)
|
||||
.await?;
|
||||
|
||||
let bili_client = download_task.app.get_bili_client();
|
||||
|
||||
match episode_info {
|
||||
EpisodeInfo::Normal(info) => {
|
||||
let tags = bili_client
|
||||
.get_tags(progress.aid)
|
||||
.await
|
||||
.context("获取视频标签失败")?;
|
||||
let movie_nfo = info
|
||||
.to_movie_nfo(tags)
|
||||
.context("将普通视频信息转换为movie NFO失败")?;
|
||||
let nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&nfo_path, movie_nfo)
|
||||
.context(format!("保存普通视频NFO到`{}`失败", nfo_path.display()))?;
|
||||
|
||||
if let Some(ugc_season) = &info.ugc_season {
|
||||
let collection_cover = &ugc_season.cover;
|
||||
let (cover_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(collection_cover)
|
||||
.await
|
||||
.context("获取普通视频合集封面失败")?;
|
||||
let cover_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&cover_path, cover_data).context(format!(
|
||||
"保存普通视频合集封面到`{}`失败",
|
||||
cover_path.display()
|
||||
))?;
|
||||
}
|
||||
}
|
||||
EpisodeInfo::Bangumi(info, ep_id) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将番剧信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将番剧信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存番剧NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", poster_path.display()))?;
|
||||
|
||||
let fanart_url = &info.bkg_cover;
|
||||
if !fanart_url.is_empty() {
|
||||
let (fanart_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(fanart_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let fanart_path = episode_dir.join(format!("fanart.{ext}"));
|
||||
std::fs::write(&fanart_path, fanart_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?;
|
||||
}
|
||||
}
|
||||
EpisodeInfo::Cheese(info, ep_id) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将课程信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将课程信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存课程NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取课程封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存课程封面到`{}`失败", poster_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
download_task.update_progress(|p| p.nfo_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::{
|
||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||
extensions::{AppHandleExt, GetOrInitPlayerInfo},
|
||||
types::player_info::PlayerInfo,
|
||||
utils,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct SubtitleTask {
|
||||
pub selected: bool,
|
||||
@@ -11,4 +21,49 @@ impl SubtitleTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::fmt::Write;
|
||||
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let player_info = player_info
|
||||
.get_or_init(&download_task.app, progress)
|
||||
.await?;
|
||||
|
||||
let bili_client = download_task.app.get_bili_client();
|
||||
|
||||
for subtitle_detail in &player_info.subtitle.subtitles {
|
||||
let url = format!("http:{}", subtitle_detail.subtitle_url);
|
||||
let subtitle = bili_client
|
||||
.get_subtitle(&url)
|
||||
.await
|
||||
.context("获取字幕失败")?;
|
||||
|
||||
let mut srt_content = String::new();
|
||||
for (i, b) in subtitle.body.iter().enumerate() {
|
||||
let index = i + 1;
|
||||
let content = &b.content;
|
||||
let start_time = utils::seconds_to_srt_time(b.from);
|
||||
let end_time = utils::seconds_to_srt_time(b.to);
|
||||
let _ = writeln!(
|
||||
&mut srt_content,
|
||||
"{index}\n{start_time} --> {end_time}\n{content}\n"
|
||||
);
|
||||
}
|
||||
|
||||
let lan = utils::filename_filter(&subtitle_detail.lan);
|
||||
let save_path = episode_dir.join(format!("{filename}.{lan}.srt"));
|
||||
std::fs::write(save_path, srt_content)?;
|
||||
}
|
||||
|
||||
download_task.update_progress(|p| p.subtitle_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
369
src-tauri/src/downloader/tasks/video_process_task.rs
Normal file
369
src-tauri/src/downloader/tasks/video_process_task.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
downloader::{
|
||||
chapter_segments::{ChapterSegment, ChapterSegments},
|
||||
download_progress::DownloadProgress,
|
||||
download_task::DownloadTask,
|
||||
},
|
||||
extensions::{AppHandleExt, GetOrInitPlayerInfo},
|
||||
types::player_info::PlayerInfo,
|
||||
utils,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct VideoProcessTask {
|
||||
pub merge_selected: bool,
|
||||
pub embed_chapter_selected: bool,
|
||||
pub embed_skip_selected: bool,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
impl VideoProcessTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected
|
||||
|| self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let embed_selected = self.embed_chapter_selected || self.embed_skip_selected;
|
||||
|
||||
if self.merge_selected && embed_selected {
|
||||
self.merge_and_embed(download_task, progress, player_info)
|
||||
.await
|
||||
.context("自动合并+嵌入章节元数据失败")?;
|
||||
} else if self.merge_selected {
|
||||
println!("merge1");
|
||||
self.merge(download_task, progress)
|
||||
.await
|
||||
.context("自动合并失败")?;
|
||||
} else if embed_selected {
|
||||
self.embed(download_task, progress, player_info)
|
||||
.await
|
||||
.context("嵌入章节元数据失败")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn merge_and_embed(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if !audio_path.exists() {
|
||||
// 如果音频文件不存在,则只嵌入章节元数据
|
||||
self.embed(download_task, progress, player_info)
|
||||
.await
|
||||
.context("嵌入章节元数据失败")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let metadata_path = self
|
||||
.create_chapter_metadata(&download_task.app, progress, player_info)
|
||||
.await
|
||||
.context("创建章节元数据失败")?;
|
||||
|
||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let audio_path_clone = audio_path.clone();
|
||||
let metadata_path_clone = metadata_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command.arg("-i").arg(video_path_clone);
|
||||
command.arg("-i").arg(audio_path_clone);
|
||||
if let Some(metadata_path) = metadata_path_clone {
|
||||
command.arg("-i").arg(metadata_path);
|
||||
command.arg("-map_metadata").arg("2");
|
||||
}
|
||||
|
||||
command.arg("-c").arg("copy");
|
||||
command.arg("-map").arg("0:v:0");
|
||||
command.arg("-map").arg("1:a:0");
|
||||
|
||||
command.arg(output_path_clone).arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::remove_file(&audio_path)
|
||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
|
||||
if let Some(metadata_path) = metadata_path {
|
||||
std::fs::remove_file(&metadata_path).context(format!(
|
||||
"删除章节元数据文件`{}`失败",
|
||||
metadata_path.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn merge(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if !audio_path.exists() {
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||
|
||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let audio_path_clone = audio_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command.arg("-i").arg(video_path_clone);
|
||||
command.arg("-i").arg(audio_path_clone);
|
||||
|
||||
command.arg("-c").arg("copy");
|
||||
command.arg("-map").arg("0:v:0");
|
||||
command.arg("-map").arg("1:a:0");
|
||||
|
||||
command.arg(output_path_clone).arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::remove_file(&audio_path)
|
||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
|
||||
|
||||
let metadata_path = self
|
||||
.create_chapter_metadata(&download_task.app, progress, player_info)
|
||||
.await
|
||||
.context("创建章节元数据失败")?;
|
||||
|
||||
let Some(metadata_path) = metadata_path else {
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let metadata_path_clone = metadata_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command.arg("-i").arg(video_path_clone);
|
||||
command.arg("-i").arg(metadata_path_clone);
|
||||
|
||||
command.arg("-map_metadata").arg("1");
|
||||
command.arg("-c").arg("copy");
|
||||
|
||||
command.arg(output_path_clone).arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
std::fs::remove_file(&metadata_path).context(format!(
|
||||
"删除章节元数据文件`{}`失败",
|
||||
metadata_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_chapter_metadata(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
let mut chapter_segments = ChapterSegments {
|
||||
segments: Vec::new(),
|
||||
};
|
||||
|
||||
if self.embed_chapter_selected {
|
||||
let player_info = player_info.get_or_init(app, progress).await?;
|
||||
let segments = player_info
|
||||
.view_points
|
||||
.iter()
|
||||
.map(|vp| ChapterSegment {
|
||||
title: vp.content.clone(),
|
||||
start: vp.from,
|
||||
end: vp.to,
|
||||
})
|
||||
.collect();
|
||||
chapter_segments = ChapterSegments { segments };
|
||||
}
|
||||
|
||||
if let (true, Some(bvid)) = (self.embed_skip_selected, &progress.bvid) {
|
||||
let bili_client = app.get_bili_client();
|
||||
let cid = Some(progress.cid);
|
||||
|
||||
let skip_segments = bili_client.get_skip_segments(bvid, cid).await?;
|
||||
for segment in skip_segments.0 {
|
||||
if let Some(chapter_segment) = segment.into_chapter_segment() {
|
||||
chapter_segments.insert(chapter_segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chapter_segments.segments.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||
std::fs::write(&metadata_path, metadata_content)
|
||||
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||
|
||||
Ok(Some(metadata_path))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{File, OpenOptions},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{anyhow, Context};
|
||||
use fs4::fs_std::FileExt;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::AppHandle;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
downloader::media_chunk::MediaChunk,
|
||||
extensions::AppHandleExt,
|
||||
downloader::{
|
||||
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||
},
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
types::{
|
||||
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
|
||||
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
|
||||
},
|
||||
utils,
|
||||
};
|
||||
|
||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||
@@ -64,11 +74,7 @@ impl VideoTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取视频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -134,11 +140,7 @@ impl VideoTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取视频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -204,47 +206,49 @@ impl VideoTask {
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取视频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
self.prepare(app, medias)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) {
|
||||
medias.sort_by_key(|m| Reverse(m.id));
|
||||
let best_quality_id = medias[0].id;
|
||||
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) -> anyhow::Result<()> {
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取音频地址失败"));
|
||||
}
|
||||
|
||||
let (prefer_quality, prefer_codec_type) = {
|
||||
let (video_quality_priority, codec_type_priority) = {
|
||||
let config = app.get_config().inner().read();
|
||||
(config.prefer_video_quality, config.prefer_codec_type)
|
||||
(
|
||||
config.video_quality_priority.clone(),
|
||||
config.codec_type_priority.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let prefer_quality_id: i64 = prefer_quality.into();
|
||||
let prefer_codec_id: i64 = prefer_codec_type.into();
|
||||
let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id);
|
||||
let mut quality_filtered_medias: Vec<MediaForPrepare> = if prefer_quality_found {
|
||||
// 如果用户指定质量存在,则使用用户指定的质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == prefer_quality_id)
|
||||
.collect()
|
||||
} else {
|
||||
// 否则使用最高质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == best_quality_id)
|
||||
.collect()
|
||||
};
|
||||
// 按照 AVC > HEVC > AV1 的顺序排列
|
||||
quality_filtered_medias.sort_by_key(|m| m.codecid);
|
||||
|
||||
let media = quality_filtered_medias
|
||||
let video_priority_map: HashMap<&VideoQuality, usize> = video_quality_priority
|
||||
.iter()
|
||||
.find(|m| m.codecid == prefer_codec_id)
|
||||
.unwrap_or(&quality_filtered_medias[0]);
|
||||
.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.codec_type = media.codecid.into();
|
||||
@@ -276,6 +280,8 @@ impl VideoTask {
|
||||
self.content_length = content_length;
|
||||
self.chunks = chunks;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_uncompleted(&mut self) {
|
||||
@@ -288,6 +294,123 @@ impl VideoTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let temp_file_path = episode_dir.join(format!(
|
||||
"{filename}.mp4.com.lanyeeee.bilibili-video-downloader"
|
||||
));
|
||||
|
||||
let (video_task, episode_title, ids_string) = {
|
||||
let progress = download_task.progress.read();
|
||||
(
|
||||
progress.video_task.clone(),
|
||||
progress.episode_title.clone(),
|
||||
progress.get_ids_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let file = if temp_file_path.exists() {
|
||||
// 如果临时文件已存在,则打开它
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&temp_file_path)?
|
||||
} else {
|
||||
// 如果临时文件不存在,创建它并预分配空间
|
||||
let file = File::create(&temp_file_path)?;
|
||||
file.allocate(video_task.content_length)?;
|
||||
file
|
||||
};
|
||||
let file = Arc::new(Mutex::new(file));
|
||||
|
||||
let chunk_count = video_task.chunks.len();
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
for (i, chunk) in video_task.chunks.iter().enumerate() {
|
||||
if chunk.completed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (start, end) = (chunk.start, chunk.end);
|
||||
|
||||
let download_chunk_task = DownloadChunkTask {
|
||||
download_task: download_task.clone(),
|
||||
start,
|
||||
end,
|
||||
url: video_task.url.to_string(),
|
||||
file: file.clone(),
|
||||
chunk_index: i,
|
||||
};
|
||||
|
||||
let chunk_order = i + 1;
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_chunk_task.process().await.context(format!(
|
||||
"分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})"
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(Ok(download_video_result)) = join_set.join_next().await {
|
||||
match download_video_result {
|
||||
Ok(i) => download_task.update_progress(|p| p.video_task.chunks[i].completed = true),
|
||||
Err(err) => {
|
||||
let err_title = format!("{ids_string} `{episode_title}`视频的一个分片下载失败");
|
||||
let string_chain = err.to_string_chain();
|
||||
tracing::error!(err_title, message = string_chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检查视频是否已下载完成
|
||||
let download_completed = download_task
|
||||
.progress
|
||||
.read()
|
||||
.video_task
|
||||
.chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.completed);
|
||||
if !download_completed {
|
||||
return Err(anyhow!(
|
||||
"视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!(
|
||||
"检查视频文件`{}`是否完整失败",
|
||||
temp_file_path.display()
|
||||
))?;
|
||||
|
||||
if !is_video_file_complete {
|
||||
download_task.update_progress(|p| p.video_task.mark_uncompleted());
|
||||
return Err(anyhow!(
|
||||
"视频文件`{}`不完整,[继续]会重新下载所有分片",
|
||||
temp_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 重命名临时文件
|
||||
let mp4_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if mp4_path.exists() {
|
||||
std::fs::remove_file(&mp4_path)
|
||||
.context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?;
|
||||
}
|
||||
std::fs::rename(&temp_file_path, &mp4_path).context(format!(
|
||||
"将临时文件`{}`重命名为`{}`失败",
|
||||
temp_file_path.display(),
|
||||
mp4_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.video_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaForPrepare {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use anyhow::Context;
|
||||
use parking_lot::RwLock;
|
||||
use tauri::{Manager, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::{
|
||||
bili_client::BiliClient, config::Config, downloader::download_manager::DownloadManager,
|
||||
bili_client::BiliClient,
|
||||
config::Config,
|
||||
downloader::{download_manager::DownloadManager, download_progress::DownloadProgress},
|
||||
types::player_info::PlayerInfo,
|
||||
};
|
||||
|
||||
pub trait AnyhowErrorToStringChain {
|
||||
@@ -43,3 +47,31 @@ impl AppHandleExt for tauri::AppHandle {
|
||||
self.state::<DownloadManager>()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait GetOrInitPlayerInfo {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
app: &AppHandle,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<&'a mut PlayerInfo>;
|
||||
}
|
||||
|
||||
impl GetOrInitPlayerInfo for Option<PlayerInfo> {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
app: &AppHandle,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<&'a mut PlayerInfo> {
|
||||
if let Some(info) = self {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
let bili_client = app.get_bili_client();
|
||||
let info = bili_client
|
||||
.get_player_info(progress.aid, progress.cid)
|
||||
.await
|
||||
.context("获取播放器信息失败")?;
|
||||
|
||||
Ok(self.insert(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +40,13 @@ pub fn run() {
|
||||
get_qrcode_status,
|
||||
get_user_info,
|
||||
get_normal_info,
|
||||
get_bangumi_info,
|
||||
get_user_video_info,
|
||||
get_fav_folders,
|
||||
get_fav_info,
|
||||
get_watch_later_info,
|
||||
get_bangumi_follow_info,
|
||||
get_history_info,
|
||||
create_download_tasks,
|
||||
pause_download_tasks,
|
||||
resume_download_tasks,
|
||||
@@ -53,6 +56,7 @@ pub fn run() {
|
||||
search,
|
||||
get_logs_dir_size,
|
||||
show_path_in_file_manager,
|
||||
get_skip_segments,
|
||||
])
|
||||
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]);
|
||||
|
||||
@@ -67,6 +71,10 @@ pub fn run() {
|
||||
)
|
||||
.expect("Failed to export typescript bindings");
|
||||
|
||||
// 解决Ubuntu24.04窗口全白的问题
|
||||
#[cfg(target_os = "linux")]
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
|
||||
@@ -7,6 +7,8 @@ use specta::Type;
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Hash,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
|
||||
209
src-tauri/src/types/bangumi_follow_info.rs
Normal file
209
src-tauri/src/types/bangumi_follow_info.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct BangumiFollowInfo {
|
||||
pub list: Vec<EpInBangumiFollow>,
|
||||
pub pn: i64,
|
||||
pub ps: i64,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct EpInBangumiFollow {
|
||||
pub season_id: i64,
|
||||
pub media_id: i64,
|
||||
pub season_type: i64,
|
||||
pub season_type_name: String,
|
||||
pub title: String,
|
||||
pub cover: String,
|
||||
pub total_count: i64,
|
||||
pub is_finish: i64,
|
||||
pub is_started: i64,
|
||||
pub is_play: i64,
|
||||
pub badge: String,
|
||||
pub badge_type: i64,
|
||||
pub rights: RightsInBangumiFollow,
|
||||
pub stat: StatInBangumiFollow,
|
||||
pub new_ep: NewEpInBangumiFollow,
|
||||
pub rating: Option<RatingInBangumiFollow>,
|
||||
pub square_cover: String,
|
||||
pub season_status: i64,
|
||||
pub season_title: String,
|
||||
pub badge_ep: String,
|
||||
pub media_attr: i64,
|
||||
pub season_attr: i64,
|
||||
pub evaluate: String,
|
||||
pub areas: Vec<AreaInBangumiFollow>,
|
||||
pub subtitle: String,
|
||||
pub first_ep: i64,
|
||||
pub can_watch: i64,
|
||||
pub release_date_show: Option<String>,
|
||||
pub series: SeriesInBangumiFollow,
|
||||
pub publish: PublishInBangumiFollow,
|
||||
pub mode: i64,
|
||||
pub section: Vec<SectionInBangumiFollow>,
|
||||
pub url: String,
|
||||
pub badge_info: BadgeInfoInBangumiFollow,
|
||||
pub renewal_time: Option<String>,
|
||||
pub first_ep_info: FirstEpInfo,
|
||||
pub formal_ep_count: Option<i64>,
|
||||
pub short_url: String,
|
||||
pub badge_infos: Option<BadgeInfos>,
|
||||
pub season_version: Option<String>,
|
||||
pub horizontal_cover_16_9: Option<String>,
|
||||
pub horizontal_cover_16_10: Option<String>,
|
||||
pub subtitle_14: Option<String>,
|
||||
pub viewable_crowd_type: i64,
|
||||
#[serde(default)]
|
||||
pub producers: Vec<Producer>,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub styles: Vec<String>,
|
||||
pub follow_status: i64,
|
||||
pub is_new: i64,
|
||||
pub progress: String,
|
||||
pub both_follow: bool,
|
||||
pub subtitle_25: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct RightsInBangumiFollow {
|
||||
pub allow_review: Option<i64>,
|
||||
pub allow_preview: Option<i64>,
|
||||
pub is_selection: i64,
|
||||
pub selection_style: i64,
|
||||
pub is_rcmd: Option<i64>,
|
||||
pub allow_bp_rank: Option<i64>,
|
||||
pub allow_bp: Option<i64>,
|
||||
pub allow_download: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct StatInBangumiFollow {
|
||||
pub follow: i64,
|
||||
pub view: i64,
|
||||
pub danmaku: i64,
|
||||
pub reply: i64,
|
||||
pub coin: i64,
|
||||
pub series_follow: Option<i64>,
|
||||
pub series_view: Option<i64>,
|
||||
pub likes: i64,
|
||||
pub favorite: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct NewEpInBangumiFollow {
|
||||
pub id: Option<i64>,
|
||||
pub index_show: Option<String>,
|
||||
pub cover: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub long_title: Option<String>,
|
||||
pub pub_time: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct RatingInBangumiFollow {
|
||||
pub score: f64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct AreaInBangumiFollow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct SeriesInBangumiFollow {
|
||||
pub series_id: Option<i64>,
|
||||
pub title: Option<String>,
|
||||
pub season_count: Option<i64>,
|
||||
pub new_season_id: Option<i64>,
|
||||
pub series_ord: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct PublishInBangumiFollow {
|
||||
pub pub_time: String,
|
||||
pub pub_time_show: String,
|
||||
pub release_date: String,
|
||||
pub release_date_show: String,
|
||||
pub pub_time_show_db: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct SectionInBangumiFollow {
|
||||
pub section_id: i64,
|
||||
pub season_id: i64,
|
||||
pub limit_group: i64,
|
||||
pub watch_platform: i64,
|
||||
pub copyright: String,
|
||||
pub ban_area_show: i64,
|
||||
pub episode_ids: Vec<i64>,
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: Option<i64>,
|
||||
pub title: Option<String>,
|
||||
pub attr: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct BadgeInfoInBangumiFollow {
|
||||
pub text: Option<String>,
|
||||
pub bg_color: String,
|
||||
pub bg_color_night: String,
|
||||
pub img: Option<String>,
|
||||
pub multi_img: MultiImg,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct MultiImg {
|
||||
pub color: String,
|
||||
pub medium_remind: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct FirstEpInfo {
|
||||
pub id: i64,
|
||||
pub cover: String,
|
||||
pub title: String,
|
||||
pub long_title: Option<String>,
|
||||
pub pub_time: String,
|
||||
pub duration: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct BadgeInfos {
|
||||
pub vip_or_pay: Option<VipOrPay>,
|
||||
pub content_attr: Option<ContentAttr>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct VipOrPay {
|
||||
pub text: String,
|
||||
pub bg_color: String,
|
||||
pub bg_color_night: String,
|
||||
pub img: String,
|
||||
pub multi_img: MultiImg,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct ContentAttr {
|
||||
pub text: String,
|
||||
pub bg_color: String,
|
||||
pub bg_color_night: String,
|
||||
pub img: String,
|
||||
pub multi_img: MultiImg,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct Producer {
|
||||
pub mid: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: i64,
|
||||
pub is_contribute: Option<i64>,
|
||||
pub title: String,
|
||||
}
|
||||
@@ -3,11 +3,12 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct BangumiInfo {
|
||||
pub activity: Activity,
|
||||
pub actors: String,
|
||||
pub alias: String,
|
||||
pub areas: Vec<Area>,
|
||||
pub areas: Vec<AreaInBangumi>,
|
||||
pub bkg_cover: String,
|
||||
pub cover: String,
|
||||
pub delivery_fragment_video: bool,
|
||||
@@ -24,15 +25,15 @@ pub struct BangumiInfo {
|
||||
pub payment: Option<PaymentInBangumi>,
|
||||
pub play_strategy: Option<PlayStrategy>,
|
||||
pub positive: Positive,
|
||||
pub publish: Publish,
|
||||
pub rating: Option<Rating>,
|
||||
pub publish: PublishInBangumi,
|
||||
pub rating: Option<RatingInBangumi>,
|
||||
pub record: String,
|
||||
pub rights: RightsInBangumi,
|
||||
pub season_id: i64,
|
||||
pub season_title: String,
|
||||
pub seasons: Vec<Season>,
|
||||
pub section: Option<Vec<SectionInBangumi>>,
|
||||
pub series: Series,
|
||||
pub series: SeriesInBangumi,
|
||||
pub share_copy: String,
|
||||
pub share_sub_title: String,
|
||||
pub share_url: String,
|
||||
@@ -88,6 +89,7 @@ impl BangumiInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Activity {
|
||||
pub head_bg_url: String,
|
||||
pub id: i64,
|
||||
@@ -95,17 +97,19 @@ pub struct Activity {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct Area {
|
||||
#[serde(default)]
|
||||
pub struct AreaInBangumi {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct EpInBangumi {
|
||||
pub aid: i64,
|
||||
pub badge: String,
|
||||
pub badge_info: BadgeInfo,
|
||||
pub badge_info: BadgeInfoInBangumi,
|
||||
pub badge_type: Option<i64>,
|
||||
pub bvid: Option<String>,
|
||||
pub cid: i64,
|
||||
@@ -140,13 +144,15 @@ pub struct EpInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct BadgeInfo {
|
||||
#[serde(default)]
|
||||
pub struct BadgeInfoInBangumi {
|
||||
pub bg_color: String,
|
||||
pub bg_color_night: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DimensionInBangumi {
|
||||
pub height: i64,
|
||||
pub rotate: i64,
|
||||
@@ -154,6 +160,7 @@ pub struct DimensionInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RightsInBangumiEp {
|
||||
pub allow_dm: i64,
|
||||
pub allow_download: i64,
|
||||
@@ -161,30 +168,35 @@ pub struct RightsInBangumiEp {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Skip {
|
||||
pub ed: Ed,
|
||||
pub op: Op,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Ed {
|
||||
pub end: i64,
|
||||
pub start: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Op {
|
||||
pub end: i64,
|
||||
pub start: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct IconFont {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct NewEp {
|
||||
pub desc: String,
|
||||
pub id: i64,
|
||||
@@ -193,6 +205,7 @@ pub struct NewEp {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PaymentInBangumi {
|
||||
pub discount: i64,
|
||||
pub pay_type: PayType,
|
||||
@@ -207,6 +220,7 @@ pub struct PaymentInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PayType {
|
||||
pub allow_discount: i64,
|
||||
pub allow_pack: i64,
|
||||
@@ -217,18 +231,21 @@ pub struct PayType {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PlayStrategy {
|
||||
pub strategies: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Positive {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct Publish {
|
||||
#[serde(default)]
|
||||
pub struct PublishInBangumi {
|
||||
pub is_finish: i64,
|
||||
pub is_started: i64,
|
||||
pub pub_time: String,
|
||||
@@ -238,12 +255,14 @@ pub struct Publish {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct Rating {
|
||||
#[serde(default)]
|
||||
pub struct RatingInBangumi {
|
||||
pub count: i64,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RightsInBangumi {
|
||||
pub allow_bp: i64,
|
||||
pub allow_bp_rank: i64,
|
||||
@@ -263,10 +282,11 @@ pub struct RightsInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Season {
|
||||
pub badge: String,
|
||||
pub badge_info: BadgeInfo,
|
||||
pub badge_info: BadgeInfoInBangumi,
|
||||
pub badge_type: i64,
|
||||
pub cover: String,
|
||||
pub enable_vt: bool,
|
||||
@@ -282,6 +302,7 @@ pub struct Season {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct NewEpInSeason {
|
||||
pub cover: String,
|
||||
pub id: i64,
|
||||
@@ -289,6 +310,7 @@ pub struct NewEpInSeason {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInSeason {
|
||||
pub favorites: i64,
|
||||
pub series_follow: i64,
|
||||
@@ -297,19 +319,22 @@ pub struct StatInSeason {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Series {
|
||||
pub struct SeriesInBangumi {
|
||||
pub display_type: i64,
|
||||
pub series_id: i64,
|
||||
pub series_title: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Show {
|
||||
pub wide_screen: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInBangumi {
|
||||
pub coins: i64,
|
||||
pub danmakus: i64,
|
||||
@@ -324,6 +349,7 @@ pub struct StatInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UpInfoInBangumi {
|
||||
pub avatar: String,
|
||||
pub mid: i64,
|
||||
@@ -331,6 +357,7 @@ pub struct UpInfoInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserStatusInBangumi {
|
||||
pub area_limit: i64,
|
||||
pub ban_area_show: i64,
|
||||
@@ -343,6 +370,7 @@ pub struct UserStatusInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SectionInBangumi {
|
||||
pub attr: i64,
|
||||
pub episodes: Vec<EpInBangumi>,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct BangumiMediaUrl {
|
||||
pub accept_format: String,
|
||||
pub code: i64,
|
||||
@@ -38,12 +39,14 @@ pub struct BangumiMediaUrl {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RecordInfo {
|
||||
pub record_icon: String,
|
||||
pub record: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SupportFormatInBangumi {
|
||||
pub display_desc: String,
|
||||
pub has_preview: bool,
|
||||
@@ -60,6 +63,7 @@ pub struct SupportFormatInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DashInBangumi {
|
||||
pub duration: u64,
|
||||
pub min_buffer_time: f64,
|
||||
@@ -68,6 +72,7 @@ pub struct DashInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct MediaInBangumi {
|
||||
pub start_with_sap: i64,
|
||||
pub bandwidth: i64,
|
||||
@@ -87,12 +92,14 @@ pub struct MediaInBangumi {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SegmentBaseInBangumi {
|
||||
pub initialization: String,
|
||||
pub index_range: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct ClipInfoList {
|
||||
#[serde(rename = "materialNo")]
|
||||
pub material_no: i64,
|
||||
@@ -105,12 +112,14 @@ pub struct ClipInfoList {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DurlInBangumi {
|
||||
pub durl: Vec<DurlDetailInBangumi>,
|
||||
pub quality: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DurlDetailInBangumi {
|
||||
pub size: i64,
|
||||
pub ahead: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct CheeseInfo {
|
||||
pub abtest_info: AbtestInfo,
|
||||
@@ -54,11 +55,13 @@ pub struct CheeseInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct AbtestInfo {
|
||||
pub style_abtest: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Brief {
|
||||
pub content: String,
|
||||
pub img: Vec<Img>,
|
||||
@@ -68,23 +71,27 @@ pub struct Brief {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Img {
|
||||
pub aspect_ratio: f64,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Consulting {
|
||||
pub consulting_flag: bool,
|
||||
pub consulting_url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Cooperation {
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct EpPage {
|
||||
pub next: bool,
|
||||
pub num: i64,
|
||||
@@ -93,6 +100,7 @@ pub struct EpPage {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct EpTag {
|
||||
pub part_preview_tag: String,
|
||||
@@ -101,6 +109,7 @@ pub struct EpTag {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct EpInCheese {
|
||||
@@ -130,6 +139,7 @@ pub struct EpInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Faq {
|
||||
pub content: String,
|
||||
pub link: String,
|
||||
@@ -137,24 +147,28 @@ pub struct Faq {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Faq1 {
|
||||
pub items: Vec<Faq1Item>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Faq1Item {
|
||||
pub answer: String,
|
||||
pub question: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PaidJump {
|
||||
pub jump_url_for_app: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Payment {
|
||||
pub bp_enough: i64,
|
||||
pub desc: String,
|
||||
@@ -168,6 +182,7 @@ pub struct Payment {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PreviewedPurchaseNote {
|
||||
pub long_watch_text: String,
|
||||
pub pay_text: String,
|
||||
@@ -177,6 +192,7 @@ pub struct PreviewedPurchaseNote {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PurchaseFormatNote {
|
||||
pub content_list: Vec<ContentList>,
|
||||
pub link: String,
|
||||
@@ -184,6 +200,7 @@ pub struct PurchaseFormatNote {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct ContentList {
|
||||
pub bold: bool,
|
||||
pub content: String,
|
||||
@@ -191,6 +208,7 @@ pub struct ContentList {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PurchaseNote {
|
||||
pub content: String,
|
||||
pub link: String,
|
||||
@@ -198,12 +216,14 @@ pub struct PurchaseNote {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PurchaseProtocol {
|
||||
pub link: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RecommendSeason {
|
||||
pub cover: String,
|
||||
pub ep_count: String,
|
||||
@@ -215,6 +235,7 @@ pub struct RecommendSeason {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInCheese {
|
||||
pub play: i64,
|
||||
pub play_desc: String,
|
||||
@@ -222,6 +243,7 @@ pub struct StatInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UpInfoInCheese {
|
||||
pub avatar: String,
|
||||
pub brief: String,
|
||||
@@ -236,6 +258,7 @@ pub struct UpInfoInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PendantInCheese {
|
||||
pub image: String,
|
||||
pub name: String,
|
||||
@@ -243,6 +266,7 @@ pub struct PendantInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserStatusInCheese {
|
||||
pub bp: i64,
|
||||
pub expire_at: i64,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CheeseMediaUrl {
|
||||
pub accept_format: String,
|
||||
pub code: i64,
|
||||
@@ -32,16 +33,19 @@ pub struct CheeseMediaUrl {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PlayViewBusinessInfo {
|
||||
pub user_status: UserStatusInCheeseUrl,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserStatusInCheeseUrl {
|
||||
pub watch_progress: WatchProgress,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct WatchProgress {
|
||||
pub current_watch_progress: i64,
|
||||
@@ -51,6 +55,7 @@ pub struct WatchProgress {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SupportFormatInCheese {
|
||||
pub display_desc: String,
|
||||
pub superscript: String,
|
||||
@@ -63,6 +68,7 @@ pub struct SupportFormatInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DashInCheese {
|
||||
pub duration: u64,
|
||||
pub min_buffer_time: f64,
|
||||
@@ -71,6 +77,7 @@ pub struct DashInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct MediaInCheese {
|
||||
pub start_with_sap: i64,
|
||||
pub bandwidth: i64,
|
||||
@@ -90,18 +97,21 @@ pub struct MediaInCheese {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SegmentBaseInCheese {
|
||||
pub initialization: String,
|
||||
pub index_range: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DurlInCheese {
|
||||
pub durl: Vec<DurlDetailInCheese>,
|
||||
pub quality: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DurlDetailInCheese {
|
||||
pub size: i64,
|
||||
pub ahead: String,
|
||||
|
||||
@@ -7,6 +7,8 @@ use specta::Type;
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Hash,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
@@ -19,6 +21,7 @@ use specta::Type;
|
||||
pub enum CodecType {
|
||||
#[default]
|
||||
Unknown = -1,
|
||||
|
||||
Audio = 0,
|
||||
AVC = 7,
|
||||
HEVC = 12,
|
||||
|
||||
@@ -11,18 +11,21 @@ pub enum CreateDownloadTaskParams {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CreateNormalDownloadTaskParams {
|
||||
pub info: NormalInfo,
|
||||
pub aid_cid_pairs: Vec<(i64, Option<i64>)>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CreateBangumiDownloadTaskParams {
|
||||
pub ep_ids: Vec<i64>,
|
||||
pub info: BangumiInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CreateCheeseDownloadTaskParams {
|
||||
pub ep_ids: Vec<i64>,
|
||||
pub info: CheeseInfo,
|
||||
|
||||
@@ -2,12 +2,14 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct FavFolders {
|
||||
pub count: i64,
|
||||
pub list: Vec<Folder>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Folder {
|
||||
pub id: i64,
|
||||
pub fid: i64,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct FavInfo {
|
||||
pub info: Info,
|
||||
pub medias: Option<Vec<MediaInFav>>,
|
||||
@@ -10,6 +11,7 @@ pub struct FavInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Info {
|
||||
pub id: i64,
|
||||
@@ -34,6 +36,7 @@ pub struct Info {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Upper {
|
||||
pub mid: i64,
|
||||
pub name: String,
|
||||
@@ -44,6 +47,7 @@ pub struct Upper {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CntInfo {
|
||||
pub collect: i64,
|
||||
pub play: i64,
|
||||
@@ -52,6 +56,7 @@ pub struct CntInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct MediaInFav {
|
||||
pub id: i64,
|
||||
@@ -76,6 +81,7 @@ pub struct MediaInFav {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UpperInMedia {
|
||||
pub mid: i64,
|
||||
pub name: String,
|
||||
@@ -84,6 +90,7 @@ pub struct UpperInMedia {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CntInfoInMedia {
|
||||
pub collect: i64,
|
||||
pub play: i64,
|
||||
@@ -95,6 +102,7 @@ pub struct CntInfoInMedia {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Ugc {
|
||||
pub first_cid: i64,
|
||||
}
|
||||
|
||||
13
src-tauri/src/types/get_bangumi_follow_info_params.rs
Normal file
13
src-tauri/src/types/get_bangumi_follow_info_params.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct GetBangumiFollowInfoParams {
|
||||
pub vmid: i64,
|
||||
/// 1: 番剧 2: 电视剧或电影
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: i64,
|
||||
pub pn: i64,
|
||||
// 0: 全部 1: 想看 2: 在看 3: 看过
|
||||
pub follow_status: i64,
|
||||
}
|
||||
38
src-tauri/src/types/get_history_info_params.rs
Normal file
38
src-tauri/src/types/get_history_info_params.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct GetHistoryInfoParams {
|
||||
pub pn: i64,
|
||||
pub keyword: String,
|
||||
pub add_time_start: i64,
|
||||
pub add_time_end: i64,
|
||||
pub arc_max_duration: i64,
|
||||
pub arc_min_duration: i64,
|
||||
pub device_type: DeviceType,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Hash,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Type,
|
||||
IntoPrimitive,
|
||||
FromPrimitive,
|
||||
)]
|
||||
#[repr(i64)]
|
||||
pub enum DeviceType {
|
||||
#[default]
|
||||
All = 0,
|
||||
PC = 1,
|
||||
Mobile = 2,
|
||||
Pad = 3,
|
||||
TV = 4,
|
||||
}
|
||||
56
src-tauri/src/types/history_info.rs
Normal file
56
src-tauri/src/types/history_info.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct HistoryInfo {
|
||||
pub has_more: bool,
|
||||
pub page: PageInHistory,
|
||||
pub list: Option<Vec<HistoryDetail>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PageInHistory {
|
||||
pub pn: i64,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct HistoryDetail {
|
||||
pub title: String,
|
||||
pub long_title: String,
|
||||
pub cover: String,
|
||||
pub uri: String,
|
||||
pub history: History,
|
||||
pub videos: i64,
|
||||
pub author_name: String,
|
||||
pub author_face: String,
|
||||
pub author_mid: i64,
|
||||
pub view_at: i64,
|
||||
pub progress: i64,
|
||||
pub badge: String,
|
||||
pub show_title: String,
|
||||
pub duration: i64,
|
||||
pub total: i64,
|
||||
pub new_desc: String,
|
||||
pub is_finish: i64,
|
||||
pub is_fav: i64,
|
||||
pub kid: i64,
|
||||
pub tag_name: String,
|
||||
pub live_status: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct History {
|
||||
pub oid: i64,
|
||||
pub epid: i64,
|
||||
pub bvid: String,
|
||||
pub page: i64,
|
||||
pub cid: i64,
|
||||
pub part: String,
|
||||
pub business: String,
|
||||
pub dt: i64,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod audio_quality;
|
||||
pub mod bangumi_follow_info;
|
||||
pub mod bangumi_info;
|
||||
pub mod bangumi_media_url;
|
||||
pub mod cheese_info;
|
||||
@@ -7,11 +8,14 @@ pub mod codec_type;
|
||||
pub mod create_download_task_params;
|
||||
pub mod fav_folders;
|
||||
pub mod fav_info;
|
||||
pub mod get_bangumi_follow_info_params;
|
||||
pub mod get_bangumi_info_params;
|
||||
pub mod get_cheese_info_params;
|
||||
pub mod get_fav_info_params;
|
||||
pub mod get_history_info_params;
|
||||
pub mod get_normal_info_params;
|
||||
pub mod get_user_video_info_params;
|
||||
pub mod history_info;
|
||||
pub mod log_level;
|
||||
pub mod normal_info;
|
||||
pub mod normal_media_url;
|
||||
@@ -20,6 +24,7 @@ pub mod qrcode_data;
|
||||
pub mod qrcode_status;
|
||||
pub mod search_params;
|
||||
pub mod search_result;
|
||||
pub mod skip_segments;
|
||||
pub mod subtitle;
|
||||
pub mod tags;
|
||||
pub mod user_info;
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct NormalInfo {
|
||||
pub bvid: String,
|
||||
@@ -52,6 +53,7 @@ pub struct NormalInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DescV2 {
|
||||
pub raw_text: String,
|
||||
#[serde(rename = "type")]
|
||||
@@ -60,6 +62,7 @@ pub struct DescV2 {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Rights {
|
||||
pub bp: i64,
|
||||
pub elec: i64,
|
||||
@@ -82,6 +85,7 @@ pub struct Rights {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct OwnerInNormal {
|
||||
pub mid: i64,
|
||||
pub name: String,
|
||||
@@ -89,6 +93,7 @@ pub struct OwnerInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInNormal {
|
||||
pub aid: i64,
|
||||
pub view: i64,
|
||||
@@ -106,6 +111,7 @@ pub struct StatInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct ArgueInfo {
|
||||
pub argue_msg: String,
|
||||
@@ -114,6 +120,7 @@ pub struct ArgueInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Dimension {
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
@@ -121,6 +128,7 @@ pub struct Dimension {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct PageInNormal {
|
||||
pub cid: i64,
|
||||
@@ -135,12 +143,14 @@ pub struct PageInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleInNormal {
|
||||
pub allow_submit: bool,
|
||||
pub list: Vec<SubtitleDetailInNormal>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleDetailInNormal {
|
||||
pub id: i64,
|
||||
pub lan: String,
|
||||
@@ -155,16 +165,19 @@ pub struct SubtitleDetailInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserGarb {
|
||||
pub url_image_ani_cut: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct HonorReply {
|
||||
pub honor: Option<Vec<Honor>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Honor {
|
||||
pub aid: i64,
|
||||
#[serde(rename = "type")]
|
||||
@@ -174,6 +187,7 @@ pub struct Honor {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UgcSeason {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
@@ -191,6 +205,7 @@ pub struct UgcSeason {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SectionInNormal {
|
||||
pub season_id: i64,
|
||||
pub id: i64,
|
||||
@@ -201,6 +216,7 @@ pub struct SectionInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct EpInNormal {
|
||||
pub season_id: i64,
|
||||
pub section_id: i64,
|
||||
@@ -216,6 +232,7 @@ pub struct EpInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Arc {
|
||||
pub aid: i64,
|
||||
pub videos: i64,
|
||||
@@ -244,6 +261,7 @@ pub struct Arc {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Author {
|
||||
pub mid: i64,
|
||||
pub name: String,
|
||||
@@ -251,6 +269,7 @@ pub struct Author {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInNormalEp {
|
||||
pub aid: i64,
|
||||
pub view: i64,
|
||||
@@ -270,6 +289,7 @@ pub struct StatInNormalEp {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInNormalSeason {
|
||||
pub season_id: i64,
|
||||
pub view: i64,
|
||||
@@ -286,6 +306,7 @@ pub struct StatInNormalSeason {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PageInNormalEp {
|
||||
pub cid: i64,
|
||||
pub page: i64,
|
||||
@@ -298,6 +319,7 @@ pub struct PageInNormalEp {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RightsInNormalEp {
|
||||
pub bp: i64,
|
||||
pub elec: i64,
|
||||
@@ -315,6 +337,7 @@ pub struct RightsInNormalEp {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Staff {
|
||||
pub mid: i64,
|
||||
pub title: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct NormalMediaUrl {
|
||||
pub from: String,
|
||||
pub result: String,
|
||||
@@ -23,6 +24,7 @@ pub struct NormalMediaUrl {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DashInNormal {
|
||||
pub duration: u64,
|
||||
pub min_buffer_time: f64,
|
||||
@@ -33,12 +35,14 @@ pub struct DashInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Flac {
|
||||
pub display: bool,
|
||||
pub audio: Option<MediaInNormal>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct MediaInNormal {
|
||||
pub id: i64,
|
||||
pub start_with_sap: i64,
|
||||
@@ -56,12 +60,14 @@ pub struct MediaInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SegmentBaseInNormal {
|
||||
pub initialization: String,
|
||||
pub index_range: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Dolby {
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: i64,
|
||||
@@ -69,6 +75,7 @@ pub struct Dolby {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SupportFormatInNormal {
|
||||
pub quality: i64,
|
||||
pub format: String,
|
||||
@@ -79,6 +86,7 @@ pub struct SupportFormatInNormal {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PlayConf {
|
||||
pub is_new_description: bool,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct PlayerInfo {
|
||||
pub aid: i64,
|
||||
@@ -45,6 +46,7 @@ pub struct PlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct IpInfo {
|
||||
pub ip: String,
|
||||
pub zone_ip: String,
|
||||
@@ -55,6 +57,7 @@ pub struct IpInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct LevelInfoInPlayerInfo {
|
||||
pub current_level: i64,
|
||||
pub current_min: i64,
|
||||
@@ -64,6 +67,7 @@ pub struct LevelInfoInPlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct VipInPlayerInfo {
|
||||
#[serde(rename = "type")]
|
||||
@@ -84,6 +88,7 @@ pub struct VipInPlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct LabelInPlayerInfo {
|
||||
pub path: String,
|
||||
@@ -101,14 +106,17 @@ pub struct LabelInPlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct AvatarIcon {
|
||||
pub icon_resource: IconResource,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct IconResource {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleInPlayerInfo {
|
||||
pub allow_submit: bool,
|
||||
pub lan: String,
|
||||
@@ -117,6 +125,7 @@ pub struct SubtitleInPlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleDetailInPlayerInfo {
|
||||
pub id: i64,
|
||||
pub lan: String,
|
||||
@@ -131,6 +140,7 @@ pub struct SubtitleDetailInPlayerInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct ViewPoint {
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: i64,
|
||||
@@ -144,12 +154,14 @@ pub struct ViewPoint {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Options {
|
||||
pub is_360: bool,
|
||||
pub without_vip: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct OnlineSwitch {
|
||||
pub enable_gray_dash_playback: String,
|
||||
pub new_broadcast: String,
|
||||
@@ -158,17 +170,20 @@ pub struct OnlineSwitch {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Fawkes {
|
||||
pub config_version: i64,
|
||||
pub ff_version: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct ShowSwitch {
|
||||
pub long_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct ElecHighLevel {
|
||||
pub privilege_type: i64,
|
||||
pub title: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct QrcodeData {
|
||||
pub url: String,
|
||||
pub qrcode_key: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct QrcodeStatus {
|
||||
pub url: String,
|
||||
pub refresh_token: String,
|
||||
|
||||
@@ -19,22 +19,27 @@ pub enum SearchResult {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct NormalSearchResult(pub NormalInfo);
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct BangumiSearchResult {
|
||||
pub ep: Option<bangumi_info::EpInBangumi>,
|
||||
pub info: BangumiInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct CheeseSearchResult {
|
||||
pub ep: Option<cheese_info::EpInCheese>,
|
||||
pub info: CheeseInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserVideoSearchResult(pub UserVideoInfo);
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct FavSearchResult(pub FavInfo);
|
||||
|
||||
54
src-tauri/src/types/skip_segments.rs
Normal file
54
src-tauri/src/types/skip_segments.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::downloader::chapter_segments::ChapterSegment;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SkipSegments(pub Vec<SkipSegment>);
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct SkipSegment {
|
||||
pub cid: String,
|
||||
pub category: String,
|
||||
#[serde(rename = "actionType")]
|
||||
pub action_type: String,
|
||||
pub segment: Vec<f64>,
|
||||
#[serde(rename = "UUID")]
|
||||
pub uuid: String,
|
||||
#[serde(rename = "videoDuration")]
|
||||
pub video_duration: f64,
|
||||
pub locked: i64,
|
||||
pub votes: i64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl SkipSegment {
|
||||
fn get_title(&self) -> Option<String> {
|
||||
match self.category.as_str() {
|
||||
"sponsor" => Some("广告".to_string()),
|
||||
"selfpromo" => Some("无偿/自我推广".to_string()),
|
||||
"exclusive_access" => Some("柔性推广/品牌合作".to_string()),
|
||||
"interaction" => Some("三连/订阅提醒".to_string()),
|
||||
"poi_highlight" => Some("精彩时刻/重点".to_string()),
|
||||
"intro" => Some("过场/开场动画".to_string()),
|
||||
"outro" => Some("鸣谢/结束画面".to_string()),
|
||||
"preview" => Some("回顾/概要".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub fn into_chapter_segment(self) -> Option<ChapterSegment> {
|
||||
if self.segment.len() < 2 {
|
||||
return None; // 确保 segment 包含开始和结束时间
|
||||
}
|
||||
|
||||
Some(ChapterSegment {
|
||||
title: self.get_title()?,
|
||||
start: self.segment[0] as i64,
|
||||
end: self.segment[1] as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Subtitle {
|
||||
pub font_size: f64,
|
||||
pub font_color: String,
|
||||
@@ -13,6 +14,7 @@ pub struct Subtitle {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Body {
|
||||
pub from: f64,
|
||||
pub to: f64,
|
||||
|
||||
@@ -4,6 +4,7 @@ use specta::Type;
|
||||
pub type Tags = Vec<Tag>;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Tag {
|
||||
pub tag_id: i64,
|
||||
|
||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserInfo {
|
||||
#[serde(rename = "isLogin")]
|
||||
pub is_login: bool,
|
||||
@@ -42,6 +43,7 @@ pub struct UserInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct LevelInfoInUserInfo {
|
||||
pub current_level: i64,
|
||||
@@ -50,6 +52,7 @@ pub struct LevelInfoInUserInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Official {
|
||||
pub role: i64,
|
||||
pub title: String,
|
||||
@@ -59,6 +62,7 @@ pub struct Official {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct OfficialVerify {
|
||||
#[serde(rename = "type")]
|
||||
pub type_field: i64,
|
||||
@@ -66,6 +70,7 @@ pub struct OfficialVerify {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PendantInUserInfo {
|
||||
pub pid: i64,
|
||||
pub name: String,
|
||||
@@ -77,6 +82,7 @@ pub struct PendantInUserInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct VipLabel {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
@@ -93,6 +99,7 @@ pub struct VipLabel {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct VipInUserInfo {
|
||||
#[serde(rename = "type")]
|
||||
@@ -112,6 +119,7 @@ pub struct VipInUserInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct LabelInUserInfo {
|
||||
pub path: String,
|
||||
@@ -129,9 +137,11 @@ pub struct LabelInUserInfo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct IconResource {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct Wallet {
|
||||
pub mid: i64,
|
||||
pub bcoin_balance: f64,
|
||||
@@ -140,6 +150,7 @@ pub struct Wallet {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct WbiImg {
|
||||
pub img_url: String,
|
||||
pub sub_url: String,
|
||||
|
||||
@@ -2,17 +2,20 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserVideoInfo {
|
||||
pub list: UserVideoList,
|
||||
pub page: PageInUserVideo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct UserVideoList {
|
||||
pub vlist: Vec<EpInUserVideo>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct EpInUserVideo {
|
||||
pub comment: i64,
|
||||
pub typeid: i64,
|
||||
@@ -54,6 +57,7 @@ pub struct EpInUserVideo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct MetaInUserVideo {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
@@ -64,12 +68,13 @@ pub struct MetaInUserVideo {
|
||||
pub attribute: i64,
|
||||
pub stat: StatInUserVideo,
|
||||
pub ep_count: i64,
|
||||
pub first_aid: i64,
|
||||
pub first_aid: Option<i64>,
|
||||
pub ptime: i64,
|
||||
pub ep_num: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInUserVideo {
|
||||
pub season_id: i64,
|
||||
pub view: i64,
|
||||
@@ -85,6 +90,7 @@ pub struct StatInUserVideo {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PageInUserVideo {
|
||||
pub pn: i64,
|
||||
pub ps: i64,
|
||||
|
||||
@@ -7,6 +7,8 @@ use specta::Type;
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Hash,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
|
||||
@@ -2,12 +2,14 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct WatchLaterInfo {
|
||||
pub count: i64,
|
||||
pub list: Vec<MediaInWatchLater>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct MediaInWatchLater {
|
||||
pub aid: i64,
|
||||
pub videos: i64,
|
||||
@@ -64,6 +66,7 @@ pub struct MediaInWatchLater {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct RightsInWatchLater {
|
||||
pub bp: i64,
|
||||
pub elec: i64,
|
||||
@@ -82,6 +85,7 @@ pub struct RightsInWatchLater {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct OwnerInWatchLater {
|
||||
pub mid: i64,
|
||||
pub name: String,
|
||||
@@ -89,6 +93,7 @@ pub struct OwnerInWatchLater {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct StatInWatchLater {
|
||||
pub aid: i64,
|
||||
pub view: i64,
|
||||
@@ -106,6 +111,7 @@ pub struct StatInWatchLater {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct DimensionInWatchLater {
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
@@ -113,6 +119,7 @@ pub struct DimensionInWatchLater {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default)]
|
||||
pub struct PageInWatchLater {
|
||||
pub cid: i64,
|
||||
pub page: i64,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufReader, Read},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
@@ -176,3 +176,13 @@ pub fn seconds_to_srt_time(seconds: f64) -> String {
|
||||
let h = total_m / 60;
|
||||
format!("{h:02}:{m:02}:{s:02},{ms:03}")
|
||||
}
|
||||
|
||||
pub fn get_ffmpeg_program() -> anyhow::Result<PathBuf> {
|
||||
let ffmpeg_program = std::env::current_exe()
|
||||
.context("获取当前可执行文件路径失败")?
|
||||
.parent()
|
||||
.context("获取当前可执行文件所在目录失败")?
|
||||
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
||||
|
||||
Ok(ffmpeg_program)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "bilibili-video-downloader",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.lanyeeee.bilibili-video-downloader",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -35,6 +35,9 @@
|
||||
"SimpChinese"
|
||||
]
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppContent from './AppContent.vue'
|
||||
import { GlobalThemeOverrides } from 'naive-ui'
|
||||
import { GlobalThemeOverrides, zhCN, dateZhCN } from 'naive-ui'
|
||||
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
@@ -15,6 +15,10 @@ const themeOverrides: GlobalThemeOverrides = {
|
||||
Tabs: {
|
||||
tabGapSmallLine: '10px',
|
||||
tabPaddingSmallLine: '6px 8px',
|
||||
tabTextColorActiveSegment: '#00AEECFF',
|
||||
tabTextColorHoverSegment: '#00AEECFF',
|
||||
tabColorSegment: '#DFF6FDFF',
|
||||
colorSegment: '#FFFFFFFF',
|
||||
},
|
||||
Button: {
|
||||
paddingSmall: '0 8px',
|
||||
@@ -35,7 +39,7 @@ const themeOverrides: GlobalThemeOverrides = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="themeOverrides">
|
||||
<n-config-provider :theme-overrides="themeOverrides" :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-dialog-provider>
|
||||
<n-modal-provider>
|
||||
<n-notification-provider placement="bottom-right" :max="3">
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
PhMagnifyingGlass,
|
||||
PhStar,
|
||||
PhClock,
|
||||
PhHeart,
|
||||
PhDownload,
|
||||
PhPlayCircle,
|
||||
} from '@phosphor-icons/vue'
|
||||
import AboutDialog from './dialogs/AboutDialog.vue'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
@@ -20,8 +22,10 @@ import FavPane from './panes/FavPane/FavPane.vue'
|
||||
import WatchLaterPane from './panes/WatchLaterPane/WatchLaterPane.vue'
|
||||
import DownloadPane from './panes/DownloadPane/DownloadPane.vue'
|
||||
import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts'
|
||||
import BangumiFollowPane from './panes/BangumiFollow/BangumiFollowPane.vue'
|
||||
import HistoryPane from './panes/HistoryPane/HistoryPane.vue'
|
||||
|
||||
export type CurrentNavName = 'search' | 'fav' | 'watch_later' | 'download'
|
||||
export type CurrentNavName = 'search' | 'fav' | 'history' | 'bangumi_follow' | 'watch_later' | 'download'
|
||||
|
||||
const currentPlatform = platform()
|
||||
|
||||
@@ -56,12 +60,13 @@ onMounted(() => {
|
||||
<TitleBar />
|
||||
|
||||
<div v-if="store.config !== undefined" class="h-full w-full flex overflow-hidden select-none">
|
||||
<div class="flex flex-col box-border p-1 border-r-solid border-r-1 border-r-[#DADADA] bg-[#F9F9F9] flex-shrink-0">
|
||||
<div
|
||||
class="flex flex-col px-1 box-border border-r-solid border-r-1 border-r-[#DADADA] bg-[#F9F9F9] flex-shrink-0">
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
搜索
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'search'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'search' }">
|
||||
<PhMagnifyingGlass :weight="store.currentNavName === 'search' ? 'fill' : 'regular'" size="28" />
|
||||
@@ -70,10 +75,10 @@ onMounted(() => {
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
收藏
|
||||
收藏夹
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'fav'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'fav' }">
|
||||
<PhStar :weight="store.currentNavName === 'fav' ? 'fill' : 'regular'" size="28" />
|
||||
@@ -82,24 +87,48 @@ onMounted(() => {
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
稍后再看
|
||||
历史记录
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
|
||||
@click="store.currentNavName = 'watch_later'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'watch_later' }">
|
||||
<PhClock :weight="store.currentNavName === 'watch_later' ? 'fill' : 'regular'" size="28" />
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'history'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'history' }">
|
||||
<PhClock :weight="store.currentNavName === 'history' ? 'fill' : 'regular'" size="28" />
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
下载
|
||||
追番追剧
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'bangumi_follow'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'bangumi_follow' }">
|
||||
<PhHeart :weight="store.currentNavName === 'bangumi_follow' ? 'fill' : 'regular'" size="28" />
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
稍后再看
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'watch_later'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'watch_later' }">
|
||||
<PhPlayCircle :weight="store.currentNavName === 'watch_later' ? 'fill' : 'regular'" size="28" />
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
下载任务
|
||||
<template #trigger>
|
||||
<n-badge :value="store.uncompletedProgressesCount" :offset="[-7, 7]">
|
||||
<div
|
||||
ref="downloadButtonRef"
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
|
||||
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded p-1 my-1"
|
||||
@click="store.currentNavName = 'download'"
|
||||
:class="{ 'text-sky-5': store.currentNavName === 'download' }">
|
||||
<PhDownload :weight="store.currentNavName === 'download' ? 'fill' : 'regular'" size="28" />
|
||||
@@ -111,7 +140,7 @@ onMounted(() => {
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
配置
|
||||
<template #trigger>
|
||||
<n-button text class="mt-auto py-1 px-2" @click="settingsDialogShowing = true">
|
||||
<n-button text class="mt-auto p-1" @click="settingsDialogShowing = true">
|
||||
<n-icon size="28">
|
||||
<PhGearSix />
|
||||
</n-icon>
|
||||
@@ -122,7 +151,7 @@ onMounted(() => {
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
日志
|
||||
<template #trigger>
|
||||
<n-button text class="py-1 px-2" @click="logDialogShowing = true">
|
||||
<n-button text class="p-1" @click="logDialogShowing = true">
|
||||
<n-icon size="28">
|
||||
<PhClockCounterClockwise />
|
||||
</n-icon>
|
||||
@@ -133,7 +162,7 @@ onMounted(() => {
|
||||
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
|
||||
关于
|
||||
<template #trigger>
|
||||
<n-button text class="py-1 px-2 mb-2" @click="aboutDialogShowing = true">
|
||||
<n-button text class="p-1 mb-1" @click="aboutDialogShowing = true">
|
||||
<n-icon size="28">
|
||||
<PhInfo />
|
||||
</n-icon>
|
||||
@@ -141,11 +170,25 @@ onMounted(() => {
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="w-full overflow-hidden">
|
||||
<SearchPane v-show="store.currentNavName === 'search'" ref="searchPaneRef" />
|
||||
<FavPane v-show="store.currentNavName === 'fav'" />
|
||||
<WatchLaterPane v-show="store.currentNavName === 'watch_later'" />
|
||||
<DownloadPane v-show="store.currentNavName === 'download'" />
|
||||
<div class="relative w-full overflow-hidden">
|
||||
<transition name="fade">
|
||||
<SearchPane class="absolute inset-0" v-show="store.currentNavName === 'search'" ref="searchPaneRef" />
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<FavPane class="absolute inset-0" v-show="store.currentNavName === 'fav'" />
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<HistoryPane class="absolute inset-0" v-show="store.currentNavName === 'history'" />
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<BangumiFollowPane class="absolute inset-0" v-show="store.currentNavName === 'bangumi_follow'" />
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<WatchLaterPane class="absolute inset-0" v-show="store.currentNavName === 'watch_later'" />
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<DownloadPane class="absolute inset-0" v-show="store.currentNavName === 'download'" />
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,4 +214,14 @@ onMounted(() => {
|
||||
:deep(.n-badge-sup) {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,6 +48,14 @@ async getNormalInfo(params: GetNormalInfoParams) : Promise<Result<NormalInfo, Co
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getBangumiInfo(params: GetBangumiInfoParams) : Promise<Result<BangumiInfo, CommandError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_bangumi_info", { params }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getUserVideoInfo(params: GetUserVideoInfoParams) : Promise<Result<UserVideoInfo, CommandError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_user_video_info", { params }) };
|
||||
@@ -80,6 +88,22 @@ async getWatchLaterInfo(page: number) : Promise<Result<WatchLaterInfo, CommandEr
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getBangumiFollowInfo(params: GetBangumiFollowInfoParams) : Promise<Result<BangumiFollowInfo, CommandError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_bangumi_follow_info", { params }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getHistoryInfo(params: GetHistoryInfoParams) : Promise<Result<HistoryInfo, CommandError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_history_info", { params }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async createDownloadTasks(params: CreateDownloadTaskParams) : Promise<void> {
|
||||
await TAURI_INVOKE("create_download_tasks", { params });
|
||||
},
|
||||
@@ -126,6 +150,14 @@ async showPathInFileManager(path: string) : Promise<Result<null, CommandError>>
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getSkipSegments(bvid: string, cid: number | null) : Promise<Result<SkipSegments, CommandError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_skip_segments", { bvid, cid }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,13 +181,17 @@ logEvent: "log-event"
|
||||
export type AbtestInfo = { style_abtest: number }
|
||||
export type Activity = { head_bg_url: string; id: number; title: string }
|
||||
export type Arc = { aid: number; videos: number; type_id: number; type_name: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; rights: RightsInNormalEp; author: Author; stat: StatInNormalEp; dynamic: string; dimension: Dimension; is_chargeable_season: boolean; is_blooper: boolean; enable_vt: number; vt_display: string; type_id_v2: number; type_name_v2: string; is_lesson_video: number }
|
||||
export type Area = { id: number; name: string }
|
||||
export type AreaInBangumi = { id: number; name: string }
|
||||
export type AreaInBangumiFollow = { id: number; name: string }
|
||||
export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string }
|
||||
export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
|
||||
export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean }
|
||||
export type Author = { mid: number; name: string; face: string }
|
||||
export type BadgeInfo = { bg_color: string; bg_color_night: string; text: string }
|
||||
export type BangumiInfo = { activity: Activity; actors: string; alias: string; areas: Area[]; bkg_cover: string; cover: string; delivery_fragment_video: boolean; enable_vt: boolean; episodes: EpInBangumi[]; evaluate: string; hide_ep_vv_vt_dm: number; icon_font: IconFont; jp_title: string; link: string; media_id: number; mode: number; new_ep: NewEp; payment: PaymentInBangumi | null; play_strategy: PlayStrategy | null; positive: Positive; publish: Publish; rating: Rating | null; record: string; rights: RightsInBangumi; season_id: number; season_title: string; seasons: Season[]; section: SectionInBangumi[] | null; series: Series; share_copy: string; share_sub_title: string; share_url: string; show: Show; show_season_type: number; square_cover: string; staff: string; stat: StatInBangumi; status: number; styles: string[]; subtitle: string; title: string; total: number; type: number; up_info: UpInfoInBangumi | null; user_status: UserStatusInBangumi }
|
||||
export type BadgeInfoInBangumi = { bg_color: string; bg_color_night: string; text: string }
|
||||
export type BadgeInfoInBangumiFollow = { text: string | null; bg_color: string; bg_color_night: string; img: string | null; multi_img: MultiImg }
|
||||
export type BadgeInfos = { vip_or_pay: VipOrPay | null; content_attr: ContentAttr | null }
|
||||
export type BangumiFollowInfo = { list: EpInBangumiFollow[]; pn: number; ps: number; total: number }
|
||||
export type BangumiInfo = { activity: Activity; actors: string; alias: string; areas: AreaInBangumi[]; bkg_cover: string; cover: string; delivery_fragment_video: boolean; enable_vt: boolean; episodes: EpInBangumi[]; evaluate: string; hide_ep_vv_vt_dm: number; icon_font: IconFont; jp_title: string; link: string; media_id: number; mode: number; new_ep: NewEp; payment: PaymentInBangumi | null; play_strategy: PlayStrategy | null; positive: Positive; publish: PublishInBangumi; rating: RatingInBangumi | null; record: string; rights: RightsInBangumi; season_id: number; season_title: string; seasons: Season[]; section: SectionInBangumi[] | null; series: SeriesInBangumi; share_copy: string; share_sub_title: string; share_url: string; show: Show; show_season_type: number; square_cover: string; staff: string; stat: StatInBangumi; status: number; styles: string[]; subtitle: string; title: string; total: number; type: number; up_info: UpInfoInBangumi | null; user_status: UserStatusInBangumi }
|
||||
export type BangumiSearchResult = { ep: EpInBangumi | null; info: BangumiInfo }
|
||||
export type Brief = { content: string; img: Img[]; title: string; type: number }
|
||||
export type CanvasConfig = {
|
||||
@@ -219,8 +255,9 @@ export type CntInfo = { collect: number; play: number; thumb_up: number; share:
|
||||
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
||||
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||
export type CommandError = { err_title: string; err_message: string }
|
||||
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: 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 }
|
||||
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
||||
export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
|
||||
export type ContentList = { bold: boolean; content: string; number: string }
|
||||
export type Cooperation = { link: string }
|
||||
export type CoverTask = { selected: boolean; url: string; completed: boolean }
|
||||
@@ -230,14 +267,16 @@ export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams
|
||||
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 DescV2 = { raw_text: string; type: number; biz_id: number }
|
||||
export type DeviceType = "All" | "PC" | "Mobile" | "Pad" | "TV"
|
||||
export type Dimension = { width: number; height: number; rotate: number }
|
||||
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
||||
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; merge_task: MergeTask; 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 }
|
||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||
export type Ed = { end: number; start: number }
|
||||
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfo; 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 }
|
||||
export type EpInBangumiFollow = { season_id: number; media_id: number; season_type: number; season_type_name: string; title: string; cover: string; total_count: number; is_finish: number; is_started: number; is_play: number; badge: string; badge_type: number; rights: RightsInBangumiFollow; stat: StatInBangumiFollow; new_ep: NewEpInBangumiFollow; rating: RatingInBangumiFollow | null; square_cover: string; season_status: number; season_title: string; badge_ep: string; media_attr: number; season_attr: number; evaluate: string; areas: AreaInBangumiFollow[]; subtitle: string; first_ep: number; can_watch: number; release_date_show: string | null; series: SeriesInBangumiFollow; publish: PublishInBangumiFollow; mode: number; section: SectionInBangumiFollow[]; url: string; badge_info: BadgeInfoInBangumiFollow; renewal_time: string | null; first_ep_info: FirstEpInfo; formal_ep_count: number | null; short_url: string; badge_infos: BadgeInfos | null; season_version: string | null; horizontal_cover_16_9: string | null; horizontal_cover_16_10: string | null; subtitle_14: string | null; viewable_crowd_type: number; producers?: Producer[]; summary: string; styles?: string[]; follow_status: number; is_new: number; progress: string; both_follow: boolean; subtitle_25: string | null }
|
||||
export type EpInCheese = { aid: number; catalogue_index: number; cid: number; cover: string; duration: number; ep_status: number; episode_can_view: boolean; from: string; id: number; index: number; label: string | null; page: number; play: number; play_way: number; playable: boolean; release_date: number; show_vt: boolean; status: number; subtitle: string; title: string; watched: boolean; watchedHistory: number }
|
||||
export type EpInNormal = { season_id: number; section_id: number; id: number; aid: number; cid: number; title: string; attribute: number; arc: Arc; page: PageInNormalEp; bvid: string; pages: PageInNormalEp[] }
|
||||
export type EpInUserVideo = { comment: number; typeid: number; play: number; pic: string; subtitle: string; description: string; copyright: string; title: string; review: number; author: string; mid: number; created: number; length: string; video_review: number; aid: number; bvid: string; hide_click: boolean; is_pay: number; is_union_video: number; is_steins_gate: number; is_live_playback: number; is_lesson_video: number; is_lesson_finished: number; lesson_update_info: string; jump_url: string; meta: MetaInUserVideo | null; is_avoided: number; season_id: number; attribute: number; is_charging_arc: boolean; elec_arc_type: number; elec_arc_badge: string; vt: number; enable_vt: number; vt_display: string; playback_position: number; is_self_view: boolean }
|
||||
@@ -250,12 +289,22 @@ export type Faq1Item = { answer: string; question: string }
|
||||
export type FavFolders = { count: number; list: Folder[] }
|
||||
export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number }
|
||||
export type FavSearchResult = FavInfo
|
||||
export type 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 GetBangumiFollowInfoParams = { vmid: number;
|
||||
/**
|
||||
* 1: 番剧 2: 电视剧或电影
|
||||
*/
|
||||
type: number; pn: number; follow_status: number }
|
||||
export type GetBangumiInfoParams = { EpId: number } | { SeasonId: number }
|
||||
export type GetCheeseInfoParams = { EpId: number } | { SeasonId: number }
|
||||
export type GetFavInfoParams = { media_list_id: number; pn: number }
|
||||
export type GetHistoryInfoParams = { pn: number; keyword: string; add_time_start: number; add_time_end: number; arc_max_duration: number; arc_min_duration: number; device_type: DeviceType }
|
||||
export type GetNormalInfoParams = { Bvid: string } | { Aid: number }
|
||||
export type GetUserVideoInfoParams = { pn: number; mid: number }
|
||||
export type History = { oid: number; epid: number; bvid: string; page: number; cid: number; part: string; business: string; dt: number }
|
||||
export type HistoryDetail = { title: string; long_title: string; cover: string; uri: string; history: History; videos: number; author_name: string; author_face: string; author_mid: number; view_at: number; progress: number; badge: string; show_title: string; duration: number; total: number; new_desc: string; is_finish: number; is_fav: number; kid: number; tag_name: string; live_status: number }
|
||||
export type HistoryInfo = { has_more: boolean; page: PageInHistory; list: HistoryDetail[] | null }
|
||||
export type Honor = { aid: number; type: number; desc: string; weekly_recommend_num: number }
|
||||
export type HonorReply = { honor: Honor[] | null }
|
||||
export type IconFont = { name: string; text: string }
|
||||
@@ -270,9 +319,10 @@ export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||
export type MediaChunk = { start: number; end: number; completed: boolean }
|
||||
export type MediaInFav = { id: number; type: number; title: string; cover: string; intro: string; page: number; duration: number; upper: UpperInMedia; attr: number; cnt_info: CntInfoInMedia; link: string; ctime: number; pubtime: number; fav_time: number; bv_id: string; bvid: string; ugc: Ugc | null; media_list_link: string }
|
||||
export type MediaInWatchLater = { aid: number; videos: number; tid: number; tname: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; redirect_url: string | null; mission_id: number | null; rights: RightsInWatchLater; owner: OwnerInWatchLater; stat: StatInWatchLater; dynamic: string; dimension: DimensionInWatchLater; short_link_v2: string; up_from_v2: number | null; first_frame: string | null; pub_location: string | null; cover43: string; tidv2: number; tnamev2: string; pid_v2: number; pid_name_v2: string; page: PageInWatchLater; count: number; cid: number; progress: number; add_at: number; bvid: string; uri: string; enable_vt: number; view_text_1: string; card_type: number; left_icon_type: number; left_text: string; right_icon_type: number; right_text: string; arc_state: number; pgc_label: string; show_up: boolean; forbid_fav: boolean; forbid_sort: boolean; season_title: string; long_title: string; index_title: string; c_source: string; season_id: number | null }
|
||||
export type MergeTask = { selected: boolean; completed: boolean }
|
||||
export type MetaInUserVideo = { id: number; title: string; cover: string; mid: number; intro: string; sign_state: number; attribute: number; stat: StatInUserVideo; ep_count: number; first_aid: number; ptime: number; ep_num: number }
|
||||
export type MetaInUserVideo = { id: number; title: string; cover: string; mid: number; intro: string; sign_state: number; attribute: number; stat: StatInUserVideo; ep_count: number; first_aid: number | null; ptime: number; ep_num: number }
|
||||
export type MultiImg = { color: string; medium_remind: string }
|
||||
export type NewEp = { desc: string; id: number; is_new: number; title: string }
|
||||
export type NewEpInBangumiFollow = { id: number | null; index_show: string | null; cover: string | null; title: string | null; long_title: string | null; pub_time: string | null; duration: number | null }
|
||||
export type NewEpInSeason = { cover: string; id: number; index_show: string }
|
||||
export type NfoTask = { selected: boolean; completed: boolean }
|
||||
export type 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 }
|
||||
@@ -282,6 +332,7 @@ export type OfficialVerify = { type: number; desc: string }
|
||||
export type Op = { end: number; start: number }
|
||||
export type OwnerInNormal = { mid: number; name: string; face: string }
|
||||
export type OwnerInWatchLater = { mid: number; name: string; face: string }
|
||||
export type PageInHistory = { pn: number; total: number }
|
||||
export type PageInNormal = { cid: number; page: number; from: string; part: string; duration: number; vid: string; weblink: string; dimension: Dimension; ctime: number }
|
||||
export type PageInNormalEp = { cid: number; page: number; from: string; part: string; duration: number; vid: string; weblink: string; dimension: Dimension }
|
||||
export type PageInUserVideo = { pn: number; ps: number; count: number }
|
||||
@@ -294,34 +345,40 @@ export type PendantInCheese = { image: string; name: string; pid: number }
|
||||
export type PendantInUserInfo = { pid: number; name: string; image: string; expire: number; image_enhance: string; image_enhance_frame: string; n_pid: number }
|
||||
export type PlayStrategy = { strategies: string[] }
|
||||
export type Positive = { id: number; title: string }
|
||||
export type PreferAudioQuality = "Best" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
|
||||
export type PreferCodecType = "Unknown" | "AVC" | "HEVC" | "AV1"
|
||||
export type PreferVideoQuality = "Best" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
|
||||
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 ProxyMode = "NoProxy" | "System" | "Custom"
|
||||
export type Publish = { is_finish: number; is_started: number; pub_time: string; pub_time_show: string; unknow_pub_date: number; weekday: number }
|
||||
export type PublishInBangumi = { is_finish: number; is_started: number; pub_time: string; pub_time_show: string; unknow_pub_date: number; weekday: number }
|
||||
export type PublishInBangumiFollow = { pub_time: string; pub_time_show: string; release_date: string; release_date_show: string; pub_time_show_db: string | null }
|
||||
export type PurchaseFormatNote = { content_list: ContentList[]; link: string; title: string }
|
||||
export type PurchaseNote = { content: string; link: string; title: string }
|
||||
export type PurchaseProtocol = { link: string; title: string }
|
||||
export type QrcodeData = { url: string; qrcode_key: string }
|
||||
export type QrcodeStatus = { url: string; refresh_token: string; timestamp: number; code: number; message: string }
|
||||
export type Rating = { count: number; score: number }
|
||||
export type RatingInBangumi = { count: number; score: number }
|
||||
export type RatingInBangumiFollow = { score: number; count: number }
|
||||
export type RecommendSeason = { cover: string; ep_count: string; id: number; season_url: string; subtitle: string; title: string; view: number }
|
||||
export type Rights = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; no_background: number; clean_mode: number; is_stein_gate: number; is_360: number; no_share: number; arc_pay: number; free_watch: number }
|
||||
export type RightsInBangumi = { allow_bp: number; allow_bp_rank: number; allow_download: number; allow_review: number; area_limit: number; ban_area_show: number; can_watch: number; copyright: string; forbid_pre: number; freya_white: number; is_cover_show: number; is_preview: number; only_vip_download: number; resource: string; watch_platform: number }
|
||||
export type RightsInBangumiEp = { allow_dm: number; allow_download: number; area_limit: number }
|
||||
export type RightsInBangumiFollow = { allow_review: number | null; allow_preview: number | null; is_selection: number; selection_style: number; is_rcmd: number | null; allow_bp_rank: number | null; allow_bp: number | null; allow_download: number | null }
|
||||
export type RightsInNormalEp = { 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; arc_pay: number; free_watch: number }
|
||||
export type RightsInWatchLater = { 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; arc_pay: number; pay_free_watch: number }
|
||||
export type SearchParams = { Normal: GetNormalInfoParams } | { Bangumi: GetBangumiInfoParams } | { Cheese: GetCheeseInfoParams } | { UserVideo: GetUserVideoInfoParams } | { Fav: GetFavInfoParams }
|
||||
export type SearchResult = { Normal: NormalSearchResult } | { Bangumi: BangumiSearchResult } | { Cheese: CheeseSearchResult } | { UserVideo: UserVideoSearchResult } | { Fav: FavSearchResult }
|
||||
export type Season = { badge: string; badge_info: BadgeInfo; badge_type: number; cover: string; enable_vt: boolean; horizontal_cover_1610: string; horizontal_cover_169: string; icon_font: IconFont; media_id: number; new_ep: NewEpInSeason; season_id: number; season_title: string; season_type: number; stat: StatInSeason }
|
||||
export type Season = { badge: string; badge_info: BadgeInfoInBangumi; badge_type: number; cover: string; enable_vt: boolean; horizontal_cover_1610: string; horizontal_cover_169: string; icon_font: IconFont; media_id: number; new_ep: NewEpInSeason; season_id: number; season_title: string; season_type: number; stat: StatInSeason }
|
||||
export type SectionInBangumi = { attr: number; episodes: EpInBangumi[]; id: number; title: string; type: number; type2: number }
|
||||
export type SectionInBangumiFollow = { section_id: number; season_id: number; limit_group: number; watch_platform: number; copyright: string; ban_area_show: number; episode_ids: number[]; type: number | null; title: string | null; attr: number | null }
|
||||
export type SectionInNormal = { season_id: number; id: number; title: string; type: number; episodes: EpInNormal[] }
|
||||
export type Series = { display_type: number; series_id: number; series_title: string }
|
||||
export type SeriesInBangumi = { display_type: number; series_id: number; series_title: string }
|
||||
export type SeriesInBangumiFollow = { series_id: number | null; title: string | null; season_count: number | null; new_season_id: number | null; series_ord: number | null }
|
||||
export type Show = { wide_screen: number }
|
||||
export type Skip = { ed: Ed; op: Op }
|
||||
export type SkipSegment = { cid: string; category: string; actionType: string; segment: number[]; UUID: string; videoDuration: number; locked: number; votes: number; description: string }
|
||||
export type SkipSegments = SkipSegment[]
|
||||
export type Staff = { mid: number; title: string; name: string; face: string; follower: number; label_style: number }
|
||||
export type StatInBangumi = { coins: number; danmakus: number; favorite: number; favorites: number; follow_text: string; likes: number; reply: number; share: number; views: number; vt: number }
|
||||
export type StatInBangumiFollow = { follow: number; view: number; danmaku: number; reply: number; coin: number; series_follow: number | null; series_view: number | null; likes: number; favorite: number }
|
||||
export type StatInCheese = { play: number; play_desc: string; show_vt: boolean }
|
||||
export type StatInNormal = { aid: number; view: number; danmaku: number; reply: number; favorite: number; coin: number; share: number; now_rank: number; his_rank: number; like: number; dislike: number; evaluation: string; vt: number }
|
||||
export type StatInNormalEp = { aid: number; view: number; danmaku: number; reply: number; fav: number; coin: number; share: number; now_rank: number; his_rank: number; like: number; dislike: number; evaluation: string; argue_msg: string; vt: number; vv: number }
|
||||
@@ -345,10 +402,12 @@ export type UserStatusInCheese = { bp: number; expire_at: number; favored: numbe
|
||||
export type UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
|
||||
export type UserVideoList = { vlist: EpInUserVideo[] }
|
||||
export type UserVideoSearchResult = UserVideoInfo
|
||||
export type VideoProcessTask = { merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; completed: boolean }
|
||||
export type 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 VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number }
|
||||
export type VipLabel = { path: string; text: string; label_theme: string; text_color: string; bg_style: number; bg_color: string; border_color: string; use_img_label: boolean; img_label_uri_hans: string; img_label_uri_hant: string; img_label_uri_hans_static: string; img_label_uri_hant_static: string }
|
||||
export type VipOrPay = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
|
||||
export type Wallet = { mid: number; bcoin_balance: number; coupon_balance: number; coupon_due_time: number }
|
||||
export type WatchLaterInfo = { count: number; list: MediaInWatchLater[] }
|
||||
export type WbiImg = { img_url: string; sub_url: string }
|
||||
|
||||
21
src/components/IconButton.vue
Normal file
21
src/components/IconButton.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
href?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="href !== undefined"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6 active:text-white">
|
||||
<slot />
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6 active:text-white">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const { size = 'medium' } = defineProps<{
|
||||
size?: 'small' | 'medium'
|
||||
checked: boolean
|
||||
onClick: () => void
|
||||
}>()
|
||||
@@ -7,12 +8,21 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-7 h-7 flex items-center justify-center rounded cursor-pointer border border-solid border-white"
|
||||
:class="checked ? 'bg-sky-5' : 'bg-gray/50'"
|
||||
class="flex items-center justify-center rounded cursor-pointer border border-solid border-white"
|
||||
:class="{
|
||||
'bg-sky-5': checked,
|
||||
'bg-gray/50': !checked,
|
||||
'w-7 h-7': size === 'medium',
|
||||
'w-5 h-5': size === 'small',
|
||||
}"
|
||||
@click="onClick">
|
||||
<svg
|
||||
v-show="checked"
|
||||
class="w-5 h-5 text-white"
|
||||
class="text-white"
|
||||
:class="{
|
||||
'w-5 h-5': size === 'medium',
|
||||
'w-4 h-4': size === 'small',
|
||||
}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,112 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectBaseOption } from 'naive-ui/es/select/src/interface'
|
||||
import { PreferAudioQuality, PreferVideoQuality } from '../../../bindings.ts'
|
||||
import { AudioQuality, VideoQuality, CodecType } from '../../../bindings.ts'
|
||||
import { useStore } from '../../../store.ts'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import ColorfulTag from '../../../components/ColorfulTag.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const videoQualitySelectOptions: SelectBaseOption<PreferVideoQuality>[] = [
|
||||
{ label: '最高', value: 'Best' },
|
||||
{ label: '240P', value: '240P' },
|
||||
{ label: '360P', value: '360P' },
|
||||
{ label: '480P', value: '480P' },
|
||||
{ label: '720P', value: '720P' },
|
||||
{ label: '720P 60帧', value: '720P60' },
|
||||
{ label: '1080P', value: '1080P' },
|
||||
{ label: '智能修复', value: 'AiRepair' },
|
||||
{ label: '1080P 高码率', value: '1080P+' },
|
||||
{ label: '1080P 60帧', value: '1080P60' },
|
||||
{ label: '4K', value: '4K' },
|
||||
{ label: 'HDR', value: 'HDR' },
|
||||
{ label: '杜比视界', value: 'Dolby' },
|
||||
{ label: '8K', value: '8K' },
|
||||
]
|
||||
const audioQualitySelectOptions: SelectBaseOption<PreferAudioQuality>[] = [
|
||||
{ label: '最高', value: 'Best' },
|
||||
{ label: '64K', value: '64K' },
|
||||
{ label: '132K', value: '132K' },
|
||||
{ label: '192K', value: '192K' },
|
||||
{ label: '杜比全景声', value: 'Dolby' },
|
||||
{ label: 'Hi-Res', value: 'HiRes' },
|
||||
]
|
||||
const videoQualityNameMap: Map<VideoQuality, string> = new Map([
|
||||
['240P', '240P 极速'],
|
||||
['360P', '360P 流畅'],
|
||||
['480P', '480P 标清'],
|
||||
['720P', '720P 准高清'],
|
||||
['720P60', '720P 60帧'],
|
||||
['1080P', '1080P 高清'],
|
||||
['AiRepair', 'AI智能修复'],
|
||||
['1080P+', '1080P 高码率'],
|
||||
['1080P60', '1080P 60帧'],
|
||||
['4K', '4K 超高清'],
|
||||
['HDR', 'HDR 真彩色'],
|
||||
['Dolby', '杜比视界'],
|
||||
['8K', '8K 超高清'],
|
||||
])
|
||||
|
||||
const audioQualityNameMap: Map<AudioQuality, string> = new Map([
|
||||
['64K', '64K'],
|
||||
['132K', '132K'],
|
||||
['192K', '192K'],
|
||||
['Dolby', '杜比全景声'],
|
||||
['HiRes', 'Hi-Res 无损'],
|
||||
])
|
||||
|
||||
const codecTypeNameMap: Map<CodecType, string> = new Map([
|
||||
['AVC', 'AVC (H.264)'],
|
||||
['HEVC', 'HEVC (H.265)'],
|
||||
['AV1', 'AV1'],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.config !== undefined" class="flex flex-col gap-row-2">
|
||||
<div class="flex gap-2">
|
||||
<span class="font-bold">主要内容</span>
|
||||
<n-checkbox v-model:checked="store.config.download_video">下载视频</n-checkbox>
|
||||
<n-checkbox v-model:checked="store.config.download_audio">下载音频</n-checkbox>
|
||||
<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_audio">下载音频</n-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span class="w-15 font-bold">视频处理</span>
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<div>自动合并音频和视频</div>
|
||||
<template #trigger>
|
||||
<n-checkbox v-model:checked="store.config.auto_merge">自动合并</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.auto_merge">自动合并</n-checkbox>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<div>如果视频有章节分段,则将章节信息嵌入mp4文件的元数据中</div>
|
||||
<div>使视频在各类播放器中支持章节导航(例如进度条分段)</div>
|
||||
<template #trigger>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.embed_chapter">标记章节</n-checkbox>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<div>将视频的广告部分以章节的形式嵌入mp4文件的元数据中</div>
|
||||
<div>可以实现自动跳过广告(如果播放器支持的话)</div>
|
||||
<template #trigger>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.embed_skip">标记广告</n-checkbox>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span class="font-bold">附加内容</span>
|
||||
<n-checkbox v-model:checked="store.config.download_subtitle">下载字幕</n-checkbox>
|
||||
<n-checkbox v-model:checked="store.config.download_cover">下载封面</n-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span class="font-bold">下载弹幕</span>
|
||||
<span class="w-15 font-bold">下载弹幕</span>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_xml_danmaku">xml弹幕</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_ass_danmaku">ass弹幕</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_json_danmaku">json弹幕</n-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span class="w-14 font-bold">元数据</span>
|
||||
<span class="w-15 font-bold">其他内容</span>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_subtitle">下载字幕</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_cover">下载封面</n-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span class="w-14 w-15 font-bold">元数据</span>
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<div>还会顺便下载poster和fanart(如果有的话)</div>
|
||||
<template #trigger>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_nfo">nfo文件</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_nfo">nfo刮削</n-checkbox>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_json">json文件</n-checkbox>
|
||||
<n-checkbox class="w-22" v-model:checked="store.config.download_json">json刮削</n-checkbox>
|
||||
</div>
|
||||
|
||||
<n-tooltip placement="left" trigger="hover" class="w-20vw">
|
||||
<div>如果视频有对应的画质,则使用对应的画质,否则选择最高画质</div>
|
||||
<template #trigger>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2 whitespace-nowrap font-bold">优先画质</span>
|
||||
<n-select
|
||||
size="small"
|
||||
v-model:value="store.config.prefer_video_quality"
|
||||
:options="videoQualitySelectOptions" />
|
||||
<div class="flex flex-justify-between">
|
||||
<div>
|
||||
<span class="whitespace-nowrap font-bold">画质优先级</span>
|
||||
<div class="overflow-auto overflow-x-hidden h-36">
|
||||
<VueDraggable
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
:animation="300"
|
||||
v-model="store.config.video_quality_priority"
|
||||
ghostClass="draggable-ghost"
|
||||
class="select-none flex flex-col gap-2">
|
||||
<ColorfulTag
|
||||
class="whitespace-nowrap cursor-move"
|
||||
color="blue"
|
||||
v-for="videoQuality in store.config.video_quality_priority"
|
||||
:key="videoQuality">
|
||||
{{ videoQualityNameMap.get(videoQuality) || videoQuality }}
|
||||
</ColorfulTag>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<n-tooltip placement="left" trigger="hover" class="w-20vw">
|
||||
<div>如果视频有对应的音质,则使用对应的音质,否则选择最高音质</div>
|
||||
<template #trigger>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2 whitespace-nowrap font-bold">优先音质</span>
|
||||
<n-select
|
||||
size="small"
|
||||
v-model:value="store.config.prefer_audio_quality"
|
||||
:options="audioQualitySelectOptions" />
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<div>
|
||||
<span class="whitespace-nowrap font-bold">音质优先级</span>
|
||||
<VueDraggable
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
v-model="store.config.audio_quality_priority"
|
||||
:animation="300"
|
||||
ghostClass="draggable-ghost"
|
||||
class="select-none flex flex-col gap-2">
|
||||
<ColorfulTag
|
||||
class="whitespace-nowrap cursor-move"
|
||||
color="blue"
|
||||
v-for="audioQuality in store.config.audio_quality_priority"
|
||||
:key="audioQuality">
|
||||
{{ audioQualityNameMap.get(audioQuality) || audioQuality }}
|
||||
</ColorfulTag>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
|
||||
<n-tooltip placement="left" trigger="hover" class="w-20vw">
|
||||
<div>如果视频有对应的编码,则使用对应的编码,否则按照 AVC > HEVC > AV1 的顺序选择编码</div>
|
||||
<template #trigger>
|
||||
<div>
|
||||
<span class="mr-2 font-bold">优先编码</span>
|
||||
<n-radio-group v-model:value="store.config.prefer_codec_type" size="small">
|
||||
<n-radio-button value="AVC">AVC</n-radio-button>
|
||||
<n-radio-button value="HEVC">HEVC</n-radio-button>
|
||||
<n-radio-button value="AV1">AV1</n-radio-button>
|
||||
</n-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<div>
|
||||
<span class="whitespace-nowrap font-bold">编码优先级</span>
|
||||
<VueDraggable
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
v-model="store.config.codec_type_priority"
|
||||
:animation="300"
|
||||
ghostClass="draggable-ghost"
|
||||
class="select-none flex flex-col gap-2">
|
||||
<ColorfulTag
|
||||
class="whitespace-nowrap cursor-move"
|
||||
color="blue"
|
||||
v-for="codecType in store.config.codec_type_priority"
|
||||
:key="codecType">
|
||||
{{ codecTypeNameMap.get(codecType) || codecType }}
|
||||
</ColorfulTag>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.draggable-ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
39
src/panes/BangumiFollow/BangumiFollowPane.vue
Normal file
39
src/panes/BangumiFollow/BangumiFollowPane.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useStore } from '../../store.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { BangumiFollowInfo, commands } from '../../bindings.ts'
|
||||
import BangumiFollowPanel from './components/BangumiFollowPanel.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const bangumiFollowInfo = ref<BangumiFollowInfo>()
|
||||
|
||||
watch(
|
||||
() => store.userInfo,
|
||||
async () => {
|
||||
if (store.userInfo === undefined) {
|
||||
bangumiFollowInfo.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const result = await commands.getBangumiFollowInfo({
|
||||
vmid: store.userInfo.mid,
|
||||
pn: 1,
|
||||
type: 1,
|
||||
follow_status: 0,
|
||||
})
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
bangumiFollowInfo.value = result.data
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="bangumiFollowInfo !== undefined" class="h-full">
|
||||
<BangumiFollowPanel v-model:bangumi-follow-info="bangumiFollowInfo" />
|
||||
</div>
|
||||
<n-empty v-else class="mt-2" description="请先登录" />
|
||||
</template>
|
||||
91
src/panes/BangumiFollow/components/BangumiFollowCard.vue
Normal file
91
src/panes/BangumiFollow/components/BangumiFollowCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { navDownloadButtonRefKey, searchPaneRefKey } from '../../../injection_keys.ts'
|
||||
import { ensureHttps, isElementInViewport, playTaskToQueueAnimation } from '../../../utils.tsx'
|
||||
import { PhDownloadSimple, PhGoogleChromeLogo, PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
import { EpInBangumiFollow } from '../../../bindings.ts'
|
||||
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
const searchPaneRef = inject(searchPaneRefKey)
|
||||
|
||||
const props = defineProps<{
|
||||
ep: EpInBangumiFollow
|
||||
downloadEpisode: (ep: EpInBangumiFollow) => Promise<void>
|
||||
checkboxChecked: (ep: EpInBangumiFollow) => boolean
|
||||
handleCheckboxClick: (ep: EpInBangumiFollow) => void
|
||||
handleContextMenu: (ep: EpInBangumiFollow) => void
|
||||
}>()
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<InstanceType<typeof IconButton>>()
|
||||
|
||||
async function handleDownloadClick() {
|
||||
if (props.downloadEpisode === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await props.downloadEpisode(props.ep)
|
||||
playDownloadAnimation()
|
||||
}
|
||||
|
||||
function playDownloadAnimation() {
|
||||
if (rootDivRef.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value?.$el
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
if (isElementInViewport(rootDivRef.value)) {
|
||||
// 只有卡片在视口内才播放动画
|
||||
playTaskToQueueAnimation(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ playDownloadAnimation, ep: props.ep })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-200px relative p-3 rounded-lg" ref="rootDivRef" @contextmenu="handleContextMenu(ep)">
|
||||
<SimpleCheckbox
|
||||
class="absolute top-5 left-5 z-1 backdrop-blur-2"
|
||||
size="small"
|
||||
:checked="checkboxChecked(ep)"
|
||||
:on-click="() => handleCheckboxClick(ep)" />
|
||||
<div class="flex">
|
||||
<img
|
||||
class="w-90px h-120px rounded-lg object-cover lazyload"
|
||||
:data-src="`${ensureHttps(ep.cover)}@308w_410h_1c.webp`"
|
||||
:key="ep.cover"
|
||||
alt=""
|
||||
draggable="false" />
|
||||
<div class="flex flex-col ml-1">
|
||||
<span class="line-clamp-2" :title="ep.title">{{ ep.title }}</span>
|
||||
<span class="text-gray mt-auto">{{ ep.season_type_name }} · {{ ep.areas[0].name }}</span>
|
||||
<span class="text-gray">{{ ep.new_ep.index_show }}</span>
|
||||
<span class="text-gray">{{ ep.progress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center mt-2">
|
||||
<IconButton title="在浏览器中打开" :href="`https://www.bilibili.com/bangumi/play/ss${ep.season_id}`">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</IconButton>
|
||||
<IconButton title="在下载器内搜索" @click="searchPaneRef?.search(`ss${props.ep.season_id}`, 'Bangumi')">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
class="ml-auto"
|
||||
title="一键下载"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
220
src/panes/BangumiFollow/components/BangumiFollowPanel.vue
Normal file
220
src/panes/BangumiFollow/components/BangumiFollowPanel.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { BangumiFollowInfo, commands, EpInBangumiFollow } from '../../../bindings.ts'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useStore } from '../../../store.ts'
|
||||
import BangumiFollowCard from './BangumiFollowCard.vue'
|
||||
import { useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
|
||||
import { SelectionArea } from '@viselect/vue'
|
||||
import { SelectOption } from 'naive-ui'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const bangumiFollowInfo = defineModel<BangumiFollowInfo>('bangumiFollowInfo', { required: true })
|
||||
|
||||
const selectedType = ref<number>(1)
|
||||
const selectTypeOptions: SelectOption[] = [
|
||||
{ label: '追番', value: 1 },
|
||||
{ label: '追剧', value: 2 },
|
||||
]
|
||||
|
||||
const selectedFollowStatus = ref<number>(0)
|
||||
const selectFollowStatusOptions: SelectOption[] = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '想看', value: 1 },
|
||||
{ label: '在看', value: 2 },
|
||||
{ label: '看过', value: 3 },
|
||||
]
|
||||
|
||||
const currentPage = ref<number>(1)
|
||||
const pageCount = computed<number>(() => {
|
||||
if (bangumiFollowInfo.value === undefined) {
|
||||
return 1
|
||||
}
|
||||
return Math.ceil(bangumiFollowInfo.value.total / 24)
|
||||
})
|
||||
|
||||
const bangumiFollowCardRefs = ref<InstanceType<typeof BangumiFollowCard>[]>([])
|
||||
const bangumiFollowCardRefsMap = computed<Map<number, InstanceType<typeof BangumiFollowCard>>>(() => {
|
||||
const map = new Map<number, InstanceType<typeof BangumiFollowCard>>()
|
||||
bangumiFollowCardRefs.value.forEach((card) => map.set(card.ep.season_id, card))
|
||||
return map
|
||||
})
|
||||
|
||||
const { selectedIds, updateSelectedIds, unselectAll } = useEpisodeSelection()
|
||||
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
|
||||
const checkedIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(bangumiFollowInfo, () => {
|
||||
selectedIds.value.clear()
|
||||
checkedIds.value.clear()
|
||||
selectionAreaRef.value?.selection?.clearSelection()
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useEpisodeDropdown(
|
||||
() => {
|
||||
selectedIds.value.forEach((seasonId) => checkedIds.value.add(seasonId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
selectedIds.value.forEach((seasonId) => checkedIds.value.delete(seasonId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
bangumiFollowInfo.value.list?.forEach((ep) => selectedIds.value.add(ep.season_id))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useBangumiFollowCard(
|
||||
async (ep: EpInBangumiFollow) => {
|
||||
await downloadSeason(ep.season_id)
|
||||
},
|
||||
(ep: EpInBangumiFollow) => {
|
||||
return checkedIds.value.has(ep.season_id)
|
||||
},
|
||||
(ep: EpInBangumiFollow) => {
|
||||
const checked = checkedIds.value.has(ep.season_id)
|
||||
if (checked) {
|
||||
checkedIds.value.delete(ep.season_id)
|
||||
} else {
|
||||
checkedIds.value.add(ep.season_id)
|
||||
}
|
||||
},
|
||||
(ep: EpInBangumiFollow) => {
|
||||
if (selectedIds.value.has(ep.season_id)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value.add(ep.season_id)
|
||||
const selection = selectionAreaRef.value?.selection
|
||||
if (selection) {
|
||||
selection.clearSelection()
|
||||
selection.select(`[data-key="${ep.season_id}"]`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function getBangumiFollowInfo(page: number) {
|
||||
if (store.userInfo === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
currentPage.value = page
|
||||
|
||||
const result = await commands.getBangumiFollowInfo({
|
||||
vmid: store.userInfo.mid,
|
||||
pn: page,
|
||||
type: selectedType.value,
|
||||
follow_status: selectedFollowStatus.value,
|
||||
})
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
bangumiFollowInfo.value = result.data
|
||||
}
|
||||
|
||||
async function downloadSeason(seasonId: number) {
|
||||
// 获取番剧信息,用于创建下载任务
|
||||
const result = await commands.getBangumiInfo({ SeasonId: seasonId })
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
const info = result.data
|
||||
// 创建下载任务
|
||||
await commands.createDownloadTasks({
|
||||
Bangumi: { info, ep_ids: info.episodes.map((ep) => ep.ep_id) },
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadCheckedEpisodes() {
|
||||
for (const seasonId of checkedIds.value) {
|
||||
// 创建下载任务
|
||||
await downloadSeason(seasonId)
|
||||
// 播放下载动画
|
||||
const card = bangumiFollowCardRefsMap.value.get(seasonId)
|
||||
if (card !== undefined) {
|
||||
card.playDownloadAnimation()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
function useBangumiFollowCard(
|
||||
downloadEpisode: (ep: EpInBangumiFollow) => Promise<void>,
|
||||
checkboxChecked: (ep: EpInBangumiFollow) => boolean,
|
||||
handleCheckboxClick: (ep: EpInBangumiFollow) => void,
|
||||
handleContextMenu: (ep: EpInBangumiFollow) => void,
|
||||
) {
|
||||
return {
|
||||
downloadEpisode,
|
||||
checkboxChecked,
|
||||
handleCheckboxClick,
|
||||
handleContextMenu,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full select-none overflow-auto">
|
||||
<SelectionArea
|
||||
ref="selectionAreaRef"
|
||||
class="selection-container flex flex-col flex-1 px-2 overflow-auto"
|
||||
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
|
||||
@contextmenu="showDropdown"
|
||||
@move="updateSelectedIds"
|
||||
@start="unselectAll">
|
||||
<div class="animate-pulse text-violet">左键拖动进行框选,右键打开菜单</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<BangumiFollowCard
|
||||
v-for="ep in bangumiFollowInfo.list"
|
||||
:key="ep.season_id"
|
||||
ref="bangumiFollowCardRefs"
|
||||
:data-key="ep.season_id"
|
||||
:class="[
|
||||
'selectable border border-solid border-transparent',
|
||||
selectedIds.has(ep.season_id) ? 'selected shadow-md' : 'hover:bg-gray-1',
|
||||
]"
|
||||
:ep="ep"
|
||||
:download-episode="downloadEpisode"
|
||||
:checkbox-checked="checkboxChecked"
|
||||
:handle-checkbox-click="handleCheckboxClick"
|
||||
:handle-context-menu="handleContextMenu" />
|
||||
</div>
|
||||
</SelectionArea>
|
||||
<div class="flex gap-2 m-2 box-border">
|
||||
<n-pagination :page-count="pageCount" :page="currentPage" @update:page="getBangumiFollowInfo($event)" />
|
||||
<n-select
|
||||
class="w-20"
|
||||
v-model:value="selectedType"
|
||||
:options="selectTypeOptions"
|
||||
size="small"
|
||||
@update:value="getBangumiFollowInfo(1)" />
|
||||
<n-select
|
||||
class="w-20"
|
||||
v-model:value="selectedFollowStatus"
|
||||
:options="selectFollowStatusOptions"
|
||||
size="small"
|
||||
@update:value="getBangumiFollowInfo(1)" />
|
||||
<n-button class="ml-auto" size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选剧集</n-button>
|
||||
</div>
|
||||
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:show="dropdownShowing"
|
||||
:on-clickoutside="() => (dropdownShowing = false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection-container .selected {
|
||||
@apply bg-[rgb(204,232,255)];
|
||||
}
|
||||
</style>
|
||||
@@ -96,7 +96,7 @@ onMounted(async () => {
|
||||
|
||||
const videoTask = progressData.video_task
|
||||
const audioTask = progressData.audio_task
|
||||
const mergeTask = progressData.merge_task
|
||||
const videoProcessTask = progressData.video_process_task
|
||||
const danmakuTask = progressData.danmaku_task
|
||||
const subtitleTask = progressData.subtitle_task
|
||||
const coverTask = progressData.cover_task
|
||||
@@ -115,9 +115,18 @@ onMounted(async () => {
|
||||
const completedChunks = progressData.audio_task.chunks.filter((chunk) => chunk.completed).length
|
||||
progressData.percentage = (completedChunks / chunkCount) * 100
|
||||
progressData.taskIndicator = `音频分片 ${completedChunks}/${chunkCount}`
|
||||
} else if (mergeTask.selected && !mergeTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '合并视频和音频'
|
||||
} else if (!videoProcessTask.completed) {
|
||||
const embedSelected = videoProcessTask.embed_chapter_selected || videoProcessTask.embed_skip_selected
|
||||
if (videoProcessTask.merge_selected && embedSelected) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '自动合并+嵌入章节元数据'
|
||||
} else if (videoProcessTask.merge_selected) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '自动合并'
|
||||
} else if (embedSelected) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '嵌入章节元数据'
|
||||
}
|
||||
} else if (danmakuSelected && !danmakuTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '弹幕'
|
||||
@@ -129,10 +138,10 @@ onMounted(async () => {
|
||||
progressData.taskIndicator = '封面'
|
||||
} else if (nfoTask.selected && !nfoTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = 'nfo元数据'
|
||||
progressData.taskIndicator = 'nfo刮削'
|
||||
} else if (jsonTask.selected && !jsonTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = 'json元数据'
|
||||
progressData.taskIndicator = 'json刮削'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import ColorfulTag from '../../../components/ColorfulTag.vue'
|
||||
import { searchPaneRefKey } from '../../../injection_keys.ts'
|
||||
import { ProgressData } from '../DownloadPane.vue'
|
||||
import { ensureHttps } from '../../../utils.tsx'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
@@ -153,24 +154,27 @@ function handleSearchClick() {
|
||||
P{{ p.part_order }} {{ p.part_title }}
|
||||
</ColorfulTag>
|
||||
|
||||
<div class="mt-auto flex gap-1 flex-wrap" title="下载内容">
|
||||
<div class="mt-auto flex gap-1 flex-wrap pt-2" title="任务内容">
|
||||
<ColorfulTag v-if="p.video_task.selected" color="blue">
|
||||
视频(编码:{{ p.video_task.codec_type }} 画质:{{ p.video_task.video_quality }})
|
||||
</ColorfulTag>
|
||||
<ColorfulTag v-if="p.audio_task.selected" color="blue">
|
||||
音频(音质:{{ p.audio_task.audio_quality }})
|
||||
</ColorfulTag>
|
||||
<ColorfulTag v-if="p.merge_task.selected" color="blue">自动合并</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.danmaku_task.xml_selected" color="purple">xml弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.ass_selected" color="purple">ass弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.json_selected" color="purple">json弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.video_process_task.merge_selected" color="purple">自动合并</ColorfulTag>
|
||||
<ColorfulTag v-if="p.video_process_task.embed_chapter_selected" color="purple">标记章节</ColorfulTag>
|
||||
<ColorfulTag v-if="p.video_process_task.embed_skip_selected" color="purple">标记广告</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.subtitle_task.selected" color="green">字幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.cover_task.selected" color="green">封面</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.xml_selected" color="green">xml弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.ass_selected" color="green">ass弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.json_selected" color="green">json弹幕</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.nfo_task.selected" color="amber">nfo元数据</ColorfulTag>
|
||||
<ColorfulTag v-if="p.json_task.selected" color="amber">json元数据</ColorfulTag>
|
||||
<ColorfulTag v-if="p.subtitle_task.selected" color="amber">字幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.cover_task.selected" color="amber">封面</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.nfo_task.selected" color="rose">nfo刮削</ColorfulTag>
|
||||
<ColorfulTag v-if="p.json_task.selected" color="rose">json刮削</ColorfulTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,34 +207,24 @@ function handleSearchClick() {
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex gap-2 items-center">
|
||||
<div
|
||||
<IconButton
|
||||
v-if="p.state === 'Completed' && p.video_task.selected"
|
||||
title="打开mp4目录"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="showMp4InFileManager(p.episode_dir, p.filename)">
|
||||
<PhFileVideo :size="24" />
|
||||
</div>
|
||||
<div
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="p.state === 'Completed'"
|
||||
title="打开下载目录"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="showEpisodeDirInFileManager(p.episode_dir)">
|
||||
<PhFolderOpen :size="24" />
|
||||
</div>
|
||||
<div
|
||||
title="在下载器内搜索"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="handleSearchClick">
|
||||
</IconButton>
|
||||
<IconButton title="在下载器内搜索" @click="handleSearchClick">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
<a
|
||||
:href="href"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
title="在浏览器中打开"
|
||||
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
|
||||
</IconButton>
|
||||
<IconButton title="在浏览器中打开" :href="href">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</a>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureHttps, isElementInViewport, playTaskToQueueAnimation } from '../.
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { navDownloadButtonRefKey } from '../../../injection_keys.ts'
|
||||
import { SearchType } from '../../SearchPane/SearchPane.vue'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
media: MediaInFav
|
||||
@@ -18,7 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<InstanceType<typeof IconButton>>()
|
||||
|
||||
const openInBrowserHref = computed<string | undefined>(() => {
|
||||
if (props.media.type === 2) {
|
||||
@@ -54,7 +55,7 @@ function playDownloadAnimation() {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value
|
||||
const from = downloadButtonRef.value?.$el
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
@@ -121,31 +122,20 @@ defineExpose({ playDownloadAnimation, media: props.media })
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<a
|
||||
:href="openInBrowserHref"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
title="在浏览器中打开"
|
||||
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
|
||||
<IconButton title="在浏览器中打开" :href="openInBrowserHref">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="search !== undefined"
|
||||
title="在下载器内搜索"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="searchInSearchPane">
|
||||
</IconButton>
|
||||
<IconButton v-if="search !== undefined" title="在下载器内搜索" @click="searchInSearchPane">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="props.downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
class="ml-auto"
|
||||
title="一键下载"
|
||||
class="ml-auto cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
43
src/panes/HistoryPane/HistoryPane.vue
Normal file
43
src/panes/HistoryPane/HistoryPane.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { commands, HistoryInfo } from '../../bindings.ts'
|
||||
import { useStore } from '../../store.ts'
|
||||
import HistoryPanel from './components/HistoryPanel.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const historyInfo = ref<HistoryInfo>()
|
||||
|
||||
watch(
|
||||
() => store.userInfo,
|
||||
async () => {
|
||||
if (store.userInfo === undefined) {
|
||||
historyInfo.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const result = await commands.getHistoryInfo({
|
||||
pn: 1,
|
||||
keyword: '',
|
||||
add_time_start: 0,
|
||||
add_time_end: 0,
|
||||
arc_max_duration: 0,
|
||||
arc_min_duration: 0,
|
||||
device_type: 'All',
|
||||
})
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
historyInfo.value = result.data
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="historyInfo !== undefined" class="h-full">
|
||||
<HistoryPanel v-model:history-info="historyInfo" />
|
||||
</div>
|
||||
<n-empty v-else class="mt-2" description="请先登录" />
|
||||
</template>
|
||||
195
src/panes/HistoryPane/components/HistoryCard.vue
Normal file
195
src/panes/HistoryPane/components/HistoryCard.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { EpisodeType, HistoryDetail } from '../../../bindings.ts'
|
||||
import { SearchType } from '../../SearchPane/SearchPane.vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { navDownloadButtonRefKey } from '../../../injection_keys.ts'
|
||||
import { ensureHttps, isElementInViewport, playTaskToQueueAnimation } from '../../../utils.tsx'
|
||||
import { PhDownloadSimple, PhGoogleChromeLogo, PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
episodeType: EpisodeType
|
||||
historyDetail: HistoryDetail
|
||||
downloadEpisode?: (historyDetail: HistoryDetail) => Promise<void>
|
||||
checkboxChecked?: (historyDetail: HistoryDetail) => boolean
|
||||
handleCheckboxClick?: (historyDetail: HistoryDetail) => void
|
||||
handleContextMenu?: (historyDetail: HistoryDetail) => void
|
||||
search?: (input: string, searchType: SearchType) => void
|
||||
}>()
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<InstanceType<typeof IconButton>>()
|
||||
|
||||
const openInBrowserHref = computed<string | undefined>(() => {
|
||||
if (props.episodeType === 'Normal') {
|
||||
return `https://www.bilibili.com/video/${props.historyDetail.history.bvid}/`
|
||||
} else if (props.episodeType === 'Bangumi') {
|
||||
return `https://www.bilibili.com/bangumi/play/ep${props.historyDetail.history.epid}`
|
||||
} else if (props.episodeType === 'Cheese') {
|
||||
return `https://www.bilibili.com/cheese/play/ep${props.historyDetail.history.epid}`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const downloadHint = computed<string | undefined>(() => {
|
||||
if (props.episodeType !== 'Normal') {
|
||||
return '下载请点击左下角放大镜按钮'
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const subTitle = computed<string>(() => {
|
||||
if (props.episodeType === 'Normal') {
|
||||
return ''
|
||||
} else if (props.historyDetail.long_title !== '') {
|
||||
return props.historyDetail.long_title
|
||||
} else if (props.historyDetail.show_title !== '') {
|
||||
return props.historyDetail.show_title
|
||||
} else if (props.historyDetail.new_desc !== '') {
|
||||
return props.historyDetail.new_desc
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const timeFormat = computed(() => {
|
||||
function isToday(date: Date): boolean {
|
||||
const today = new Date()
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
)
|
||||
}
|
||||
|
||||
function isYesterday(date: Date): boolean {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
)
|
||||
}
|
||||
|
||||
const date = new Date(props.historyDetail.view_at * 1000)
|
||||
|
||||
if (isToday(date)) {
|
||||
return "'今天' HH:mm"
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return "'昨天' HH:mm"
|
||||
} else {
|
||||
return 'MM-dd HH:mm'
|
||||
}
|
||||
})
|
||||
|
||||
async function handleDownloadClick() {
|
||||
if (props.downloadEpisode === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await props.downloadEpisode(props.historyDetail)
|
||||
playDownloadAnimation()
|
||||
}
|
||||
|
||||
function playDownloadAnimation() {
|
||||
if (rootDivRef.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value?.$el
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
if (isElementInViewport(rootDivRef.value)) {
|
||||
// 只有卡片在视口内才播放动画
|
||||
playTaskToQueueAnimation(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function searchInSearchPane() {
|
||||
if (props.search === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.episodeType === 'Normal') {
|
||||
props.search(props.historyDetail.history.bvid, 'Normal')
|
||||
} else if (props.episodeType === 'Bangumi') {
|
||||
props.search(`ep${props.historyDetail.history.epid}`, 'Bangumi')
|
||||
} else if (props.episodeType === 'Cheese') {
|
||||
props.search(`ep${props.historyDetail.history.epid}`, 'Cheese')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ playDownloadAnimation, historyDetail: props.historyDetail })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-200px relative p-3 rounded-lg"
|
||||
ref="rootDivRef"
|
||||
:title="downloadHint"
|
||||
@contextmenu="handleContextMenu?.(historyDetail)">
|
||||
<SimpleCheckbox
|
||||
v-if="episodeType === 'Normal' && handleCheckboxClick !== undefined && checkboxChecked !== undefined"
|
||||
class="absolute top-6 left-6 z-1 backdrop-blur-2"
|
||||
:checked="checkboxChecked(historyDetail)"
|
||||
:on-click="() => handleCheckboxClick?.(historyDetail)" />
|
||||
<div v-if="historyDetail.badge !== ''" class="absolute top-6 right-6 z-1 bg-[#ff6699] text-white px-1 rounded">
|
||||
{{ historyDetail.badge }}
|
||||
</div>
|
||||
<img
|
||||
class="w-200px h-125px rounded-lg object-cover lazyload"
|
||||
:data-src="`${ensureHttps(historyDetail.cover)}@672w_378h_1c.webp`"
|
||||
:key="historyDetail.cover"
|
||||
alt=""
|
||||
draggable="false" />
|
||||
|
||||
<div class="w-full flex flex-col h-45px mt-2">
|
||||
<span class="line-clamp-2" :title="historyDetail.title">{{ historyDetail.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center whitespace-nowrap text-gray text-12px w-full overflow-hidden">
|
||||
<a
|
||||
v-if="episodeType === 'Normal'"
|
||||
class="min-w-0 color-inherit no-underline hover:text-sky-5 mr-1"
|
||||
:href="`https://space.bilibili.com/${historyDetail.author_mid}`"
|
||||
target="_blank"
|
||||
draggable="false">
|
||||
<div class="truncate text-ellipsis" :title="historyDetail.author_name">{{ historyDetail.author_name }}</div>
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
class="min-w-0 color-inherit no-underline hover:text-sky-5 mr-1"
|
||||
:href="openInBrowserHref"
|
||||
target="_blank"
|
||||
draggable="false">
|
||||
<div class="truncate text-ellipsis" :title="subTitle">{{ subTitle }}</div>
|
||||
</a>
|
||||
|
||||
<span class="ml-auto flex-shrink-0" title="上次观看时间">
|
||||
<n-time unix :format="timeFormat" :time="historyDetail.view_at" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton title="在浏览器中打开" :href="openInBrowserHref">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</IconButton>
|
||||
<IconButton v-if="search !== undefined" title="在下载器内搜索" @click="searchInSearchPane">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="props.downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
class="ml-auto"
|
||||
title="一键下载"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
359
src/panes/HistoryPane/components/HistoryPanel.vue
Normal file
359
src/panes/HistoryPane/components/HistoryPanel.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { commands, DeviceType, HistoryDetail, HistoryInfo } from '../../../bindings.ts'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { searchPaneRefKey } from '../../../injection_keys.ts'
|
||||
import HistoryCard from './HistoryCard.vue'
|
||||
import { useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
|
||||
import { SelectionArea } from '@viselect/vue'
|
||||
import FloatLabelInput from '../../../components/FloatLabelInput.vue'
|
||||
import { PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
|
||||
const historyInfo = defineModel<HistoryInfo>('historyInfo', { required: true })
|
||||
|
||||
const searchPaneRef = inject(searchPaneRefKey)
|
||||
|
||||
const currentPage = ref<number>(1)
|
||||
const pageCount = computed<number>(() => Math.ceil(historyInfo.value.page.total / 20))
|
||||
|
||||
const searching = ref<boolean>(false)
|
||||
|
||||
const searchInput = ref<string>('')
|
||||
|
||||
type DurationTabName = 'all' | '<10' | '10-30' | '30-60' | '>60'
|
||||
const durationTabName = ref<DurationTabName>('all')
|
||||
let addTimeStart: number = 0
|
||||
let addTimeEnd: number = 0
|
||||
|
||||
const datePickerRange = ref<[number, number]>(getInitRange())
|
||||
type StartTimeTabName = 'all' | 'today' | 'yesterday' | 'week' | 'date-picker'
|
||||
const startTimeTabName = ref<StartTimeTabName>('all')
|
||||
let arcMinDuration: number = 0
|
||||
let arcMaxDuration: number = 0
|
||||
|
||||
const selectedDeviceType = ref<DeviceType>('All')
|
||||
|
||||
watch(durationTabName, () => {
|
||||
if (durationTabName.value === 'all') {
|
||||
arcMinDuration = 0
|
||||
arcMaxDuration = 0
|
||||
} else if (durationTabName.value === '<10') {
|
||||
arcMinDuration = 0
|
||||
arcMaxDuration = 10 * 60
|
||||
} else if (durationTabName.value === '10-30') {
|
||||
arcMinDuration = 10 * 60
|
||||
arcMaxDuration = 30 * 60
|
||||
} else if (durationTabName.value === '30-60') {
|
||||
arcMinDuration = 30 * 60
|
||||
arcMaxDuration = 60 * 60
|
||||
} else if (durationTabName.value === '>60') {
|
||||
arcMinDuration = 60 * 60
|
||||
arcMaxDuration = 0
|
||||
}
|
||||
|
||||
getHistory(1)
|
||||
})
|
||||
|
||||
watch(startTimeTabName, () => {
|
||||
const tabName = startTimeTabName.value
|
||||
if (tabName === 'date-picker') {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabName === 'all') {
|
||||
addTimeStart = 0
|
||||
addTimeEnd = 0
|
||||
} else if (tabName === 'today') {
|
||||
const now = new Date()
|
||||
addTimeStart = Math.floor(new Date(now.setHours(0, 0, 0, 0)).getTime() / 1000)
|
||||
addTimeEnd = 0
|
||||
} else if (tabName === 'yesterday') {
|
||||
const now = new Date()
|
||||
const yesterday = new Date(now.setDate(now.getDate() - 1))
|
||||
addTimeStart = Math.floor(new Date(yesterday.setHours(0, 0, 0, 0)).getTime() / 1000)
|
||||
addTimeEnd = Math.floor(new Date(yesterday.setHours(23, 59, 59, 0)).getTime() / 1000)
|
||||
} else if (tabName === 'week') {
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.setDate(now.getDate() - 7))
|
||||
addTimeStart = Math.floor(new Date(weekAgo.setHours(0, 0, 0, 0)).getTime() / 1000)
|
||||
addTimeEnd = 0
|
||||
}
|
||||
|
||||
getHistory(1)
|
||||
})
|
||||
|
||||
watch(selectedDeviceType, () => getHistory(1))
|
||||
|
||||
async function getHistory(page: number) {
|
||||
currentPage.value = page
|
||||
searching.value = true
|
||||
|
||||
const result = await commands.getHistoryInfo({
|
||||
pn: page,
|
||||
keyword: searchInput.value,
|
||||
add_time_start: addTimeStart,
|
||||
add_time_end: addTimeEnd,
|
||||
arc_min_duration: arcMinDuration,
|
||||
arc_max_duration: arcMaxDuration,
|
||||
device_type: selectedDeviceType.value,
|
||||
})
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
historyInfo.value = result.data
|
||||
searching.value = false
|
||||
}
|
||||
|
||||
const historyCardRefs = ref<InstanceType<typeof HistoryCard>[]>([])
|
||||
const historyCardRefsMap = computed<Map<number, InstanceType<typeof HistoryCard>>>(() => {
|
||||
const map = new Map<number, InstanceType<typeof HistoryCard>>()
|
||||
historyCardRefs.value.forEach((card) => map.set(card.historyDetail.kid, card))
|
||||
return map
|
||||
})
|
||||
|
||||
const { selectedIds, updateSelectedIds, unselectAll } = useEpisodeSelection()
|
||||
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
|
||||
const checkedIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(historyInfo, () => {
|
||||
selectedIds.value.clear()
|
||||
checkedIds.value.clear()
|
||||
selectionAreaRef.value?.selection?.clearSelection()
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useEpisodeDropdown(
|
||||
() => {
|
||||
selectedIds.value.forEach((aid) => checkedIds.value.add(aid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
selectedIds.value.forEach((aid) => checkedIds.value.delete(aid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
historyInfo.value.list?.forEach((detail) => selectedIds.value.add(detail.kid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useFavCard(
|
||||
async (historyDetail: HistoryDetail) => {
|
||||
await downloadNormalEpisode(historyDetail.kid)
|
||||
},
|
||||
(historyDetail: HistoryDetail) => {
|
||||
return checkedIds.value.has(historyDetail.kid)
|
||||
},
|
||||
(historyDetail: HistoryDetail) => {
|
||||
const checked = checkedIds.value.has(historyDetail.kid)
|
||||
if (checked) {
|
||||
checkedIds.value.delete(historyDetail.kid)
|
||||
} else {
|
||||
checkedIds.value.add(historyDetail.kid)
|
||||
}
|
||||
},
|
||||
(historyDetail: HistoryDetail) => {
|
||||
if (selectedIds.value.has(historyDetail.kid)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value.add(historyDetail.kid)
|
||||
const selection = selectionAreaRef.value?.selection
|
||||
if (selection) {
|
||||
selection.clearSelection()
|
||||
selection.select(`[data-key="${historyDetail.kid}"]`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function downloadNormalEpisode(aid: number) {
|
||||
// 获取普通视频信息,用于创建下载任务
|
||||
const result = await commands.getNormalInfo({ Aid: aid })
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
// 创建下载任务
|
||||
await commands.createDownloadTasks({ Normal: { info: result.data, aid_cid_pairs: [[aid, null]] } })
|
||||
}
|
||||
|
||||
async function downloadCheckedEpisodes() {
|
||||
for (const aid of checkedIds.value) {
|
||||
// 创建下载任务
|
||||
await downloadNormalEpisode(aid)
|
||||
// 播放下载动画
|
||||
const card = historyCardRefsMap.value.get(aid)
|
||||
if (card !== undefined) {
|
||||
card.playDownloadAnimation()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimePickerConfirm(range: [number, number] | null) {
|
||||
if (range === null) {
|
||||
return
|
||||
}
|
||||
|
||||
addTimeStart = Math.floor(new Date(range[0]).setHours(0, 0, 0, 0) / 1000)
|
||||
addTimeEnd = Math.floor(new Date(range[1]).setHours(23, 59, 59, 0) / 1000)
|
||||
|
||||
startTimeTabName.value = 'date-picker'
|
||||
getHistory(1)
|
||||
}
|
||||
|
||||
function getInitRange(): [number, number] {
|
||||
const now = new Date()
|
||||
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
lastMonthStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const todayEnd = new Date(now)
|
||||
todayEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
return [lastMonthStart.getTime(), todayEnd.getTime()]
|
||||
}
|
||||
|
||||
function useFavCard(
|
||||
downloadEpisode: (historyDetail: HistoryDetail) => Promise<void>,
|
||||
checkboxChecked: (historyDetail: HistoryDetail) => boolean,
|
||||
handleCheckboxClick: (historyDetail: HistoryDetail) => void,
|
||||
handleContextMenu: (historyDetail: HistoryDetail) => void,
|
||||
) {
|
||||
return {
|
||||
downloadEpisode,
|
||||
checkboxChecked,
|
||||
handleCheckboxClick,
|
||||
handleContextMenu,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full select-none overflow-auto">
|
||||
<n-input-group class="box-border px-2 pt-2">
|
||||
<FloatLabelInput
|
||||
label="搜索标题/up主昵称"
|
||||
size="small"
|
||||
v-model:value="searchInput"
|
||||
clearable
|
||||
@keydown.enter="getHistory(1)" />
|
||||
<n-popover trigger="click" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-select class="w-20%" :show="false" default-value="更多筛选" size="small" />
|
||||
</template>
|
||||
<div class="w-155">
|
||||
<n-tabs class="w-111.5" type="segment" size="small" v-model:value="durationTabName">
|
||||
<n-tab name="all">全部时长</n-tab>
|
||||
<n-tab name="<10">10分钟以下</n-tab>
|
||||
<n-tab name="10-30">10-30分钟</n-tab>
|
||||
<n-tab name="30-60">30-60分钟</n-tab>
|
||||
<n-tab name=">60">60分钟以上</n-tab>
|
||||
</n-tabs>
|
||||
<div class="flex items-center">
|
||||
<n-tabs
|
||||
class="justify-between"
|
||||
type="segment"
|
||||
size="small"
|
||||
v-model:value="startTimeTabName"
|
||||
@before-leave="(tabName: StartTimeTabName) => tabName !== 'date-picker'">
|
||||
<n-tab name="all">全部时间</n-tab>
|
||||
<n-tab name="today">今天</n-tab>
|
||||
<n-tab name="yesterday">昨天</n-tab>
|
||||
<n-tab name="week">近一周</n-tab>
|
||||
<n-tab class="cursor-default!" name="date-picker">
|
||||
<n-date-picker
|
||||
size="small"
|
||||
class="ml-auto w-63.5 px-1"
|
||||
v-model:value="datePickerRange"
|
||||
type="daterange"
|
||||
@confirm="handleTimePickerConfirm" />
|
||||
</n-tab>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<n-tabs class="w-111.5" type="segment" size="small" v-model:value="selectedDeviceType">
|
||||
<n-tab name="All">全部设备</n-tab>
|
||||
<n-tab name="PC">PC</n-tab>
|
||||
<n-tab name="Mobile">手机</n-tab>
|
||||
<n-tab name="TV">平板</n-tab>
|
||||
<n-tab name="Pad">TV</n-tab>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</n-popover>
|
||||
<n-button :loading="searching" type="primary" size="small" class="w-10%" @click="getHistory(1)">
|
||||
<template #icon>
|
||||
<n-icon size="22">
|
||||
<PhMagnifyingGlass weight="bold" />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
<SelectionArea
|
||||
ref="selectionAreaRef"
|
||||
class="selection-container flex flex-col flex-1 px-2 overflow-auto"
|
||||
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
|
||||
@contextmenu="showDropdown"
|
||||
@move="updateSelectedIds"
|
||||
@start="unselectAll">
|
||||
<div class="animate-pulse text-violet">左键拖动进行框选,右键打开菜单</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="historyDetail in historyInfo.list" :key="historyDetail.kid">
|
||||
<HistoryCard
|
||||
v-if="historyDetail.badge === ''"
|
||||
ref="historyCardRefs"
|
||||
:data-key="historyDetail.kid"
|
||||
:class="[
|
||||
'selectable border border-solid border-transparent',
|
||||
selectedIds.has(historyDetail.kid) ? 'selected shadow-md' : 'hover:bg-gray-1',
|
||||
]"
|
||||
episode-type="Normal"
|
||||
:history-detail="historyDetail"
|
||||
:download-episode="downloadEpisode"
|
||||
:checkbox-checked="checkboxChecked"
|
||||
:handle-checkbox-click="handleCheckboxClick"
|
||||
:handle-context-menu="handleContextMenu"
|
||||
:search="searchPaneRef?.search" />
|
||||
|
||||
<HistoryCard
|
||||
v-else-if="historyDetail.badge === '课堂'"
|
||||
ref="historyCardRefs"
|
||||
class="border border-solid border-transparent hover:border-gray-3"
|
||||
episode-type="Cheese"
|
||||
:history-detail="historyDetail"
|
||||
:search="searchPaneRef?.search" />
|
||||
|
||||
<HistoryCard
|
||||
v-else
|
||||
ref="historyCardRefs"
|
||||
class="border border-solid border-transparent hover:border-gray-3"
|
||||
episode-type="Bangumi"
|
||||
:history-detail="historyDetail"
|
||||
:search="searchPaneRef?.search" />
|
||||
</template>
|
||||
</div>
|
||||
</SelectionArea>
|
||||
|
||||
<div class="flex gap-2 m-2 box-border">
|
||||
<n-pagination :page-count="pageCount" :page="currentPage" @update:page="getHistory($event)" />
|
||||
<n-button class="ml-auto" size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选视频</n-button>
|
||||
</div>
|
||||
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:show="dropdownShowing"
|
||||
:on-clickoutside="() => (dropdownShowing = false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection-container .selected {
|
||||
@apply bg-[rgb(204,232,255)];
|
||||
}
|
||||
</style>
|
||||
@@ -104,6 +104,11 @@ watch(
|
||||
async () => {
|
||||
const episode = props.bangumiResult.ep
|
||||
if (episode === null) {
|
||||
currentTabIndex.value = 0
|
||||
selectedIds.value.clear()
|
||||
checkedIds.value.clear()
|
||||
selectionAreaRef.value?.selection?.clearSelection()
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ watch(
|
||||
async () => {
|
||||
const ep = props.cheeseResult.ep
|
||||
if (ep === null) {
|
||||
selectedIds.value.clear()
|
||||
checkedIds.value.clear()
|
||||
selectionAreaRef.value?.selection?.clearSelection()
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
return
|
||||
}
|
||||
selectedIds.value = new Set([ep.id])
|
||||
|
||||
@@ -18,6 +18,7 @@ import PartsDialogContent from './PartsDialogContent.vue'
|
||||
import { ensureHttps, extractBvid, isElementInViewport, playTaskToQueueAnimation } from '../../../utils.tsx'
|
||||
import { navDownloadButtonRefKey } from '../../../injection_keys.ts'
|
||||
import { SearchType } from '../SearchPane.vue'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
onMounted(() => console.log('EpisodeCard mounted'))
|
||||
onUpdated(() => console.log('EpisodeCard updated'))
|
||||
@@ -51,7 +52,7 @@ const props = defineProps<{
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<InstanceType<typeof IconButton>>()
|
||||
|
||||
const episodeInfo = computed<EpisodeInfo>(() => {
|
||||
if (props.episodeType === 'NormalSingle') {
|
||||
@@ -174,7 +175,7 @@ function playDownloadAnimation() {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value
|
||||
const from = downloadButtonRef.value?.$el
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
@@ -226,38 +227,26 @@ defineExpose({ playDownloadAnimation, episodeInfo })
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<a
|
||||
v-if="episodeInfo.href !== undefined"
|
||||
:href="episodeInfo.href"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
title="在浏览器中打开"
|
||||
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
|
||||
<IconButton v-if="episodeInfo.href !== undefined" title="在浏览器中打开" :href="episodeInfo.href">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</a>
|
||||
<div
|
||||
v-if="partsButtonShowing"
|
||||
title="查看分P"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="handlePartsButtonClick">
|
||||
</IconButton>
|
||||
<IconButton v-if="partsButtonShowing" title="查看分P" @click="handlePartsButtonClick">
|
||||
<PhQueue :size="24" />
|
||||
</div>
|
||||
<div
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="search !== undefined && episodeInfo.bvid !== undefined"
|
||||
title="在下载器内搜索"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="search(episodeInfo.bvid, 'Normal')">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="downloadButtonRef"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
title="一键下载"
|
||||
class="ml-auto cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
class="ml-auto"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureHttps, extractEpId, isElementInViewport, playTaskToQueueAnimation
|
||||
import { PhDownloadSimple, PhGoogleChromeLogo, PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
|
||||
import { SearchType } from '../../SearchPane/SearchPane.vue'
|
||||
import IconButton from '../../../components/IconButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
media: MediaInWatchLater
|
||||
@@ -18,7 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<InstanceType<typeof IconButton>>()
|
||||
|
||||
const openInBrowserHref = computed<string>(() => {
|
||||
if (props.media.redirect_url === null) {
|
||||
@@ -43,7 +44,7 @@ function playDownloadAnimation() {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value
|
||||
const from = downloadButtonRef.value?.$el
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
@@ -109,31 +110,20 @@ defineExpose({ playDownloadAnimation, media: props.media })
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<a
|
||||
:href="openInBrowserHref"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
title="在浏览器中打开"
|
||||
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
|
||||
<IconButton title="在浏览器中打开" :href="openInBrowserHref">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="search !== undefined"
|
||||
title="在下载器内搜索"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="searchInSearchPane">
|
||||
</IconButton>
|
||||
<IconButton v-if="search !== undefined" title="在下载器内搜索" @click="searchInSearchPane">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
title="一键下载"
|
||||
class="ml-auto cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
class="ml-auto"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user