From 91a81fb1bab75ef2dd946cc68d0555344f0d0dc9 Mon Sep 17 00:00:00 2001 From: lanyeeee Date: Tue, 20 Jan 2026 04:39:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8B=E8=BD=BD=E6=96=87=E4=BB=B6=E5=B7=B2?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E6=97=B6=E7=9A=84=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/config.rs | 9 + src-tauri/src/downloader/download_progress.rs | 16 +- src-tauri/src/downloader/download_task.rs | 16 +- src-tauri/src/downloader/tasks/audio_task.rs | 24 +- src-tauri/src/downloader/tasks/cover_task.rs | 5 + .../src/downloader/tasks/danmaku_task.rs | 32 ++- src-tauri/src/downloader/tasks/json_task.rs | 5 + src-tauri/src/downloader/tasks/nfo_task.rs | 263 ++++++++++++------ .../src/downloader/tasks/subtitle_task.rs | 5 + .../downloader/tasks/video_process_task.rs | 6 +- src-tauri/src/downloader/tasks/video_task.rs | 26 +- src/bindings.ts | 11 +- 12 files changed, 298 insertions(+), 120 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index cad3aa7..2374bb9 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -42,6 +42,7 @@ pub struct Config { pub chunk_concurrency: usize, pub chunk_download_interval_sec: u64, pub danmaku_config: CanvasConfig, + pub file_exist_action: FileExistAction, } impl Config { @@ -151,6 +152,7 @@ impl Config { chunk_concurrency: 16, chunk_download_interval_sec: 0, danmaku_config: CanvasConfig::default(), + file_exist_action: FileExistAction::Overwrite, } } } @@ -162,3 +164,10 @@ pub enum ProxyMode { System, Custom, } + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] +pub enum FileExistAction { + #[default] + Overwrite, + Skip, +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index 1884fb1..89100ca 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -330,12 +330,12 @@ impl DownloadProgress { pub fn mark_uncompleted(&mut self) { self.video_task.mark_uncompleted(); self.audio_task.mark_uncompleted(); - self.video_process_task.completed = false; - self.danmaku_task.completed = false; - self.subtitle_task.completed = false; - self.cover_task.completed = false; - self.nfo_task.completed = false; - self.json_task.completed = false; + self.video_process_task.mark_uncompleted(); + self.danmaku_task.mark_uncompleted(); + self.subtitle_task.mark_uncompleted(); + self.cover_task.mark_uncompleted(); + self.nfo_task.mark_uncompleted(); + self.json_task.mark_uncompleted(); } pub fn get_ids_string(&self) -> String { @@ -656,6 +656,7 @@ impl Tasks { content_length: 0, chunks: Vec::new(), completed: false, + skipped: false, }; let audio = AudioTask { @@ -665,6 +666,7 @@ impl Tasks { content_length: 0, chunks: Vec::new(), completed: false, + skipped: false, }; let video_process = VideoProcessTask { @@ -679,6 +681,7 @@ impl Tasks { ass_selected: config.download_ass_danmaku, json_selected: config.download_json_danmaku, completed: false, + skipped: false, }; let subtitle = SubtitleTask { @@ -695,6 +698,7 @@ impl Tasks { let nfo = NfoTask { selected: config.download_nfo, completed: false, + skipped: false, }; let json = JsonTask { diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index 658b446..a2737a8 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -292,7 +292,7 @@ impl DownloadTask { .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载视频文件失败"))?; - tracing::debug!("{ids_string} `{filename}`视频下载完成"); + tracing::debug!("{ids_string} `{filename}`视频下载任务完成"); } if !audio_task.is_completed() && audio_task.content_length != 0 { @@ -300,7 +300,7 @@ impl DownloadTask { .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载音频文件失败"))?; - tracing::debug!("{ids_string} `{filename}`音频下载完成"); + tracing::debug!("{ids_string} `{filename}`音频下载任务完成"); } if !video_process_task.is_completed() { @@ -308,7 +308,7 @@ impl DownloadTask { .process(self, &progress, &mut player_info) .await .context(format!("{ids_string} `{filename}`视频处理失败"))?; - tracing::debug!("{ids_string} `{filename}`视频处理完成"); + tracing::debug!("{ids_string} `{filename}`视频处理任务完成"); } if !danmaku_task.is_completed() { @@ -316,7 +316,7 @@ impl DownloadTask { .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载弹幕失败"))?; - tracing::debug!("{ids_string} `{filename}`弹幕下载完成"); + tracing::debug!("{ids_string} `{filename}`弹幕下载任务完成"); } if !subtitle_task.is_completed() { @@ -324,7 +324,7 @@ impl DownloadTask { .process(self, &progress, &mut player_info) .await .context(format!("{ids_string} `{filename}`下载字幕失败"))?; - tracing::debug!("{ids_string} `{filename}`字幕下载完成"); + tracing::debug!("{ids_string} `{filename}`字幕下载任务完成"); } if !cover_task.is_completed() { @@ -332,7 +332,7 @@ impl DownloadTask { .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载封面失败"))?; - tracing::debug!("{ids_string} `{filename}`封面下载完成"); + tracing::debug!("{ids_string} `{filename}`封面下载任务完成"); } if !nfo_task.is_completed() { @@ -340,7 +340,7 @@ impl DownloadTask { .process(self, &progress, &mut episode_info) .await .context(format!("{ids_string} `{filename}`下载NFO失败"))?; - tracing::debug!("{ids_string} `{filename}`NFO下载完成"); + tracing::debug!("{ids_string} `{filename}`NFO下载任务完成"); } if !json_task.is_completed() { @@ -348,7 +348,7 @@ impl DownloadTask { .process(self, &progress, &mut episode_info) .await .context(format!("{ids_string} `{filename}`下载JSON元数据失败"))?; - tracing::debug!("{ids_string} `{filename}`JSON元数据下载完成"); + tracing::debug!("{ids_string} `{filename}`JSON元数据下载任务完成"); } let completed_ts = SystemTime::now() diff --git a/src-tauri/src/downloader/tasks/audio_task.rs b/src-tauri/src/downloader/tasks/audio_task.rs index 0fc684a..3236de4 100644 --- a/src-tauri/src/downloader/tasks/audio_task.rs +++ b/src-tauri/src/downloader/tasks/audio_task.rs @@ -13,6 +13,7 @@ use tauri::AppHandle; use tokio::task::JoinSet; use crate::{ + config::FileExistAction, downloader::{ download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress, download_task::DownloadTask, media_chunk::MediaChunk, @@ -28,6 +29,7 @@ use crate::{ const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct AudioTask { pub selected: bool, pub url: String, @@ -35,6 +37,7 @@ pub struct AudioTask { pub content_length: u64, pub chunks: Vec, pub completed: bool, + pub skipped: bool, } impl AudioTask { @@ -286,6 +289,7 @@ impl AudioTask { self.chunks.iter_mut().for_each(|chunk| { chunk.completed = false; }); + self.skipped = false; } pub fn is_completed(&self) -> bool { @@ -298,10 +302,6 @@ impl AudioTask { 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(), @@ -310,6 +310,21 @@ impl AudioTask { ) }; + let m4a_path = episode_dir.join(format!("{filename}.m4a")); + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip && m4a_path.exists() { + tracing::debug!("{ids_string} `{filename}`音频文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.audio_task.skipped = true; + p.audio_task.completed = true; + }); + return Ok(()); + } + + let temp_file_path = episode_dir.join(format!( + "{filename}.m4a.com.lanyeeee.bilibili-video-downloader" + )); + let file = if temp_file_path.exists() { // 如果文件已存在,则打开它 OpenOptions::new() @@ -389,7 +404,6 @@ impl AudioTask { } // 重命名临时文件 - let m4a_path = episode_dir.join(format!("{filename}.m4a")); if m4a_path.exists() { std::fs::remove_file(&m4a_path) .context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?; diff --git a/src-tauri/src/downloader/tasks/cover_task.rs b/src-tauri/src/downloader/tasks/cover_task.rs index 260160c..8a68f05 100644 --- a/src-tauri/src/downloader/tasks/cover_task.rs +++ b/src-tauri/src/downloader/tasks/cover_task.rs @@ -10,6 +10,7 @@ use crate::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct CoverTask { pub selected: bool, pub url: String, @@ -17,6 +18,10 @@ pub struct CoverTask { } impl CoverTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + } + pub fn is_completed(&self) -> bool { !self.selected || self.completed } diff --git a/src-tauri/src/downloader/tasks/danmaku_task.rs b/src-tauri/src/downloader/tasks/danmaku_task.rs index de2ca27..44781f9 100644 --- a/src-tauri/src/downloader/tasks/danmaku_task.rs +++ b/src-tauri/src/downloader/tasks/danmaku_task.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use crate::{ + config::FileExistAction, danmaku_xml_to_ass::xml_to_ass, downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, extensions::AppHandleExt, @@ -12,15 +13,22 @@ use crate::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] #[allow(clippy::struct_excessive_bools)] pub struct DanmakuTask { pub xml_selected: bool, pub ass_selected: bool, pub json_selected: bool, pub completed: bool, + pub skipped: bool, } impl DanmakuTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + self.skipped = false; + } + pub fn is_completed(&self) -> bool { !self.xml_selected && !self.ass_selected && !self.json_selected || self.completed } @@ -32,6 +40,27 @@ impl DanmakuTask { ) -> anyhow::Result<()> { let danmaku_task = &progress.danmaku_task; let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + let ids_string = progress.get_ids_string(); + + let xml_path = episode_dir.join(format!("{filename}.弹幕.xml")); + let ass_path = episode_dir.join(format!("{filename}.弹幕.ass")); + let json_path = episode_dir.join(format!("{filename}.弹幕.json")); + + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip { + let skip_xml = !danmaku_task.xml_selected || xml_path.exists(); + let skip_ass = !danmaku_task.ass_selected || ass_path.exists(); + let skip_json = !danmaku_task.json_selected || json_path.exists(); + + if skip_xml && skip_ass && skip_json { + tracing::debug!("{ids_string} `{filename}`弹幕文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.danmaku_task.skipped = true; + p.danmaku_task.completed = true; + }); + return Ok(()); + } + } let bili_client = download_task.app.get_bili_client(); let replies = bili_client @@ -44,14 +73,12 @@ impl DanmakuTask { .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(); @@ -59,7 +86,6 @@ impl DanmakuTask { } 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()))?; diff --git a/src-tauri/src/downloader/tasks/json_task.rs b/src-tauri/src/downloader/tasks/json_task.rs index ae6a4ab..5e89949 100644 --- a/src-tauri/src/downloader/tasks/json_task.rs +++ b/src-tauri/src/downloader/tasks/json_task.rs @@ -11,12 +11,17 @@ use crate::downloader::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct JsonTask { pub selected: bool, pub completed: bool, } impl JsonTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + } + pub fn is_completed(&self) -> bool { !self.selected || self.completed } diff --git a/src-tauri/src/downloader/tasks/nfo_task.rs b/src-tauri/src/downloader/tasks/nfo_task.rs index 45b1174..985da44 100644 --- a/src-tauri/src/downloader/tasks/nfo_task.rs +++ b/src-tauri/src/downloader/tasks/nfo_task.rs @@ -7,6 +7,7 @@ use specta::Type; use yaserde::{YaDeserialize, YaSerialize}; use crate::{ + config::FileExistAction, downloader::{ download_progress::DownloadProgress, download_task::DownloadTask, @@ -19,12 +20,19 @@ use crate::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct NfoTask { pub selected: bool, pub completed: bool, + pub skipped: bool, } impl NfoTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + self.skipped = false; + } + pub fn is_completed(&self) -> bool { !self.selected || self.completed } @@ -35,105 +43,188 @@ impl NfoTask { progress: &DownloadProgress, episode_info: &mut Option, ) -> 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() - ))?; - } + self.process_normal(download_task, progress, info).await?; } EpisodeInfo::Bangumi(info, ep_id) => { - let tvshow_nfo = info - .to_tvshow_nfo() - .context("将番剧信息转换为tvshow NFO失败")?; - let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); - std::fs::write(&tvshow_nfo_path, tvshow_nfo) - .context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?; - - let episode_details_nfo = info - .to_episode_details_nfo(*ep_id) - .context("将番剧信息转换为episodedetail NFO失败")?; - let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); - std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( - "保存番剧NFO到`{}`失败", - episode_details_nfo_path.display() - ))?; - - let poster_url = &info.cover; - let (poster_data, ext) = bili_client - .get_cover_data_and_ext(poster_url) - .await - .context("获取番剧封面失败")?; - let poster_path = episode_dir.join(format!("poster.{ext}")); - std::fs::write(&poster_path, poster_data) - .context(format!("保存番剧封面到`{}`失败", poster_path.display()))?; - - let fanart_url = &info.bkg_cover; - if !fanart_url.is_empty() { - let (fanart_data, ext) = bili_client - .get_cover_data_and_ext(fanart_url) - .await - .context("获取番剧封面失败")?; - let fanart_path = episode_dir.join(format!("fanart.{ext}")); - std::fs::write(&fanart_path, fanart_data) - .context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?; - } + self.process_bangumi(download_task, progress, info, ep_id) + .await?; } EpisodeInfo::Cheese(info, ep_id) => { - let tvshow_nfo = info - .to_tvshow_nfo() - .context("将课程信息转换为tvshow NFO失败")?; - let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); - std::fs::write(&tvshow_nfo_path, tvshow_nfo) - .context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?; - - let episode_details_nfo = info - .to_episode_details_nfo(*ep_id) - .context("将课程信息转换为episodedetail NFO失败")?; - let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); - std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( - "保存课程NFO到`{}`失败", - episode_details_nfo_path.display() - ))?; - - let poster_url = &info.cover; - let (poster_data, ext) = bili_client - .get_cover_data_and_ext(poster_url) - .await - .context("获取课程封面失败")?; - let poster_path = episode_dir.join(format!("poster.{ext}")); - std::fs::write(&poster_path, poster_data) - .context(format!("保存课程封面到`{}`失败", poster_path.display()))?; + self.process_cheese(download_task, progress, info, ep_id) + .await?; } } + Ok(()) + } + + async fn process_normal( + &self, + download_task: &Arc, + progress: &DownloadProgress, + info: &NormalInfo, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + let ids_string = progress.get_ids_string(); + let nfo_path = episode_dir.join(format!("{filename}.nfo")); + + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip && nfo_path.exists() { + tracing::debug!("{ids_string} `{filename}`NFO文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.nfo_task.skipped = true; + p.nfo_task.completed = true; + }); + return Ok(()); + } + + let bili_client = download_task.app.get_bili_client(); + + let tags = bili_client + .get_tags(progress.aid) + .await + .context("获取视频标签失败")?; + let movie_nfo = info + .to_movie_nfo(tags) + .context("将普通视频信息转换为movie 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() + ))?; + } + + download_task.update_progress(|p| p.nfo_task.completed = true); + + Ok(()) + } + + async fn process_bangumi( + &self, + download_task: &Arc, + progress: &DownloadProgress, + info: &BangumiInfo, + ep_id: &i64, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + let ids_string = progress.get_ids_string(); + let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); + + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip && episode_details_nfo_path.exists() { + tracing::debug!("{ids_string} `{filename}`NFO文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.nfo_task.skipped = true; + p.nfo_task.completed = true; + }); + return Ok(()); + } + + let bili_client = download_task.app.get_bili_client(); + + let tvshow_nfo = info + .to_tvshow_nfo() + .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()))?; + } + + download_task.update_progress(|p| p.nfo_task.completed = true); + + Ok(()) + } + + async fn process_cheese( + &self, + download_task: &Arc, + progress: &DownloadProgress, + info: &CheeseInfo, + ep_id: &i64, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + let ids_string = progress.get_ids_string(); + let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); + + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip && episode_details_nfo_path.exists() { + tracing::debug!("{ids_string} `{filename}`NFO文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.nfo_task.skipped = true; + p.nfo_task.completed = true; + }); + return Ok(()); + } + + let bili_client = download_task.app.get_bili_client(); + + let tvshow_nfo = info + .to_tvshow_nfo() + .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失败")?; + 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(()) diff --git a/src-tauri/src/downloader/tasks/subtitle_task.rs b/src-tauri/src/downloader/tasks/subtitle_task.rs index 689204a..bf0d94c 100644 --- a/src-tauri/src/downloader/tasks/subtitle_task.rs +++ b/src-tauri/src/downloader/tasks/subtitle_task.rs @@ -12,12 +12,17 @@ use crate::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct SubtitleTask { pub selected: bool, pub completed: bool, } impl SubtitleTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + } + pub fn is_completed(&self) -> bool { !self.selected || self.completed } diff --git a/src-tauri/src/downloader/tasks/video_process_task.rs b/src-tauri/src/downloader/tasks/video_process_task.rs index e1a2e8f..696ca30 100644 --- a/src-tauri/src/downloader/tasks/video_process_task.rs +++ b/src-tauri/src/downloader/tasks/video_process_task.rs @@ -17,6 +17,7 @@ use crate::{ }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] #[allow(clippy::struct_excessive_bools)] pub struct VideoProcessTask { pub merge_selected: bool, @@ -26,6 +27,10 @@ pub struct VideoProcessTask { } impl VideoProcessTask { + pub fn mark_uncompleted(&mut self) { + self.completed = false; + } + pub fn is_completed(&self) -> bool { !self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected || self.completed @@ -44,7 +49,6 @@ impl VideoProcessTask { .await .context("自动合并+嵌入章节元数据失败")?; } else if self.merge_selected { - println!("merge1"); self.merge(download_task, progress) .await .context("自动合并失败")?; diff --git a/src-tauri/src/downloader/tasks/video_task.rs b/src-tauri/src/downloader/tasks/video_task.rs index 8da941e..d36a6b1 100644 --- a/src-tauri/src/downloader/tasks/video_task.rs +++ b/src-tauri/src/downloader/tasks/video_task.rs @@ -13,6 +13,7 @@ use tauri::AppHandle; use tokio::task::JoinSet; use crate::{ + config::FileExistAction, downloader::{ download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress, download_task::DownloadTask, media_chunk::MediaChunk, @@ -28,6 +29,7 @@ use crate::{ const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] pub struct VideoTask { pub selected: bool, pub url: String, @@ -36,6 +38,7 @@ pub struct VideoTask { pub content_length: u64, pub chunks: Vec, pub completed: bool, + pub skipped: bool, } impl VideoTask { @@ -309,23 +312,20 @@ impl VideoTask { self.chunks.iter_mut().for_each(|chunk| { chunk.completed = false; }); + self.skipped = false; } pub fn is_completed(&self) -> bool { !self.selected || self.completed } + #[allow(clippy::too_many_lines)] pub async fn process( &self, download_task: &Arc, 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(); ( @@ -335,6 +335,21 @@ impl VideoTask { ) }; + let mp4_path = episode_dir.join(format!("{filename}.mp4")); + let file_exist_action = download_task.app.get_config().read().file_exist_action; + if file_exist_action == FileExistAction::Skip && mp4_path.exists() { + tracing::debug!("{ids_string} `{filename}`视频文件已存在,跳过下载"); + download_task.update_progress(|p| { + p.video_task.skipped = true; + p.video_task.completed = true; + }); + return Ok(()); + } + + let temp_file_path = episode_dir.join(format!( + "{filename}.mp4.com.lanyeeee.bilibili-video-downloader" + )); + let file = if temp_file_path.exists() { // 如果临时文件已存在,则打开它 OpenOptions::new() @@ -416,7 +431,6 @@ impl VideoTask { } // 重命名临时文件 - let mp4_path = episode_dir.join(format!("{filename}.mp4")); if mp4_path.exists() { std::fs::remove_file(&mp4_path) .context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?; diff --git a/src/bindings.ts b/src/bindings.ts index 4b2be01..fb25eec 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -185,7 +185,7 @@ export type AreaInBangumi = { id: number; name: string } export type AreaInBangumiFollow = { id: number; name: string } export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string } export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes" -export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean } +export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean; skipped: boolean } export type Author = { mid: number; name: string; face: string } export type 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 } @@ -255,7 +255,7 @@ 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; video_quality_priority: VideoQuality[]; codec_type_priority: CodecType[]; audio_quality_priority: AudioQuality[]; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; embed_skip: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig } +export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; video_quality_priority: VideoQuality[]; codec_type_priority: CodecType[]; audio_quality_priority: AudioQuality[]; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; embed_skip: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig; file_exist_action: FileExistAction } export type Consulting = { consulting_flag: boolean; consulting_url: string } export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg } export type ContentList = { bold: boolean; content: string; number: string } @@ -265,7 +265,7 @@ export type CreateBangumiDownloadTaskParams = { ep_ids: number[]; info: BangumiI export type CreateCheeseDownloadTaskParams = { ep_ids: number[]; info: CheeseInfo } export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams } export type CreateNormalDownloadTaskParams = { info: NormalInfo; aid_cid_pairs: ([number, number | null])[] } -export type DanmakuTask = { xml_selected: boolean; ass_selected: boolean; json_selected: boolean; completed: boolean } +export type DanmakuTask = { xml_selected: boolean; ass_selected: boolean; json_selected: boolean; completed: boolean; skipped: boolean } export type DescV2 = { raw_text: string; type: number; biz_id: number } export type DeviceType = "All" | "PC" | "Mobile" | "Pad" | "TV" export type Dimension = { width: number; height: number; rotate: number } @@ -289,6 +289,7 @@ export type Faq1Item = { answer: string; question: string } export type FavFolders = { count: number; list: Folder[] } export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number } export type FavSearchResult = FavInfo +export type FileExistAction = "Overwrite" | "Skip" export type FirstEpInfo = { id: number; cover: string; title: string; long_title: string | null; pub_time: string; duration: number } export type Folder = { id: number; fid: number; mid: number; attr: number; title: string; fav_state: number; media_count: number } export type GetBangumiFollowInfoParams = { vmid: number; @@ -324,7 +325,7 @@ export type MultiImg = { color: string; medium_remind: string } export type NewEp = { desc: string; id: number; is_new: number; title: string } export type NewEpInBangumiFollow = { id: number | null; index_show: string | null; cover: string | null; title: string | null; long_title: string | null; pub_time: string | null; duration: number | null } export type NewEpInSeason = { cover: string; id: number; index_show: string } -export type NfoTask = { selected: boolean; completed: boolean } +export type NfoTask = { selected: boolean; completed: boolean; skipped: boolean } export type NormalInfo = { bvid: string; aid: number; videos: number; tid: number; tid_v2: number; tname: string; tname_v2: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; desc_v2: DescV2[] | null; state: number; duration: number; rights: Rights; owner: OwnerInNormal; stat: StatInNormal; argue_info: ArgueInfo; dynamic: string; cid: number; dimension: Dimension; teenage_mode: number; is_chargeable_season: boolean; is_story: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_upower_preview: boolean; enable_vt: number; vt_display: string; is_upower_exclusive_with_qa: boolean; no_cache: boolean; pages: PageInNormal[]; subtitle: SubtitleInNormal; staff: Staff[] | null; ugc_season: UgcSeason | null; is_season_display: boolean; user_garb: UserGarb; honor_reply: HonorReply; like_icon: string; need_jump_bv: boolean; disable_show_up_info: boolean; is_story_play: number; is_view_self: boolean } export type NormalSearchResult = NormalInfo export type Official = { role: number; title: string; desc: string; type: number } @@ -404,7 +405,7 @@ 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 VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean; skipped: boolean } export type VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number } export type VipLabel = { path: string; text: string; label_theme: string; text_color: string; bg_style: number; bg_color: string; border_color: string; use_img_label: boolean; img_label_uri_hans: string; img_label_uri_hant: string; img_label_uri_hans_static: string; img_label_uri_hant_static: string } export type VipOrPay = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }