From 39b735e525f16054da57f75e8a247646f8f83edf Mon Sep 17 00:00:00 2001 From: lanyeeee Date: Sun, 31 Aug 2025 04:35:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BB=A5=E7=AB=A0=E8=8A=82=E5=85=83=E6=95=B0=E6=8D=AE=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E8=B7=B3=E8=BF=87=E7=89=87=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/config.rs | 2 + src-tauri/src/downloader/chapter_segments.rs | 122 ++++++++++++ src-tauri/src/downloader/download_progress.rs | 21 +- src-tauri/src/downloader/download_task.rs | 184 ++++++++++++++++-- src-tauri/src/downloader/mod.rs | 1 + .../downloader/tasks/embed_chapter_task.rs | 97 --------- .../src/downloader/tasks/embed_skip_task.rs | 14 ++ src-tauri/src/downloader/tasks/mod.rs | 1 + src-tauri/src/types/player_info.rs | 20 -- src-tauri/src/types/skip_segments.rs | 33 +++- src/bindings.ts | 5 +- 11 files changed, 360 insertions(+), 140 deletions(-) create mode 100644 src-tauri/src/downloader/chapter_segments.rs create mode 100644 src-tauri/src/downloader/tasks/embed_skip_task.rs diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 2496b25..779c0ae 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -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, diff --git a/src-tauri/src/downloader/chapter_segments.rs b/src-tauri/src/downloader/chapter_segments.rs new file mode 100644 index 0000000..7c4b82a --- /dev/null +++ b/src-tauri/src/downloader/chapter_segments.rs @@ -0,0 +1,122 @@ +pub struct ChapterSegments { + pub segments: Vec, +} + +#[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 + } +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index 6b17a5b..25e3a28 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -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, diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index 191bffa..d0d1e76 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -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, + 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, @@ -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, + 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(()) diff --git a/src-tauri/src/downloader/mod.rs b/src-tauri/src/downloader/mod.rs index 73822a2..1b7cb72 100644 --- a/src-tauri/src/downloader/mod.rs +++ b/src-tauri/src/downloader/mod.rs @@ -1,3 +1,4 @@ +pub mod chapter_segments; pub mod download_chunk_task; pub mod download_manager; pub mod download_progress; diff --git a/src-tauri/src/downloader/tasks/embed_chapter_task.rs b/src-tauri/src/downloader/tasks/embed_chapter_task.rs index cd9f075..5e5cf31 100644 --- a/src-tauri/src/downloader/tasks/embed_chapter_task.rs +++ b/src-tauri/src/downloader/tasks/embed_chapter_task.rs @@ -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, - 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.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(()) - } } diff --git a/src-tauri/src/downloader/tasks/embed_skip_task.rs b/src-tauri/src/downloader/tasks/embed_skip_task.rs new file mode 100644 index 0000000..7584641 --- /dev/null +++ b/src-tauri/src/downloader/tasks/embed_skip_task.rs @@ -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 + } +} diff --git a/src-tauri/src/downloader/tasks/mod.rs b/src-tauri/src/downloader/tasks/mod.rs index ed66dad..bf88d4f 100644 --- a/src-tauri/src/downloader/tasks/mod.rs +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -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; diff --git a/src-tauri/src/types/player_info.rs b/src-tauri/src/types/player_info.rs index 307bd93..7f76731 100644 --- a/src-tauri/src/types/player_info.rs +++ b/src-tauri/src/types/player_info.rs @@ -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, diff --git a/src-tauri/src/types/skip_segments.rs b/src-tauri/src/types/skip_segments.rs index df45b1d..f657e39 100644 --- a/src-tauri/src/types/skip_segments.rs +++ b/src-tauri/src/types/skip_segments.rs @@ -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); @@ -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 { + 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 { + 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, + }) + } +} diff --git a/src/bindings.ts b/src/bindings.ts index d29bee7..be6ef8b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -247,7 +247,7 @@ export type CntInfo = { collect: number; play: number; thumb_up: number; share: export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string } export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1" export type CommandError = { err_title: string; err_message: string } -export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig } +export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; embed_skip: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig } export type Consulting = { consulting_flag: boolean; consulting_url: string } export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg } export type ContentList = { bold: boolean; content: string; number: string } @@ -263,10 +263,11 @@ 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; 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; 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 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 }