refactor: 将自动合并任务与嵌入章节任务合并成视频处理任务

This commit is contained in:
lanyeeee
2025-09-02 04:40:51 +08:00
parent 2ba7504359
commit 8372b05143
10 changed files with 422 additions and 504 deletions
+19 -52
View File
@@ -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,
+9 -294
View File
@@ -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<Self>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> anyhow::Result<ChapterSegments> {
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<Self>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> 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<Self>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> 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;
@@ -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
}
}
@@ -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
}
}
@@ -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<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.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(())
}
}
+1 -3
View File
@@ -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;
@@ -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<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 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<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)
.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<DownloadTask>,
progress: &DownloadProgress,
player_info: &mut Option<PlayerInfo>,
) -> 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<PlayerInfo>,
) -> anyhow::Result<ChapterSegments> {
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)
}
}