diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index 25e3a28..1884fb1 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -13,9 +13,8 @@ use crate::{ config::Config, downloader::tasks::{ audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask, - embed_chapter_task::EmbedChapterTask, embed_skip_task::EmbedSkipTask, 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::{ @@ -53,9 +52,7 @@ pub struct DownloadProgress { pub filename: String, pub video_task: VideoTask, pub audio_task: AudioTask, - pub merge_task: MergeTask, - pub embed_chapter_task: EmbedChapterTask, - pub embed_skip_task: EmbedSkipTask, + pub video_process_task: VideoProcessTask, pub subtitle_task: SubtitleTask, pub danmaku_task: DanmakuTask, pub cover_task: CoverTask, @@ -127,9 +124,7 @@ impl DownloadProgress { filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -180,9 +175,7 @@ impl DownloadProgress { filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -326,8 +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.embed_chapter_task.is_completed() + && self.video_process_task.is_completed() && self.danmaku_task.is_completed() && self.subtitle_task.is_completed() && self.cover_task.is_completed() @@ -338,8 +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.embed_chapter_task.completed = false; + self.video_process_task.completed = false; self.danmaku_task.completed = false; self.subtitle_task.completed = false; self.cover_task.completed = false; @@ -392,9 +383,7 @@ fn create_normal_progresses_for_single( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -434,9 +423,7 @@ fn create_normal_progresses_for_single( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -476,9 +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(), - embed_chapter_task: tasks.embed_chapter.clone(), - embed_skip_task: tasks.embed_skip.clone(), + video_process_task: tasks.video_process.clone(), danmaku_task: tasks.danmaku.clone(), subtitle_task: tasks.subtitle.clone(), cover_task: tasks.cover.clone(), @@ -550,9 +535,7 @@ fn create_normal_progresses_for_season( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -592,9 +575,7 @@ fn create_normal_progresses_for_season( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, - embed_chapter_task: tasks.embed_chapter, - embed_skip_task: tasks.embed_skip, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -635,9 +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(), - embed_chapter_task: tasks.embed_chapter.clone(), - embed_skip_task: tasks.embed_skip.clone(), + video_process_task: tasks.video_process.clone(), danmaku_task: tasks.danmaku.clone(), subtitle_task: tasks.subtitle.clone(), cover_task: tasks.cover.clone(), @@ -659,9 +638,7 @@ fn create_normal_progresses_for_season( struct Tasks { video: VideoTask, audio: AudioTask, - merge: MergeTask, - embed_chapter: EmbedChapterTask, - embed_skip: EmbedSkipTask, + video_process: VideoProcessTask, danmaku: DanmakuTask, subtitle: SubtitleTask, cover: CoverTask, @@ -690,18 +667,10 @@ impl Tasks { completed: false, }; - let merge = MergeTask { - selected: config.auto_merge, - completed: false, - }; - - let embed_chapter = EmbedChapterTask { - selected: config.embed_chapter, - completed: false, - }; - - let embed_skip = EmbedSkipTask { - selected: config.embed_skip, + let video_process = VideoProcessTask { + merge_selected: config.auto_merge, + embed_chapter_selected: config.embed_chapter, + embed_skip_selected: config.embed_skip, completed: false, }; @@ -736,9 +705,7 @@ impl Tasks { Self { video, audio, - merge, - embed_chapter, - embed_skip, + video_process, danmaku, subtitle, cover, diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index d0d1e76..f1e3cd9 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -3,7 +3,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -use anyhow::{anyhow, Context}; +use anyhow::Context; use parking_lot::RwLock; use tauri::AppHandle; use tauri_specta::Event; @@ -13,11 +13,9 @@ use tokio::{ }; use crate::{ - downloader::chapter_segments::{ChapterSegment, ChapterSegments}, events::DownloadEvent, - extensions::{AnyhowErrorToStringChain, AppHandleExt, GetOrInitPlayerInfo}, - types::{create_download_task_params::CreateDownloadTaskParams, player_info::PlayerInfo}, - utils::{self}, + extensions::{AnyhowErrorToStringChain, AppHandleExt}, + types::create_download_task_params::CreateDownloadTaskParams, }; use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState}; @@ -279,9 +277,7 @@ impl DownloadTask { let video_task = &progress.video_task; let audio_task = &progress.audio_task; - let merge_task = &progress.merge_task; - let embed_chapter_task = &progress.embed_chapter_task; - let embed_skip_task = &progress.embed_skip_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; @@ -292,7 +288,6 @@ impl DownloadTask { let mut episode_info = None; if !video_task.is_completed() && video_task.content_length != 0 { - // 如果视频任务被选中且未完成且有要下载的内容,则下载视频 video_task .process(self, &progress) .await @@ -301,7 +296,6 @@ impl DownloadTask { } if !audio_task.is_completed() && audio_task.content_length != 0 { - // 如果音频任务被选中且未完成且有要下载的内容,则下载音频 audio_task .process(self, &progress) .await @@ -309,30 +303,12 @@ impl DownloadTask { tracing::debug!("{ids_string} `{filename}`音频下载完成"); } - let merge_completed = merge_task.is_completed(); - let embed_completed = embed_chapter_task.is_completed() && embed_skip_task.is_completed(); - - if !merge_completed && !embed_completed { - // 如果合并任务和嵌入任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成 - self.merge_and_embed(&progress, &mut player_info) + 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}`自动合并+嵌入章节元数据完成"); - } else if !merge_completed { - // 如果合并任务未完成,嵌入任务已完成,则只合并 - merge_task - .process(self, &progress) - .await - .context(format!("{ids_string} `{filename}`自动合并失败"))?; - tracing::debug!("{ids_string} `{filename}`自动合并完成"); - } else if !embed_completed { - // 如果嵌入任务未完成,合并任务已完成,则只嵌入 - self.embed_chapter_segments(&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 !danmaku_task.is_completed() { @@ -386,267 +362,6 @@ impl DownloadTask { Ok(()) } - async fn create_chapter_segments( - self: &Arc, - progress: &DownloadProgress, - player_info: &mut Option, - ) -> anyhow::Result { - let mut chapter_segments = ChapterSegments { - segments: Vec::new(), - }; - - if !progress.embed_chapter_task.is_completed() { - let player_info = player_info.get_or_init(&self.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 !progress.embed_skip_task.is_completed() { - let bili_client = self.app.get_bili_client(); - let Some(bvid) = &progress.bvid else { - return Ok(chapter_segments); - }; - 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); - } - } - } - - Ok(chapter_segments) - } - - async fn merge_and_embed( - self: &Arc, - progress: &DownloadProgress, - player_info: &mut Option, - ) -> 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| { - if !p.embed_chapter_task.is_completed() { - p.embed_chapter_task.completed = true; - } - if !p.embed_skip_task.is_completed() { - p.embed_skip_task.completed = true; - } - - p.merge_task.completed = true; - }); - - return Ok(()); - } - - let audio_path = episode_dir.join(format!("{filename}.m4a")); - if !audio_path.exists() { - // 如果音频文件不存在,则只嵌入章节元数据 - self.embed_chapter_segments(progress, player_info) - .await - .context("嵌入章节元数据失败")?; - - self.update_progress(|p| p.merge_task.completed = true); - return Ok(()); - } - - let chapter_segments = self.create_chapter_segments(progress, player_info).await?; - let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration); - let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); - - std::fs::write(&metadata_path, metadata_content) - .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; - - 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 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) - .arg("-i") - .arg(audio_path_clone) - .arg("-i") - .arg(metadata_path_clone) - .arg("-map_metadata") - .arg("2") - .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() - ))?; - std::fs::remove_file(&metadata_path).context(format!( - "删除章节元数据文件`{}`失败", - metadata_path.display() - ))?; - - self.update_progress(|p| { - if !p.embed_chapter_task.is_completed() { - p.embed_chapter_task.completed = true; - } - if !p.embed_skip_task.is_completed() { - p.embed_skip_task.completed = true; - } - - p.merge_task.completed = true; - }); - - Ok(()) - } - - async fn embed_chapter_segments( - self: &Arc, - progress: &DownloadProgress, - player_info: &mut Option, - ) -> 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| { - if !p.embed_chapter_task.is_completed() { - p.embed_chapter_task.completed = true; - } - if !p.embed_skip_task.is_completed() { - p.embed_skip_task.completed = true; - } - }); - return Ok(()); - } - - let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; - let output_path = episode_dir.join(format!("{filename}-embed.mp4")); - - let chapter_segments = self.create_chapter_segments(progress, player_info).await?; - let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration); - let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); - - std::fs::write(&metadata_path, metadata_content) - .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; - - 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) - .arg("-i") - .arg(metadata_path_clone) - .arg("-map_metadata") - .arg("1") - .arg("-c") - .arg("copy") - .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() - ))?; - - self.update_progress(|p| { - if !p.embed_chapter_task.is_completed() { - p.embed_chapter_task.completed = true; - } - if !p.embed_skip_task.is_completed() { - p.embed_skip_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; diff --git a/src-tauri/src/downloader/tasks/embed_chapter_task.rs b/src-tauri/src/downloader/tasks/embed_chapter_task.rs deleted file mode 100644 index 5e5cf31..0000000 --- a/src-tauri/src/downloader/tasks/embed_chapter_task.rs +++ /dev/null @@ -1,14 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct EmbedChapterTask { - pub selected: bool, - pub completed: bool, -} - -impl EmbedChapterTask { - pub fn is_completed(&self) -> bool { - !self.selected || self.completed - } -} diff --git a/src-tauri/src/downloader/tasks/embed_skip_task.rs b/src-tauri/src/downloader/tasks/embed_skip_task.rs deleted file mode 100644 index 7584641..0000000 --- a/src-tauri/src/downloader/tasks/embed_skip_task.rs +++ /dev/null @@ -1,14 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct EmbedSkipTask { - pub selected: bool, - pub completed: bool, -} - -impl EmbedSkipTask { - pub fn is_completed(&self) -> bool { - !self.selected || self.completed - } -} diff --git a/src-tauri/src/downloader/tasks/merge_task.rs b/src-tauri/src/downloader/tasks/merge_task.rs deleted file mode 100644 index 3e959b6..0000000 --- a/src-tauri/src/downloader/tasks/merge_task.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::sync::Arc; - -use anyhow::{anyhow, Context}; -use serde::{Deserialize, Serialize}; -use specta::Type; - -use crate::{ - downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, - utils, -}; - -#[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 - } - - pub async fn process( - &self, - download_task: &Arc, - 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.merge_task.completed = true); - return Ok(()); - } - - let audio_path = episode_dir.join(format!("{filename}.m4a")); - if !audio_path.exists() { - download_task.update_progress(|p| p.merge_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) - .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() - ))?; - - download_task.update_progress(|p| p.merge_task.completed = true); - - Ok(()) - } -} diff --git a/src-tauri/src/downloader/tasks/mod.rs b/src-tauri/src/downloader/tasks/mod.rs index bf88d4f..1c5444c 100644 --- a/src-tauri/src/downloader/tasks/mod.rs +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -1,10 +1,8 @@ pub mod audio_task; pub mod cover_task; pub mod danmaku_task; -pub mod embed_chapter_task; -pub mod embed_skip_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; diff --git a/src-tauri/src/downloader/tasks/video_process_task.rs b/src-tauri/src/downloader/tasks/video_process_task.rs new file mode 100644 index 0000000..7482085 --- /dev/null +++ b/src-tauri/src/downloader/tasks/video_process_task.rs @@ -0,0 +1,375 @@ +use std::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, + progress: &DownloadProgress, + player_info: &mut Option, + ) -> 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, + progress: &DownloadProgress, + player_info: &mut Option, + ) -> 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() { + // 如果音频文件不存在,则只嵌入章节元数据 + self.embed(download_task, progress, player_info) + .await + .context("嵌入章节元数据失败")?; + return Ok(()); + } + + let chapter_segments = self + .create_chapter_segments(&download_task.app, progress, player_info) + .await?; + let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration); + let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); + + std::fs::write(&metadata_path, metadata_content) + .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; + + 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 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) + .arg("-i") + .arg(audio_path_clone) + .arg("-i") + .arg(metadata_path_clone) + .arg("-map_metadata") + .arg("2") + .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() + ))?; + 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, + 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) + .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() + ))?; + + download_task.update_progress(|p| p.video_process_task.completed = true); + + Ok(()) + } + + async fn embed( + &self, + download_task: &Arc, + progress: &DownloadProgress, + player_info: &mut Option, + ) -> 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 ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; + let output_path = episode_dir.join(format!("{filename}-embed.mp4")); + + let chapter_segments = self + .create_chapter_segments(&download_task.app, progress, player_info) + .await?; + let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration); + let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); + + std::fs::write(&metadata_path, metadata_content) + .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; + + 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) + .arg("-i") + .arg(metadata_path_clone) + .arg("-map_metadata") + .arg("1") + .arg("-c") + .arg("copy") + .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_segments( + &self, + app: &AppHandle, + progress: &DownloadProgress, + player_info: &mut Option, + ) -> anyhow::Result { + 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 self.embed_skip_selected { + let bili_client = app.get_bili_client(); + let Some(bvid) = &progress.bvid else { + return Ok(chapter_segments); + }; + 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); + } + } + } + + Ok(chapter_segments) + } +} diff --git a/src/bindings.ts b/src/bindings.ts index be6ef8b..0a65c08 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -263,11 +263,9 @@ 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; embed_chapter_task: EmbedChapterTask; embed_skip_task: EmbedSkipTask; 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 EmbedChapterTask = { selected: boolean; completed: boolean } -export type EmbedSkipTask = { selected: boolean; completed: boolean } 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 } @@ -308,7 +306,6 @@ 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 | 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 } @@ -394,6 +391,7 @@ 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 } diff --git a/src/panes/DownloadPane/DownloadPane.vue b/src/panes/DownloadPane/DownloadPane.vue index 02bea04..9c9a0d2 100644 --- a/src/panes/DownloadPane/DownloadPane.vue +++ b/src/panes/DownloadPane/DownloadPane.vue @@ -96,9 +96,7 @@ onMounted(async () => { const videoTask = progressData.video_task const audioTask = progressData.audio_task - const mergeTask = progressData.merge_task - const embedChapterTask = progressData.embed_chapter_task - const embedSkipTask = progressData.embed_skip_task + const videoProcessTask = progressData.video_process_task const danmakuTask = progressData.danmaku_task const subtitleTask = progressData.subtitle_task const coverTask = progressData.cover_task @@ -106,9 +104,6 @@ onMounted(async () => { const jsonTask = progressData.json_task const danmakuSelected = danmakuTask.xml_selected || danmakuTask.ass_selected || danmakuTask.json_selected - const embedding = - (embedChapterTask.selected && !embedChapterTask.completed) || - (embedSkipTask.selected && !embedSkipTask.completed) if (videoTask.selected && !videoTask.completed && videoTask.content_length > 0) { const chunkCount = progressData.video_task.chunks.length @@ -120,15 +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 && embedding) { - progressData.percentage = 100 - progressData.taskIndicator = '自动合并+嵌入章节元数据' - } else if (mergeTask.selected && !mergeTask.completed) { - progressData.percentage = 100 - progressData.taskIndicator = '自动合并' - } else if (embedding) { - 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 = '弹幕' diff --git a/src/panes/DownloadPane/components/DownloadProgress.vue b/src/panes/DownloadPane/components/DownloadProgress.vue index cfc4299..44a9c6c 100644 --- a/src/panes/DownloadPane/components/DownloadProgress.vue +++ b/src/panes/DownloadPane/components/DownloadProgress.vue @@ -161,9 +161,9 @@ function handleSearchClick() { 音频(音质:{{ p.audio_task.audio_quality }}) - 自动合并 - 标记章节 - 标记广告 + 自动合并 + 标记章节 + 标记广告 xml弹幕 ass弹幕