mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-05-06 20:02:57 +08:00
feat: 后端支持以章节元数据嵌入跳过片段
This commit is contained in:
@@ -21,6 +21,7 @@ pub struct Config {
|
||||
pub download_audio: bool,
|
||||
pub auto_merge: bool,
|
||||
pub embed_chapter: bool,
|
||||
pub embed_skip: bool,
|
||||
pub download_xml_danmaku: bool,
|
||||
pub download_ass_danmaku: bool,
|
||||
pub download_json_danmaku: bool,
|
||||
@@ -106,6 +107,7 @@ impl Config {
|
||||
download_audio: true,
|
||||
auto_merge: true,
|
||||
embed_chapter: true,
|
||||
embed_skip: true,
|
||||
download_xml_danmaku: true,
|
||||
download_ass_danmaku: true,
|
||||
download_json_danmaku: true,
|
||||
|
||||
122
src-tauri/src/downloader/chapter_segments.rs
Normal file
122
src-tauri/src/downloader/chapter_segments.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
pub struct ChapterSegments {
|
||||
pub segments: Vec<ChapterSegment>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChapterSegment {
|
||||
pub title: String,
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
}
|
||||
|
||||
impl ChapterSegments {
|
||||
/// 插入一个新的章节片段
|
||||
///
|
||||
/// 此函数会处理新片段与现有片段的重叠情况:
|
||||
/// - 对于与新片段重叠的现有片段,会将其分割为非重叠的部分
|
||||
/// - 新片段会替换所有重叠区域
|
||||
/// - 最终结果会按开始时间排序
|
||||
///
|
||||
/// # 参数
|
||||
/// * `new_segment` - 要插入的新章节片段
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// // 假设现有片段: [0-10], [20-30]
|
||||
/// // 插入新片段: [5-25]
|
||||
/// // 结果: [0-5], [5-25], [25-30]
|
||||
/// ```
|
||||
pub fn insert(&mut self, new_segment: ChapterSegment) {
|
||||
// 创建一个新的 Vec 来存储处理后的片段
|
||||
// 预分配容量为当前片段数量 + 2,因为最坏情况下每个现有片段可能被分割成两部分,再加上新片段
|
||||
let mut processed_segments = Vec::with_capacity(self.segments.len() + 2);
|
||||
|
||||
for segment in &self.segments {
|
||||
if !Self::overlaps(segment, &new_segment) {
|
||||
// 如果当前片段与新片段没有重叠,直接将当前片段添加到结果中
|
||||
processed_segments.push(segment.clone());
|
||||
continue;
|
||||
}
|
||||
// 如果有重叠,需要分割当前片段,只保留不与新片段重叠的部分
|
||||
|
||||
// 处理左侧部分:当前片段开始到新片段开始之间的部分
|
||||
// left_end 是左侧部分的结束时间,取当前片段结束时间和新片段开始时间的较小值
|
||||
let left_end = segment.end.min(new_segment.start);
|
||||
if segment.start < left_end {
|
||||
// 只有当左侧部分确实存在时(start < end)才添加
|
||||
processed_segments.push(ChapterSegment {
|
||||
title: segment.title.clone(),
|
||||
start: segment.start,
|
||||
end: left_end,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理右侧部分:新片段结束到当前片段结束之间的部分
|
||||
// right_start 是右侧部分的开始时间,取当前片段开始时间和新片段结束时间的较大值
|
||||
let right_start = segment.start.max(new_segment.end);
|
||||
if right_start < segment.end {
|
||||
// 只有当右侧部分确实存在时(start < end)才添加
|
||||
processed_segments.push(ChapterSegment {
|
||||
title: segment.title.clone(),
|
||||
start: right_start,
|
||||
end: segment.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历完所有现有片段并处理完所有重叠后,将新的片段添加到结果列表中
|
||||
processed_segments.push(new_segment);
|
||||
|
||||
processed_segments.sort_by(|a, b| a.start.cmp(&b.start));
|
||||
|
||||
self.segments = processed_segments;
|
||||
}
|
||||
|
||||
pub fn generate_chapter_metadata(&self, video_duration: u64) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
fn write_segment(content: &mut String, title: &str, start: i64, end: i64) {
|
||||
let _ = writeln!(
|
||||
content,
|
||||
"[CHAPTER]\nTIMEBASE=1/1\nSTART={start}\nEND={end}\ntitle={title}\n"
|
||||
);
|
||||
}
|
||||
|
||||
let video_duration = i64::try_from(video_duration).unwrap_or(i64::MAX);
|
||||
|
||||
let mut metadata_content = ";FFMETADATA1\n".to_string();
|
||||
|
||||
let mut last_end = 0;
|
||||
for segment in &self.segments {
|
||||
// 检查当前片段的开始时间与上一个片段的结束时间之间是否有间隙
|
||||
if segment.start > last_end {
|
||||
// 如果有间隙,则插入一个标题为空格的空白片段
|
||||
write_segment(&mut metadata_content, " ", last_end, segment.start);
|
||||
}
|
||||
|
||||
// 写入当前片段
|
||||
write_segment(
|
||||
&mut metadata_content,
|
||||
&segment.title,
|
||||
segment.start,
|
||||
segment.end,
|
||||
);
|
||||
|
||||
// 更新上一个片段的结束时间
|
||||
last_end = segment.end;
|
||||
}
|
||||
|
||||
// 循环结束后,检查最后一个片段的结尾与视频总时长之间是否还有间隙
|
||||
if video_duration > last_end {
|
||||
// 如果有,则填充从 last_end 到视频结尾的剩余部分
|
||||
write_segment(&mut metadata_content, " ", last_end, video_duration);
|
||||
}
|
||||
|
||||
metadata_content
|
||||
}
|
||||
|
||||
/// 检查两个片段是否重叠。
|
||||
fn overlaps(s1: &ChapterSegment, s2: &ChapterSegment) -> bool {
|
||||
s1.start < s1.end && s2.start < s2.end && s1.start < s2.end && s2.start < s1.end
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ use crate::{
|
||||
config::Config,
|
||||
downloader::tasks::{
|
||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
||||
embed_chapter_task::EmbedChapterTask, json_task::JsonTask, merge_task::MergeTask,
|
||||
nfo_task::NfoTask, subtitle_task::SubtitleTask, video_task::VideoTask,
|
||||
embed_chapter_task::EmbedChapterTask, embed_skip_task::EmbedSkipTask, json_task::JsonTask,
|
||||
merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
||||
video_task::VideoTask,
|
||||
},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
@@ -54,6 +55,7 @@ pub struct DownloadProgress {
|
||||
pub audio_task: AudioTask,
|
||||
pub merge_task: MergeTask,
|
||||
pub embed_chapter_task: EmbedChapterTask,
|
||||
pub embed_skip_task: EmbedSkipTask,
|
||||
pub subtitle_task: SubtitleTask,
|
||||
pub danmaku_task: DanmakuTask,
|
||||
pub cover_task: CoverTask,
|
||||
@@ -127,6 +129,7 @@ impl DownloadProgress {
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -179,6 +182,7 @@ impl DownloadProgress {
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -390,6 +394,7 @@ fn create_normal_progresses_for_single(
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -431,6 +436,7 @@ fn create_normal_progresses_for_single(
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -472,6 +478,7 @@ fn create_normal_progresses_for_single(
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
embed_chapter_task: tasks.embed_chapter.clone(),
|
||||
embed_skip_task: tasks.embed_skip.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
@@ -545,6 +552,7 @@ fn create_normal_progresses_for_season(
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -586,6 +594,7 @@ fn create_normal_progresses_for_season(
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
embed_chapter_task: tasks.embed_chapter,
|
||||
embed_skip_task: tasks.embed_skip,
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
@@ -628,6 +637,7 @@ fn create_normal_progresses_for_season(
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
embed_chapter_task: tasks.embed_chapter.clone(),
|
||||
embed_skip_task: tasks.embed_skip.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
@@ -651,6 +661,7 @@ struct Tasks {
|
||||
audio: AudioTask,
|
||||
merge: MergeTask,
|
||||
embed_chapter: EmbedChapterTask,
|
||||
embed_skip: EmbedSkipTask,
|
||||
danmaku: DanmakuTask,
|
||||
subtitle: SubtitleTask,
|
||||
cover: CoverTask,
|
||||
@@ -689,6 +700,11 @@ impl Tasks {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
let embed_skip = EmbedSkipTask {
|
||||
selected: config.embed_skip,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
let danmaku = DanmakuTask {
|
||||
xml_selected: config.download_xml_danmaku,
|
||||
ass_selected: config.download_ass_danmaku,
|
||||
@@ -722,6 +738,7 @@ impl Tasks {
|
||||
audio,
|
||||
merge,
|
||||
embed_chapter,
|
||||
embed_skip,
|
||||
danmaku,
|
||||
subtitle,
|
||||
cover,
|
||||
|
||||
@@ -13,6 +13,7 @@ use tokio::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
downloader::chapter_segments::{ChapterSegment, ChapterSegments},
|
||||
events::DownloadEvent,
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt, GetOrInitPlayerInfo},
|
||||
types::{create_download_task_params::CreateDownloadTaskParams, player_info::PlayerInfo},
|
||||
@@ -280,6 +281,7 @@ impl DownloadTask {
|
||||
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 danmaku_task = &progress.danmaku_task;
|
||||
let subtitle_task = &progress.subtitle_task;
|
||||
let cover_task = &progress.cover_task;
|
||||
@@ -308,27 +310,26 @@ impl DownloadTask {
|
||||
}
|
||||
|
||||
let merge_completed = merge_task.is_completed();
|
||||
let embed_completed = embed_chapter_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命令完成
|
||||
// 如果合并任务和嵌入任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成
|
||||
self.merge_and_embed(&progress, &mut player_info)
|
||||
.await
|
||||
.context(format!(
|
||||
"{ids_string} `{filename}`合并视频和音频失败并+嵌入章节元数据失败"
|
||||
"{ids_string} `{filename}`自动合并+嵌入章节元数据失败"
|
||||
))?;
|
||||
tracing::debug!("{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}`视频和音频合并完成");
|
||||
.context(format!("{ids_string} `{filename}`自动合并失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`自动合并完成");
|
||||
} else if !embed_completed {
|
||||
// 如果嵌入章节任务未完成,合并任务已完成,则只嵌入
|
||||
embed_chapter_task
|
||||
.process(self, &progress, &mut player_info)
|
||||
// 如果嵌入任务未完成,合并任务已完成,则只嵌入
|
||||
self.embed_chapter_segments(&progress, &mut player_info)
|
||||
.await
|
||||
.context(format!("{ids_string} `{filename}`嵌入章节元数据失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`嵌入章节元数据完成");
|
||||
@@ -385,6 +386,47 @@ 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,
|
||||
@@ -395,8 +437,14 @@ impl DownloadTask {
|
||||
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;
|
||||
p.embed_chapter_task.completed = true;
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
@@ -405,9 +453,7 @@ impl DownloadTask {
|
||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if !audio_path.exists() {
|
||||
// 如果音频文件不存在,则只嵌入章节元数据
|
||||
progress
|
||||
.embed_chapter_task
|
||||
.process(self, progress, player_info)
|
||||
self.embed_chapter_segments(progress, player_info)
|
||||
.await
|
||||
.context("嵌入章节元数据失败")?;
|
||||
|
||||
@@ -415,9 +461,8 @@ impl DownloadTask {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let player_info = player_info.get_or_init(&self.app, progress).await?;
|
||||
|
||||
let metadata_content = player_info.generate_chapter_metadata();
|
||||
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)
|
||||
@@ -492,8 +537,111 @@ impl DownloadTask {
|
||||
))?;
|
||||
|
||||
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;
|
||||
p.embed_chapter_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(())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chapter_segments;
|
||||
pub mod download_chunk_task;
|
||||
pub mod download_manager;
|
||||
pub mod download_progress;
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::{
|
||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||
extensions::GetOrInitPlayerInfo,
|
||||
types::player_info::PlayerInfo,
|
||||
utils,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct EmbedChapterTask {
|
||||
pub selected: bool,
|
||||
@@ -21,91 +11,4 @@ impl EmbedChapterTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
download_task: &Arc<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
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.embed_chapter_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 player_info = player_info
|
||||
.get_or_init(&download_task.app, progress)
|
||||
.await?;
|
||||
|
||||
let metadata_content = player_info.generate_chapter_metadata();
|
||||
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.embed_chapter_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
14
src-tauri/src/downloader/tasks/embed_skip_task.rs
Normal file
14
src-tauri/src/downloader/tasks/embed_skip_task.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
|
||||
@@ -44,26 +44,6 @@ pub struct PlayerInfo {
|
||||
pub is_upower_exclusive_with_qa: bool,
|
||||
}
|
||||
|
||||
impl PlayerInfo {
|
||||
pub fn generate_chapter_metadata(&self) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut metadata_content = ";FFMETADATA1\n".to_string();
|
||||
|
||||
for view_point in &self.view_points {
|
||||
let start = view_point.from;
|
||||
let end = view_point.to;
|
||||
let title = &view_point.content;
|
||||
let _ = writeln!(
|
||||
&mut metadata_content,
|
||||
"[CHAPTER]\nTIMEBASE=1/1\nSTART={start}\nEND={end}\ntitle={title}\n"
|
||||
);
|
||||
}
|
||||
|
||||
metadata_content
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct IpInfo {
|
||||
pub ip: String,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::downloader::chapter_segments::ChapterSegment;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct SkipSegments(pub Vec<SkipSegment>);
|
||||
|
||||
@@ -14,8 +16,37 @@ pub struct SkipSegment {
|
||||
#[serde(rename = "UUID")]
|
||||
pub uuid: String,
|
||||
#[serde(rename = "videoDuration")]
|
||||
pub video_duration: i64,
|
||||
pub video_duration: f64,
|
||||
pub locked: i64,
|
||||
pub votes: i64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl SkipSegment {
|
||||
fn get_title(&self) -> Option<String> {
|
||||
match self.category.as_str() {
|
||||
"sponsor" => Some("广告".to_string()),
|
||||
"selfpromo" => Some("无偿/自我推广".to_string()),
|
||||
"exclusive_access" => Some("柔性推广/品牌合作".to_string()),
|
||||
"interaction" => Some("三连/订阅提醒".to_string()),
|
||||
"poi_highlight" => Some("精彩时刻/重点".to_string()),
|
||||
"intro" => Some("过场/开场动画".to_string()),
|
||||
"outro" => Some("鸣谢/结束画面".to_string()),
|
||||
"preview" => Some("回顾/概要".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub fn into_chapter_segment(self) -> Option<ChapterSegment> {
|
||||
if self.segment.len() < 2 {
|
||||
return None; // 确保 segment 包含开始和结束时间
|
||||
}
|
||||
|
||||
Some(ChapterSegment {
|
||||
title: self.get_title()?,
|
||||
start: self.segment[0] as i64,
|
||||
end: self.segment[1] as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user