diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6fbbecf..1db3dec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -286,6 +286,7 @@ name = "bilibili-video-downloader" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "byteorder", "bytes", "chrono", @@ -297,6 +298,7 @@ dependencies = [ "num_enum", "parking_lot 0.12.4", "prost", + "rand 0.9.1", "reqwest", "reqwest-middleware", "reqwest-retry", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ebe500..7e7aaaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,8 @@ yaserde = { version = "0.12.0", features = ["yaserde_derive"] } float-ord = { version = "0.3.2" } memchr = { version = "2.7.5" } md-5 = { version = "0.10.6" } +rand = { version = "0.9.1" } +base64 = { version = "0.22.1" } [profile.release] strip = true diff --git a/src-tauri/src/bili_client.rs b/src-tauri/src/bili_client.rs index fd06455..89f4c1d 100644 --- a/src-tauri/src/bili_client.rs +++ b/src-tauri/src/bili_client.rs @@ -1,6 +1,7 @@ use std::time::Duration; use anyhow::{anyhow, Context}; +use base64::{engine::general_purpose, Engine}; use bytes::Bytes; use parking_lot::RwLock; use prost::Message; @@ -20,14 +21,16 @@ use crate::{ extensions::{AnyhowErrorToStringChain, AppHandleExt}, protobuf::DmSegMobileReply, types::{ - bangumi_info::BangumiInfo, bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo, + bangumi_follow_info::BangumiFollowInfo, bangumi_info::BangumiInfo, + bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo, cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo, + get_bangumi_follow_info_params::GetBangumiFollowInfoParams, get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams, get_user_video_info_params::GetUserVideoInfoParams, normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo, qrcode_data::QrcodeData, - qrcode_status::QrcodeStatus, subtitle::Subtitle, tags::Tags, user_info::UserInfo, - user_video_info::UserVideoInfo, watch_later_info::WatchLaterInfo, + qrcode_status::QrcodeStatus, skip_segments::SkipSegments, subtitle::Subtitle, tags::Tags, + user_info::UserInfo, user_video_info::UserVideoInfo, watch_later_info::WatchLaterInfo, }, }; @@ -296,10 +299,28 @@ impl BiliClient { &self, params: GetUserVideoInfoParams, ) -> anyhow::Result { + const DM_IMG_INTER: &str = r#"{"ds":[],"wh":[0,0,0],"of":[0,0,0]}"#; + + fn random_base64() -> String { + let random_bytes: Vec = (0..48).map(|_| rand::random_range(32..=127)).collect(); + + general_purpose::STANDARD.encode(&random_bytes) + } + + let mut dm_img_str = random_base64(); + dm_img_str.truncate(dm_img_str.len() - 2); + + let mut dm_cover_img_str = random_base64(); + dm_cover_img_str.truncate(dm_cover_img_str.len() - 2); + let mut params: Vec<(&str, String)> = vec![ ("pn", params.pn.to_string()), ("ps", "42".to_string()), ("mid", params.mid.to_string()), + ("dm_img_list", "[]".to_string()), + ("dm_img_str", dm_img_str), + ("dm_cover_img_str", dm_cover_img_str), + ("dm_img_inter", DM_IMG_INTER.to_string()), ]; self.wbi(&mut params).await?; @@ -607,6 +628,50 @@ impl BiliClient { Ok(watch_later_info) } + pub async fn get_bangumi_follow_info( + &self, + params: GetBangumiFollowInfoParams, + ) -> anyhow::Result { + // 发送获取番剧追踪信息的请求 + let params = json!({ + "vmid": params.vmid, + "type": params.type_field, + "pn": params.pn, + "ps": 24, + "follow_status": params.follow_status, + }); + let request = self + .api_client + .read() + .get("https://api.bilibili.com/x/space/bangumi/follow/list") + .query(¶ms) + .header("cookie", self.get_cookie()); + let http_resp = request.send().await?; + // 检查http响应状态码 + let status = http_resp.status(); + let body = http_resp.text().await?; + if status != StatusCode::OK { + return Err(anyhow!("预料之外的状态码({status}): {body}")); + } + // 尝试将body解析为BiliResp + let bili_resp: BiliResp = + serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?; + // 检查BiliResp的code字段 + if bili_resp.code != 0 { + return Err(anyhow!("预料之外的code: {bili_resp:?}")); + } + // 检查BiliResp的data是否存在 + let Some(data) = bili_resp.data else { + return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}")); + }; + // 尝试将data解析为BangumiFollowInfo + let data_str = data.to_string(); + let bangumi_follow_info: BangumiFollowInfo = serde_json::from_str(&data_str) + .context(format!("将data解析为BangumiFollowInfo失败: {data_str}"))?; + + Ok(bangumi_follow_info) + } + pub async fn get_media_chunk( &self, media_url: &str, @@ -804,6 +869,41 @@ impl BiliClient { Ok(tags) } + pub async fn get_skip_segments( + &self, + bvid: &str, + cid: Option, + ) -> anyhow::Result { + // 发送获取跳过片段的请求 + let mut params = json!({ + "videoID": bvid, + "actionType": "skip", + }); + if let Some(cid) = cid { + params["cid"] = cid.into(); + } + + let request = self + .api_client + .read() + .get("https://bsbsb.top/api/skipSegments") + .query(¶ms); + let http_resp = request.send().await?; + // 检查http响应状态码 + let status = http_resp.status(); + let body = http_resp.text().await?; + if status == StatusCode::NOT_FOUND { + return Ok(SkipSegments(Vec::new())); + } else if status != StatusCode::OK { + return Err(anyhow!("预料之外的状态码({status}): {body}")); + } + // 尝试将body解析为SkipSegments + let skip_segments: SkipSegments = + serde_json::from_str(&body).context(format!("将body解析为SkipSegments失败: {body}"))?; + + Ok(skip_segments) + } + pub fn get_cookie(&self) -> String { let sessdata = self.app.get_config().read().sessdata.clone(); format!("SESSDATA={sessdata}") diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3667fdd..f19f14e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,10 +9,12 @@ use crate::{ extensions::AppHandleExt, logger, types::{ - bangumi_info::EpInBangumi, + bangumi_follow_info::BangumiFollowInfo, + bangumi_info::{BangumiInfo, EpInBangumi}, create_download_task_params::CreateDownloadTaskParams, fav_folders::FavFolders, fav_info::FavInfo, + get_bangumi_follow_info_params::GetBangumiFollowInfoParams, get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, get_fav_info_params::GetFavInfoParams, @@ -26,6 +28,7 @@ use crate::{ BangumiSearchResult, CheeseSearchResult, FavSearchResult, NormalSearchResult, SearchResult, UserVideoSearchResult, }, + skip_segments::SkipSegments, user_info::UserInfo, user_video_info::UserVideoInfo, watch_later_info::WatchLaterInfo, @@ -118,6 +121,20 @@ pub async fn get_user_info(app: AppHandle, sessdata: String) -> CommandResult CommandResult { + let bili_client = app.get_bili_client(); + let bangumi_info = bili_client + .get_bangumi_info(params) + .await + .map_err(|err| CommandError::from("获取番剧视频信息失败", err))?; + Ok(bangumi_info) +} + #[tauri::command(async)] #[specta::specta] pub async fn get_normal_info( @@ -179,6 +196,20 @@ pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult CommandResult { + let bili_client = app.get_bili_client(); + let bangumi_follow_info = bili_client + .get_bangumi_follow_info(params) + .await + .map_err(|err| CommandError::from("获取追番信息失败", err))?; + Ok(bangumi_follow_info) +} + #[allow(clippy::needless_pass_by_value)] #[tauri::command(async)] #[specta::specta] @@ -331,3 +362,18 @@ pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<() .map_err(|err| CommandError::from("在文件管理器中打开失败", err))?; Ok(()) } + +#[tauri::command(async)] +#[specta::specta] +pub async fn get_skip_segments( + app: AppHandle, + bvid: String, + cid: Option, +) -> CommandResult { + let bili_client = app.get_bili_client(); + let skip_segments = bili_client + .get_skip_segments(&bvid, cid) + .await + .map_err(|err| CommandError::from("获取跳过片段失败", err))?; + Ok(skip_segments) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 84cdfc9..779c0ae 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -20,6 +20,8 @@ pub struct Config { pub download_video: bool, 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, @@ -104,6 +106,8 @@ impl Config { download_video: true, 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_chunk_task.rs b/src-tauri/src/downloader/download_chunk_task.rs new file mode 100644 index 0000000..a514c89 --- /dev/null +++ b/src-tauri/src/downloader/download_chunk_task.rs @@ -0,0 +1,118 @@ +use std::{ + fs::File, + io::{Seek, Write}, + sync::Arc, + time::Duration, +}; + +use parking_lot::Mutex; +use tokio::{sync::SemaphorePermit, time::sleep}; + +use crate::{ + downloader::{download_task::DownloadTask, download_task_state::DownloadTaskState}, + extensions::AppHandleExt, +}; + +pub struct DownloadChunkTask { + pub download_task: Arc, + pub start: u64, + pub end: u64, + pub url: String, + pub file: Arc>, + pub chunk_index: usize, +} + +impl DownloadChunkTask { + pub async fn process(self) -> anyhow::Result { + let download_chunk_task = self.download_chunk(); + tokio::pin!(download_chunk_task); + + let mut state_receiver = self.download_task.state_sender.subscribe(); + state_receiver.mark_changed(); + + let mut restart_receiver = self.download_task.restart_sender.subscribe(); + let mut delete_receiver = self.download_task.delete_sender.subscribe(); + + let mut permit = None; + + loop { + let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading; + tokio::select! { + result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result, + + result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => { + match result { + Ok(()) => {}, + Err(err) => break Err(err), + } + }, + + _ = state_receiver.changed() => { + if *state_receiver.borrow() == DownloadTaskState::Paused { + // 稍微等一下再释放permit + sleep(Duration::from_millis(100)).await; + if let Some(permit) = permit.take() { + drop(permit); + }; + } + }, + + _ = restart_receiver.changed() => break Ok(self.chunk_index), + + _ = delete_receiver.changed() => break Ok(self.chunk_index), + } + } + } + + async fn download_chunk(&self) -> anyhow::Result { + let bili_client = self.download_task.app.get_bili_client(); + let chunk_data = bili_client + .get_media_chunk(&self.url, self.start, self.end) + .await?; + + let len = chunk_data.len() as u64; + self.download_task + .app + .get_download_manager() + .byte_per_sec + .fetch_add(len, std::sync::atomic::Ordering::Relaxed); + // 将下载的内容写入文件 + { + let mut file = self.file.lock(); + file.seek(std::io::SeekFrom::Start(self.start))?; + file.write_all(&chunk_data)?; + } + + let chunk_download_interval_sec = self + .download_task + .app + .get_config() + .read() + .chunk_download_interval_sec; + sleep(Duration::from_secs(chunk_download_interval_sec)).await; + + Ok(self.chunk_index) + } + + async fn acquire_chunk_permit<'a>( + &'a self, + permit: &mut Option>, + ) -> anyhow::Result<()> { + *permit = match permit.take() { + // 如果有permit,则直接用 + Some(permit) => Some(permit), + // 如果没有permit,则获取permit + None => Some( + self.download_task + .app + .get_download_manager() + .inner() + .media_chunk_sem + .acquire() + .await?, + ), + }; + + Ok(()) + } +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index ad1f31e..1884fb1 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -13,8 +13,8 @@ use crate::{ config::Config, downloader::tasks::{ audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask, - 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::{ @@ -52,7 +52,7 @@ pub struct DownloadProgress { pub filename: String, pub video_task: VideoTask, pub audio_task: AudioTask, - pub merge_task: MergeTask, + pub video_process_task: VideoProcessTask, pub subtitle_task: SubtitleTask, pub danmaku_task: DanmakuTask, pub cover_task: CoverTask, @@ -124,7 +124,7 @@ impl DownloadProgress { filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -175,7 +175,7 @@ impl DownloadProgress { filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -297,15 +297,19 @@ impl DownloadProgress { } } - pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { + pub fn save(&self, app: &AppHandle, allow_create: bool) -> anyhow::Result<()> { let progress = self.clone(); let file_name = format!("{}.json", progress.task_id); let app_data_dir = app.path().app_data_dir()?; - let task_dir = app_data_dir.join(".下载任务"); - std::fs::create_dir_all(&task_dir)?; + let tasks_dir = app_data_dir.join(".下载任务"); + std::fs::create_dir_all(&tasks_dir)?; + + let save_path = tasks_dir.join(file_name); + if !allow_create && !save_path.exists() { + return Ok(()); + } - let save_path = task_dir.join(file_name); let progress_json = serde_json::to_string(&progress)?; std::fs::write(save_path, progress_json)?; @@ -315,7 +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.video_process_task.is_completed() && self.danmaku_task.is_completed() && self.subtitle_task.is_completed() && self.cover_task.is_completed() @@ -326,7 +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.video_process_task.completed = false; self.danmaku_task.completed = false; self.subtitle_task.completed = false; self.cover_task.completed = false; @@ -379,7 +383,7 @@ fn create_normal_progresses_for_single( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -419,7 +423,7 @@ fn create_normal_progresses_for_single( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -459,7 +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(), + video_process_task: tasks.video_process.clone(), danmaku_task: tasks.danmaku.clone(), subtitle_task: tasks.subtitle.clone(), cover_task: tasks.cover.clone(), @@ -531,7 +535,7 @@ fn create_normal_progresses_for_season( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -571,7 +575,7 @@ fn create_normal_progresses_for_season( filename: String::new(), video_task: tasks.video, audio_task: tasks.audio, - merge_task: tasks.merge, + video_process_task: tasks.video_process, danmaku_task: tasks.danmaku, subtitle_task: tasks.subtitle, cover_task: tasks.cover, @@ -612,7 +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(), + video_process_task: tasks.video_process.clone(), danmaku_task: tasks.danmaku.clone(), subtitle_task: tasks.subtitle.clone(), cover_task: tasks.cover.clone(), @@ -634,7 +638,7 @@ fn create_normal_progresses_for_season( struct Tasks { video: VideoTask, audio: AudioTask, - merge: MergeTask, + video_process: VideoProcessTask, danmaku: DanmakuTask, subtitle: SubtitleTask, cover: CoverTask, @@ -663,8 +667,10 @@ impl Tasks { completed: false, }; - let merge = MergeTask { - selected: config.auto_merge, + let video_process = VideoProcessTask { + merge_selected: config.auto_merge, + embed_chapter_selected: config.embed_chapter, + embed_skip_selected: config.embed_skip, completed: false, }; @@ -699,7 +705,7 @@ impl Tasks { Self { video, audio, - merge, + video_process, danmaku, subtitle, cover, diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index 79bf91c..f1e3cd9 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -1,34 +1,21 @@ use std::{ - fs::{File, OpenOptions}, - io::{Seek, Write}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use anyhow::{anyhow, Context}; -use fs4::fs_std::FileExt; -use parking_lot::{Mutex, RwLock}; +use anyhow::Context; +use parking_lot::RwLock; use tauri::AppHandle; use tauri_specta::Event; use tokio::{ sync::{watch, SemaphorePermit}, - task::JoinSet, time::sleep, }; use crate::{ - bili_client::BiliClient, - danmaku_xml_to_ass::xml_to_ass, - downloader::episode_type::EpisodeType, events::DownloadEvent, extensions::{AnyhowErrorToStringChain, AppHandleExt}, - types::{ - bangumi_info::BangumiInfo, cheese_info::CheeseInfo, - create_download_task_params::CreateDownloadTaskParams, - get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, - get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo, - }, - utils::{self, ToXml}, + types::create_download_task_params::CreateDownloadTaskParams, }; use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState}; @@ -103,7 +90,7 @@ impl DownloadTask { let mut tasks = Vec::new(); for progress in progresses { - if let Err(err) = progress.save(app) { + if let Err(err) = progress.save(app, true) { let ids_string = progress.get_ids_string(); let episode_title = &progress.episode_title; let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败"); @@ -288,61 +275,77 @@ impl DownloadTask { episode_dir.display() ))?; - if !progress.video_task.is_completed() && progress.video_task.content_length != 0 { - // 如果视频任务被选中且未完成且有要下载的内容,则下载视频 - self.download_video(&progress) + let video_task = &progress.video_task; + let audio_task = &progress.audio_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; + let nfo_task = &progress.nfo_task; + let json_task = &progress.json_task; + + let mut player_info = None; + let mut episode_info = None; + + if !video_task.is_completed() && video_task.content_length != 0 { + video_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载视频文件失败"))?; tracing::debug!("{ids_string} `{filename}`视频下载完成"); } - if !progress.audio_task.is_completed() && progress.audio_task.content_length != 0 { - // 如果音频任务被选中且未完成且有要下载的内容,则下载音频 - self.download_audio(&progress) + if !audio_task.is_completed() && audio_task.content_length != 0 { + audio_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载音频文件失败"))?; tracing::debug!("{ids_string} `{filename}`音频下载完成"); } - if !progress.merge_task.is_completed() { - self.merge_video_audio(&progress) + 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}`视频和音频合并完成"); + .context(format!("{ids_string} `{filename}`视频处理失败"))?; + tracing::debug!("{ids_string} `{filename}`视频处理完成"); } - if !progress.danmaku_task.is_completed() { - self.download_danmaku(&progress) + if !danmaku_task.is_completed() { + danmaku_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载弹幕失败"))?; tracing::debug!("{ids_string} `{filename}`弹幕下载完成"); } - if !progress.subtitle_task.is_completed() { - self.download_subtitle(&progress) + if !subtitle_task.is_completed() { + subtitle_task + .process(self, &progress, &mut player_info) .await .context(format!("{ids_string} `{filename}`下载字幕失败"))?; tracing::debug!("{ids_string} `{filename}`字幕下载完成"); } - if !progress.cover_task.is_completed() { - self.download_cover(&progress) + if !cover_task.is_completed() { + cover_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载封面失败"))?; tracing::debug!("{ids_string} `{filename}`封面下载完成"); } - let mut episode_info = None; - - if !progress.nfo_task.is_completed() { - self.download_nfo(&progress, &mut episode_info) + if !nfo_task.is_completed() { + nfo_task + .process(self, &progress, &mut episode_info) .await .context(format!("{ids_string} `{filename}`下载NFO失败"))?; tracing::debug!("{ids_string} `{filename}`NFO下载完成"); } - if !progress.json_task.is_completed() { - self.download_json(&progress, &mut episode_info) + if !json_task.is_completed() { + json_task + .process(self, &progress, &mut episode_info) .await .context(format!("{ids_string} `{filename}`下载JSON元数据失败"))?; tracing::debug!("{ids_string} `{filename}`JSON元数据下载完成"); @@ -359,560 +362,6 @@ impl DownloadTask { Ok(()) } - async fn download_video(self: &Arc, progress: &DownloadProgress) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let temp_file_path = episode_dir.join(format!( - "{filename}.mp4.com.lanyeeee.bilibili-video-downloader" - )); - - let (video_task, episode_title, ids_string) = { - let progress = self.progress.read(); - ( - progress.video_task.clone(), - progress.episode_title.clone(), - progress.get_ids_string(), - ) - }; - - let file = if temp_file_path.exists() { - // 如果临时文件已存在,则打开它 - OpenOptions::new() - .read(true) - .write(true) - .open(&temp_file_path)? - } else { - // 如果临时文件不存在,创建它并预分配空间 - let file = File::create(&temp_file_path)?; - file.allocate(video_task.content_length)?; - file - }; - let file = Arc::new(Mutex::new(file)); - - let chunk_count = video_task.chunks.len(); - - let mut join_set = JoinSet::new(); - for (i, chunk) in video_task.chunks.iter().enumerate() { - if chunk.completed { - continue; - } - - let (start, end) = (chunk.start, chunk.end); - - let download_chunk_task = DownloadChunkTask { - download_task: self.clone(), - start, - end, - url: video_task.url.to_string(), - file: file.clone(), - chunk_index: i, - }; - - let chunk_order = i + 1; - - join_set.spawn(async move { - download_chunk_task.process().await.context(format!( - "分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})" - )) - }); - } - - while let Some(Ok(download_video_result)) = join_set.join_next().await { - match download_video_result { - Ok(i) => self.update_progress(|p| p.video_task.chunks[i].completed = true), - Err(err) => { - let err_title = format!("{ids_string} `{episode_title}`视频的一个分片下载失败"); - let string_chain = err.to_string_chain(); - tracing::error!(err_title, message = string_chain); - } - } - } - // 检查视频是否已下载完成 - let download_completed = self - .progress - .read() - .video_task - .chunks - .iter() - .all(|chunk| chunk.completed); - if !download_completed { - return Err(anyhow!( - "视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", - temp_file_path.display() - )); - } - - let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( - "检查视频文件`{}`是否完整失败", - temp_file_path.display() - ))?; - - if !is_video_file_complete { - self.update_progress(|p| p.video_task.mark_uncompleted()); - return Err(anyhow!( - "视频文件`{}`不完整,[继续]会重新下载所有分片", - temp_file_path.display() - )); - } - - // 重命名临时文件 - let mp4_path = episode_dir.join(format!("{filename}.mp4")); - if mp4_path.exists() { - std::fs::remove_file(&mp4_path) - .context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?; - } - std::fs::rename(&temp_file_path, &mp4_path).context(format!( - "将临时文件`{}`重命名为`{}`失败", - temp_file_path.display(), - mp4_path.display() - ))?; - - self.update_progress(|p| p.video_task.completed = true); - - Ok(()) - } - - async fn download_audio(self: &Arc, progress: &DownloadProgress) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let temp_file_path = episode_dir.join(format!( - "{filename}.m4a.com.lanyeeee.bilibili-video-downloader" - )); - let (audio_task, episode_title, ids_string) = { - let progress = self.progress.read(); - ( - progress.audio_task.clone(), - progress.episode_title.clone(), - progress.get_ids_string(), - ) - }; - - let file = if temp_file_path.exists() { - // 如果文件已存在,则打开它 - OpenOptions::new() - .read(true) - .write(true) - .open(&temp_file_path)? - } else { - // 如果文件不存在,创建它并预分配空间 - let file = File::create(&temp_file_path)?; - file.allocate(audio_task.content_length)?; - file - }; - let file = Arc::new(Mutex::new(file)); - - let chunk_count = audio_task.chunks.len(); - - let mut join_set = JoinSet::new(); - for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() { - if chunk.completed { - continue; - } - - let (start, end) = (chunk.start, chunk.end); - - let download_chunk_task = DownloadChunkTask { - download_task: self.clone(), - start, - end, - url: audio_task.url.to_string(), - file: file.clone(), - chunk_index, - }; - - join_set.spawn(async move { - download_chunk_task.process().await.context(format!( - "分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})" - )) - }); - } - - while let Some(Ok(download_video_result)) = join_set.join_next().await { - match download_video_result { - Ok(i) => self.update_progress(|p| p.audio_task.chunks[i].completed = true), - Err(err) => { - let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败"); - let string_chain = err.to_string_chain(); - tracing::error!(err_title, message = string_chain); - } - } - } - - let download_completed = self - .progress - .read() - .audio_task - .chunks - .iter() - .all(|chunk| chunk.completed); - if !download_completed { - return Err(anyhow!( - "音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", - temp_file_path.display() - )); - } - - let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( - "检查音频文件`{}`是否完整失败", - temp_file_path.display() - ))?; - - if !is_audio_file_complete { - self.update_progress(|p| p.video_task.mark_uncompleted()); - return Err(anyhow!( - "音频文件`{}`不完整,[继续]会重新下载所有分片", - temp_file_path.display() - )); - } - - // 重命名临时文件 - let m4a_path = episode_dir.join(format!("{filename}.m4a")); - if m4a_path.exists() { - std::fs::remove_file(&m4a_path) - .context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?; - } - std::fs::rename(&temp_file_path, &m4a_path).context(format!( - "将临时文件`{}`重命名为`{}`失败", - temp_file_path.display(), - m4a_path.display() - ))?; - - self.update_progress(|p| p.audio_task.completed = true); - - Ok(()) - } - - async fn merge_video_audio(&self, 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() { - self.update_progress(|p| p.merge_task.completed = true); - return Ok(()); - } - - let audio_path = episode_dir.join(format!("{filename}.m4a")); - if !audio_path.exists() { - self.update_progress(|p| p.merge_task.completed = true); - return Ok(()); - } - - let output_path = episode_dir.join(format!("{filename}-merged.mp4")); - - let ffmpeg_program = std::env::current_exe() - .context("获取当前可执行文件路径失败")? - .parent() - .context("获取当前可执行文件所在目录失败")? - .join("com.lanyeeee.bilibili-video-downloader-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() - ))?; - - self.update_progress(|p| p.merge_task.completed = true); - - Ok(()) - } - - async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> { - let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration); - let danmaku_task = &progress.danmaku_task; - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let bili_client = self.app.get_bili_client(); - let replies = bili_client - .get_danmaku(aid, cid, duration) - .await - .context("获取弹幕失败")?; - - let xml = replies.to_xml(cid).context("将弹幕转换为XML失败")?; - - if danmaku_task.xml_selected { - let xml_path = episode_dir.join(format!("{filename}.弹幕.xml")); - std::fs::write(&xml_path, &xml) - .context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?; - } - - if danmaku_task.ass_selected { - let config = self.app.get_config().read().danmaku_config.clone(); - let ass_path = episode_dir.join(format!("{filename}.弹幕.ass")); - let ass_file = File::create(&ass_path) - .context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?; - let title = filename.to_string(); - xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?; - } - - if danmaku_task.json_selected { - let json_path = episode_dir.join(format!("{filename}.弹幕.json")); - let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?; - std::fs::write(&json_path, json_string) - .context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?; - } - - self.update_progress(|p| p.danmaku_task.completed = true); - - Ok(()) - } - - async fn download_subtitle(&self, progress: &DownloadProgress) -> anyhow::Result<()> { - use std::fmt::Write; - - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let (aid, cid) = { - let progress = self.progress.read(); - (progress.aid, progress.cid) - }; - - let bili_client = self.app.get_bili_client(); - let player_info = bili_client - .get_player_info(aid, cid) - .await - .context("获取播放器信息失败")?; - - let subtitle = &player_info.subtitle; - for subtitle_detail in &subtitle.subtitles { - let url = format!("http:{}", subtitle_detail.subtitle_url); - let subtitle = bili_client - .get_subtitle(&url) - .await - .context("获取字幕失败")?; - - let mut srt_content = String::new(); - for (i, b) in subtitle.body.iter().enumerate() { - let index = i + 1; - let content = &b.content; - let start_time = utils::seconds_to_srt_time(b.from); - let end_time = utils::seconds_to_srt_time(b.to); - let _ = writeln!( - &mut srt_content, - "{index}\n{start_time} --> {end_time}\n{content}\n" - ); - } - - let lan = utils::filename_filter(&subtitle_detail.lan); - let save_path = episode_dir.join(format!("{filename}.{lan}.srt")); - std::fs::write(save_path, srt_content)?; - } - - self.update_progress(|p| p.subtitle_task.completed = true); - - Ok(()) - } - - async fn download_cover(&self, progress: &DownloadProgress) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let bili_client = self.app.get_bili_client(); - let (cover_data, ext) = bili_client - .get_cover_data_and_ext(&progress.cover_task.url) - .await - .context("获取封面失败")?; - - let save_path = episode_dir.join(format!("{filename}.{ext}")); - std::fs::write(&save_path, cover_data) - .context(format!("保存封面到`{}`失败", save_path.display()))?; - - self.update_progress(|p| p.cover_task.completed = true); - - Ok(()) - } - - async fn download_nfo( - &self, - progress: &DownloadProgress, - episode_info: &mut Option, - ) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type); - - let bili_client = self.app.get_bili_client(); - - let episode_info = episode_info - .get_or_init(&bili_client, aid, ep_id, episode_type) - .await?; - - match episode_info { - EpisodeInfo::Normal(info) => { - let tags = bili_client - .get_tags(aid) - .await - .context("获取视频标签失败")?; - let movie_nfo = info - .to_movie_nfo(tags) - .context("将普通视频信息转换为movie NFO失败")?; - let nfo_path = episode_dir.join(format!("{filename}.nfo")); - std::fs::write(&nfo_path, movie_nfo) - .context(format!("保存普通视频NFO到`{}`失败", nfo_path.display()))?; - - if let Some(ugc_season) = &info.ugc_season { - let collection_cover = &ugc_season.cover; - let (cover_data, ext) = bili_client - .get_cover_data_and_ext(collection_cover) - .await - .context("获取普通视频合集封面失败")?; - let cover_path = episode_dir.join(format!("poster.{ext}")); - std::fs::write(&cover_path, cover_data).context(format!( - "保存普通视频合集封面到`{}`失败", - cover_path.display() - ))?; - } - } - EpisodeInfo::Bangumi(info, ep_id) => { - let tvshow_nfo = info - .to_tvshow_nfo() - .context("将番剧信息转换为tvshow NFO失败")?; - let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); - std::fs::write(&tvshow_nfo_path, tvshow_nfo) - .context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?; - - let episode_details_nfo = info - .to_episode_details_nfo(*ep_id) - .context("将番剧信息转换为episodedetail NFO失败")?; - let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); - std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( - "保存番剧NFO到`{}`失败", - episode_details_nfo_path.display() - ))?; - - let poster_url = &info.cover; - let (poster_data, ext) = bili_client - .get_cover_data_and_ext(poster_url) - .await - .context("获取番剧封面失败")?; - let poster_path = episode_dir.join(format!("poster.{ext}")); - std::fs::write(&poster_path, poster_data) - .context(format!("保存番剧封面到`{}`失败", poster_path.display()))?; - - let fanart_url = &info.bkg_cover; - if !fanart_url.is_empty() { - let (fanart_data, ext) = bili_client - .get_cover_data_and_ext(fanart_url) - .await - .context("获取番剧封面失败")?; - let fanart_path = episode_dir.join(format!("fanart.{ext}")); - std::fs::write(&fanart_path, fanart_data) - .context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?; - } - } - EpisodeInfo::Cheese(info, ep_id) => { - let tvshow_nfo = info - .to_tvshow_nfo() - .context("将课程信息转换为tvshow NFO失败")?; - let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); - std::fs::write(&tvshow_nfo_path, tvshow_nfo) - .context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?; - - let episode_details_nfo = info - .to_episode_details_nfo(*ep_id) - .context("将课程信息转换为episodedetail NFO失败")?; - let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); - std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( - "保存课程NFO到`{}`失败", - episode_details_nfo_path.display() - ))?; - - let poster_url = &info.cover; - let (poster_data, ext) = bili_client - .get_cover_data_and_ext(poster_url) - .await - .context("获取课程封面失败")?; - let poster_path = episode_dir.join(format!("poster.{ext}")); - std::fs::write(&poster_path, poster_data) - .context(format!("保存课程封面到`{}`失败", poster_path.display()))?; - } - } - - self.update_progress(|p| p.nfo_task.completed = true); - - Ok(()) - } - - async fn download_json( - &self, - progress: &DownloadProgress, - episode_info: &mut Option, - ) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type); - - let bili_client = self.app.get_bili_client(); - - let episode_info = episode_info - .get_or_init(&bili_client, aid, ep_id, episode_type) - .await?; - - let json_path = episode_dir.join(format!("{filename}-元数据.json")); - let json_string = match episode_info { - EpisodeInfo::Normal(info) => { - serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")? - } - EpisodeInfo::Bangumi(info, _ep_id) => { - serde_json::to_string(&info).context("将番剧信息转换为JSON失败")? - } - EpisodeInfo::Cheese(info, _ep_id) => { - serde_json::to_string(&info).context("将课程信息转换为JSON失败")? - } - }; - std::fs::write(&json_path, json_string) - .context(format!("保存JSON到`{}`失败", json_path.display()))?; - - self.update_progress(|p| p.json_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; @@ -1019,7 +468,7 @@ impl DownloadTask { } } - fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) { + pub fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) { // 修改数据 let updated_progress = { let mut progress = self.progress.write(); @@ -1032,7 +481,7 @@ impl DownloadTask { } .emit(&self.app); - if let Err(err) = updated_progress.save(&self.app) { + if let Err(err) = updated_progress.save(&self.app, false) { let ids_string = updated_progress.get_ids_string(); let episode_title = &updated_progress.episode_title; let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败"); @@ -1041,166 +490,3 @@ impl DownloadTask { } } } - -struct DownloadChunkTask { - download_task: Arc, - start: u64, - end: u64, - url: String, - file: Arc>, - chunk_index: usize, -} - -impl DownloadChunkTask { - async fn process(self) -> anyhow::Result { - let download_chunk_task = self.download_chunk(); - tokio::pin!(download_chunk_task); - - let mut state_receiver = self.download_task.state_sender.subscribe(); - state_receiver.mark_changed(); - - let mut restart_receiver = self.download_task.restart_sender.subscribe(); - let mut delete_receiver = self.download_task.delete_sender.subscribe(); - - let mut permit = None; - - loop { - let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading; - tokio::select! { - result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result, - - result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => { - match result { - Ok(()) => {}, - Err(err) => break Err(err), - } - }, - - _ = state_receiver.changed() => { - if *state_receiver.borrow() == DownloadTaskState::Paused { - // 稍微等一下再释放permit - sleep(Duration::from_millis(100)).await; - if let Some(permit) = permit.take() { - drop(permit); - }; - } - }, - - _ = restart_receiver.changed() => break Ok(self.chunk_index), - - _ = delete_receiver.changed() => break Ok(self.chunk_index), - } - } - } - - pub async fn download_chunk(&self) -> anyhow::Result { - let bili_client = self.download_task.app.get_bili_client(); - let chunk_data = bili_client - .get_media_chunk(&self.url, self.start, self.end) - .await?; - - let len = chunk_data.len() as u64; - self.download_task - .app - .get_download_manager() - .byte_per_sec - .fetch_add(len, std::sync::atomic::Ordering::Relaxed); - // 将下载的内容写入文件 - { - let mut file = self.file.lock(); - file.seek(std::io::SeekFrom::Start(self.start))?; - file.write_all(&chunk_data)?; - } - - let chunk_download_interval_sec = self - .download_task - .app - .get_config() - .read() - .chunk_download_interval_sec; - sleep(Duration::from_secs(chunk_download_interval_sec)).await; - - Ok(self.chunk_index) - } - - async fn acquire_chunk_permit<'a>( - &'a self, - permit: &mut Option>, - ) -> anyhow::Result<()> { - *permit = match permit.take() { - // 如果有permit,则直接用 - Some(permit) => Some(permit), - // 如果没有permit,则获取permit - None => Some( - self.download_task - .app - .get_download_manager() - .inner() - .media_chunk_sem - .acquire() - .await?, - ), - }; - - Ok(()) - } -} - -#[derive(Clone)] -enum EpisodeInfo { - Normal(NormalInfo), - Bangumi(BangumiInfo, i64), - Cheese(CheeseInfo, i64), -} - -trait GetOrInitEpisodeInfo { - async fn get_or_init<'a>( - &'a mut self, - bili_client: &BiliClient, - aid: i64, - ep_id: Option, - episode_type: EpisodeType, - ) -> anyhow::Result<&'a mut EpisodeInfo>; -} - -impl GetOrInitEpisodeInfo for Option { - async fn get_or_init<'a>( - &'a mut self, - bili_client: &BiliClient, - aid: i64, - ep_id: Option, - episode_type: EpisodeType, - ) -> anyhow::Result<&'a mut EpisodeInfo> { - if let Some(info) = self { - return Ok(info); - } - - let new_info = match episode_type { - EpisodeType::Normal => { - let info = bili_client - .get_normal_info(GetNormalInfoParams::Aid(aid)) - .await - .context("获取普通视频信息失败")?; - EpisodeInfo::Normal(info) - } - EpisodeType::Bangumi => { - let ep_id = ep_id.context("ep_id为None")?; - let info = bili_client - .get_bangumi_info(GetBangumiInfoParams::EpId(ep_id)) - .await - .context("获取番剧信息失败")?; - EpisodeInfo::Bangumi(info, ep_id) - } - EpisodeType::Cheese => { - let ep_id = ep_id.context("ep_id为None")?; - let info = bili_client - .get_cheese_info(GetCheeseInfoParams::EpId(ep_id)) - .await - .context("获取课程信息失败")?; - EpisodeInfo::Cheese(info, ep_id) - } - }; - - Ok(self.insert(new_info)) - } -} diff --git a/src-tauri/src/downloader/episode_info.rs b/src-tauri/src/downloader/episode_info.rs new file mode 100644 index 0000000..ce85968 --- /dev/null +++ b/src-tauri/src/downloader/episode_info.rs @@ -0,0 +1,70 @@ +use anyhow::Context; +use tauri::AppHandle; + +use crate::{ + downloader::{download_progress::DownloadProgress, episode_type::EpisodeType}, + extensions::AppHandleExt, + types::{ + bangumi_info::BangumiInfo, cheese_info::CheeseInfo, + get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, + get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo, + }, +}; + +#[derive(Clone)] +pub enum EpisodeInfo { + Normal(NormalInfo), + Bangumi(BangumiInfo, i64), + Cheese(CheeseInfo, i64), +} + +pub trait GetOrInitEpisodeInfo { + async fn get_or_init<'a>( + &'a mut self, + app: &AppHandle, + progress: &DownloadProgress, + ) -> anyhow::Result<&'a mut EpisodeInfo>; +} + +impl GetOrInitEpisodeInfo for Option { + async fn get_or_init<'a>( + &'a mut self, + app: &AppHandle, + progress: &DownloadProgress, + ) -> anyhow::Result<&'a mut EpisodeInfo> { + if let Some(info) = self { + return Ok(info); + } + + let bili_client = app.get_bili_client(); + let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type); + + let new_info = match episode_type { + EpisodeType::Normal => { + let info = bili_client + .get_normal_info(GetNormalInfoParams::Aid(aid)) + .await + .context("获取普通视频信息失败")?; + EpisodeInfo::Normal(info) + } + EpisodeType::Bangumi => { + let ep_id = ep_id.context("ep_id为None")?; + let info = bili_client + .get_bangumi_info(GetBangumiInfoParams::EpId(ep_id)) + .await + .context("获取番剧信息失败")?; + EpisodeInfo::Bangumi(info, ep_id) + } + EpisodeType::Cheese => { + let ep_id = ep_id.context("ep_id为None")?; + let info = bili_client + .get_cheese_info(GetCheeseInfoParams::EpId(ep_id)) + .await + .context("获取课程信息失败")?; + EpisodeInfo::Cheese(info, ep_id) + } + }; + + Ok(self.insert(new_info)) + } +} diff --git a/src-tauri/src/downloader/mod.rs b/src-tauri/src/downloader/mod.rs index b8af363..1b7cb72 100644 --- a/src-tauri/src/downloader/mod.rs +++ b/src-tauri/src/downloader/mod.rs @@ -1,7 +1,10 @@ +pub mod chapter_segments; +pub mod download_chunk_task; pub mod download_manager; pub mod download_progress; pub mod download_task; pub mod download_task_state; +pub mod episode_info; pub mod episode_type; pub mod fmt_params; pub mod media_chunk; diff --git a/src-tauri/src/downloader/tasks/audio_task.rs b/src-tauri/src/downloader/tasks/audio_task.rs index b3586e8..5cdd4fc 100644 --- a/src-tauri/src/downloader/tasks/audio_task.rs +++ b/src-tauri/src/downloader/tasks/audio_task.rs @@ -1,18 +1,28 @@ -use std::cmp::Reverse; +use std::{ + cmp::Reverse, + fs::{File, OpenOptions}, + sync::Arc, +}; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; +use fs4::fs_std::FileExt; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::AppHandle; use tokio::task::JoinSet; use crate::{ - downloader::media_chunk::MediaChunk, - extensions::AppHandleExt, + downloader::{ + download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress, + download_task::DownloadTask, media_chunk::MediaChunk, + }, + extensions::{AnyhowErrorToStringChain, AppHandleExt}, types::{ audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl, }, + utils, }; const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB @@ -297,6 +307,119 @@ impl AudioTask { 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 temp_file_path = episode_dir.join(format!( + "{filename}.m4a.com.lanyeeee.bilibili-video-downloader" + )); + let (audio_task, episode_title, ids_string) = { + ( + progress.audio_task.clone(), + progress.episode_title.clone(), + progress.get_ids_string(), + ) + }; + + let file = if temp_file_path.exists() { + // 如果文件已存在,则打开它 + OpenOptions::new() + .read(true) + .write(true) + .open(&temp_file_path)? + } else { + // 如果文件不存在,创建它并预分配空间 + let file = File::create(&temp_file_path)?; + file.allocate(audio_task.content_length)?; + file + }; + let file = Arc::new(Mutex::new(file)); + + let chunk_count = audio_task.chunks.len(); + + let mut join_set = JoinSet::new(); + for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() { + if chunk.completed { + continue; + } + + let (start, end) = (chunk.start, chunk.end); + + let download_chunk_task = DownloadChunkTask { + download_task: download_task.clone(), + start, + end, + url: audio_task.url.to_string(), + file: file.clone(), + chunk_index, + }; + + join_set.spawn(async move { + download_chunk_task.process().await.context(format!( + "分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})" + )) + }); + } + + while let Some(Ok(download_video_result)) = join_set.join_next().await { + match download_video_result { + Ok(i) => download_task.update_progress(|p| p.audio_task.chunks[i].completed = true), + Err(err) => { + let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + } + } + + let download_completed = download_task + .progress + .read() + .audio_task + .chunks + .iter() + .all(|chunk| chunk.completed); + if !download_completed { + return Err(anyhow!( + "音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", + temp_file_path.display() + )); + } + + let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( + "检查音频文件`{}`是否完整失败", + temp_file_path.display() + ))?; + + if !is_audio_file_complete { + download_task.update_progress(|p| p.video_task.mark_uncompleted()); + return Err(anyhow!( + "音频文件`{}`不完整,[继续]会重新下载所有分片", + temp_file_path.display() + )); + } + + // 重命名临时文件 + let m4a_path = episode_dir.join(format!("{filename}.m4a")); + if m4a_path.exists() { + std::fs::remove_file(&m4a_path) + .context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?; + } + std::fs::rename(&temp_file_path, &m4a_path).context(format!( + "将临时文件`{}`重命名为`{}`失败", + temp_file_path.display(), + m4a_path.display() + ))?; + + download_task.update_progress(|p| p.audio_task.completed = true); + + Ok(()) + } } #[derive(Debug, Clone)] diff --git a/src-tauri/src/downloader/tasks/cover_task.rs b/src-tauri/src/downloader/tasks/cover_task.rs index 0226052..260160c 100644 --- a/src-tauri/src/downloader/tasks/cover_task.rs +++ b/src-tauri/src/downloader/tasks/cover_task.rs @@ -1,6 +1,14 @@ +use std::sync::Arc; + +use anyhow::Context; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::{ + downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, + extensions::AppHandleExt, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct CoverTask { pub selected: bool, @@ -12,4 +20,26 @@ impl CoverTask { 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 bili_client = download_task.app.get_bili_client(); + let (cover_data, ext) = bili_client + .get_cover_data_and_ext(&progress.cover_task.url) + .await + .context("获取封面失败")?; + + let save_path = episode_dir.join(format!("{filename}.{ext}")); + std::fs::write(&save_path, cover_data) + .context(format!("保存封面到`{}`失败", save_path.display()))?; + + download_task.update_progress(|p| p.cover_task.completed = true); + + Ok(()) + } } diff --git a/src-tauri/src/downloader/tasks/danmaku_task.rs b/src-tauri/src/downloader/tasks/danmaku_task.rs index cb8564b..de2ca27 100644 --- a/src-tauri/src/downloader/tasks/danmaku_task.rs +++ b/src-tauri/src/downloader/tasks/danmaku_task.rs @@ -1,6 +1,16 @@ +use std::{fs::File, sync::Arc}; + +use anyhow::Context; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::{ + danmaku_xml_to_ass::xml_to_ass, + downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, + extensions::AppHandleExt, + utils::ToXml, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] #[allow(clippy::struct_excessive_bools)] pub struct DanmakuTask { @@ -14,4 +24,49 @@ impl DanmakuTask { pub fn is_completed(&self) -> bool { !self.xml_selected && !self.ass_selected && !self.json_selected || self.completed } + + pub async fn process( + &self, + download_task: &Arc, + progress: &DownloadProgress, + ) -> anyhow::Result<()> { + let danmaku_task = &progress.danmaku_task; + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let bili_client = download_task.app.get_bili_client(); + let replies = bili_client + .get_danmaku(progress.aid, progress.cid, progress.duration) + .await + .context("获取弹幕失败")?; + + let xml = replies + .to_xml(progress.cid) + .context("将弹幕转换为XML失败")?; + + if danmaku_task.xml_selected { + let xml_path = episode_dir.join(format!("{filename}.弹幕.xml")); + std::fs::write(&xml_path, &xml) + .context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?; + } + + if danmaku_task.ass_selected { + let config = download_task.app.get_config().read().danmaku_config.clone(); + let ass_path = episode_dir.join(format!("{filename}.弹幕.ass")); + let ass_file = File::create(&ass_path) + .context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?; + let title = filename.to_string(); + xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?; + } + + if danmaku_task.json_selected { + let json_path = episode_dir.join(format!("{filename}.弹幕.json")); + let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?; + std::fs::write(&json_path, json_string) + .context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?; + } + + download_task.update_progress(|p| p.danmaku_task.completed = true); + + Ok(()) + } } diff --git a/src-tauri/src/downloader/tasks/json_task.rs b/src-tauri/src/downloader/tasks/json_task.rs index 1267e0b..ae6a4ab 100644 --- a/src-tauri/src/downloader/tasks/json_task.rs +++ b/src-tauri/src/downloader/tasks/json_task.rs @@ -1,6 +1,15 @@ +use std::sync::Arc; + +use anyhow::Context; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::downloader::{ + download_progress::DownloadProgress, + download_task::DownloadTask, + episode_info::{EpisodeInfo, GetOrInitEpisodeInfo}, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct JsonTask { pub selected: bool, @@ -11,4 +20,36 @@ impl JsonTask { pub fn is_completed(&self) -> bool { !self.selected || self.completed } + + pub async fn process( + &self, + download_task: &Arc, + progress: &DownloadProgress, + episode_info: &mut Option, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let episode_info = episode_info + .get_or_init(&download_task.app, progress) + .await?; + + let json_path = episode_dir.join(format!("{filename}-元数据.json")); + let json_string = match episode_info { + EpisodeInfo::Normal(info) => { + serde_json::to_string(&info).context("将普通视频信息转换为JSON失败")? + } + EpisodeInfo::Bangumi(info, _ep_id) => { + serde_json::to_string(&info).context("将番剧信息转换为JSON失败")? + } + EpisodeInfo::Cheese(info, _ep_id) => { + serde_json::to_string(&info).context("将课程信息转换为JSON失败")? + } + }; + std::fs::write(&json_path, json_string) + .context(format!("保存JSON到`{}`失败", json_path.display()))?; + + download_task.update_progress(|p| p.json_task.completed = true); + + Ok(()) + } } 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 731b20a..0000000 --- a/src-tauri/src/downloader/tasks/merge_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 MergeTask { - pub selected: bool, - pub completed: bool, -} - -impl MergeTask { - 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 d8acc56..1c5444c 100644 --- a/src-tauri/src/downloader/tasks/mod.rs +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -2,7 +2,7 @@ pub mod audio_task; pub mod cover_task; pub mod danmaku_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/nfo_task.rs b/src-tauri/src/downloader/tasks/nfo_task.rs index 476067f..45b1174 100644 --- a/src-tauri/src/downloader/tasks/nfo_task.rs +++ b/src-tauri/src/downloader/tasks/nfo_task.rs @@ -1,11 +1,21 @@ +use std::sync::Arc; + use anyhow::{anyhow, Context}; use chrono::{DateTime, Datelike, NaiveDateTime}; use serde::{Deserialize, Serialize}; use specta::Type; use yaserde::{YaDeserialize, YaSerialize}; -use crate::types::{ - bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags, +use crate::{ + downloader::{ + download_progress::DownloadProgress, + download_task::DownloadTask, + episode_info::{EpisodeInfo, GetOrInitEpisodeInfo}, + }, + extensions::AppHandleExt, + types::{ + bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags, + }, }; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] @@ -18,6 +28,116 @@ impl NfoTask { pub fn is_completed(&self) -> bool { !self.selected || self.completed } + + pub async fn process( + &self, + download_task: &Arc, + progress: &DownloadProgress, + episode_info: &mut Option, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let episode_info = episode_info + .get_or_init(&download_task.app, progress) + .await?; + + let bili_client = download_task.app.get_bili_client(); + + match episode_info { + EpisodeInfo::Normal(info) => { + let tags = bili_client + .get_tags(progress.aid) + .await + .context("获取视频标签失败")?; + let movie_nfo = info + .to_movie_nfo(tags) + .context("将普通视频信息转换为movie NFO失败")?; + let nfo_path = episode_dir.join(format!("{filename}.nfo")); + std::fs::write(&nfo_path, movie_nfo) + .context(format!("保存普通视频NFO到`{}`失败", nfo_path.display()))?; + + if let Some(ugc_season) = &info.ugc_season { + let collection_cover = &ugc_season.cover; + let (cover_data, ext) = bili_client + .get_cover_data_and_ext(collection_cover) + .await + .context("获取普通视频合集封面失败")?; + let cover_path = episode_dir.join(format!("poster.{ext}")); + std::fs::write(&cover_path, cover_data).context(format!( + "保存普通视频合集封面到`{}`失败", + cover_path.display() + ))?; + } + } + EpisodeInfo::Bangumi(info, ep_id) => { + let tvshow_nfo = info + .to_tvshow_nfo() + .context("将番剧信息转换为tvshow NFO失败")?; + let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); + std::fs::write(&tvshow_nfo_path, tvshow_nfo) + .context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?; + + let episode_details_nfo = info + .to_episode_details_nfo(*ep_id) + .context("将番剧信息转换为episodedetail NFO失败")?; + let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); + std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( + "保存番剧NFO到`{}`失败", + episode_details_nfo_path.display() + ))?; + + let poster_url = &info.cover; + let (poster_data, ext) = bili_client + .get_cover_data_and_ext(poster_url) + .await + .context("获取番剧封面失败")?; + let poster_path = episode_dir.join(format!("poster.{ext}")); + std::fs::write(&poster_path, poster_data) + .context(format!("保存番剧封面到`{}`失败", poster_path.display()))?; + + let fanart_url = &info.bkg_cover; + if !fanart_url.is_empty() { + let (fanart_data, ext) = bili_client + .get_cover_data_and_ext(fanart_url) + .await + .context("获取番剧封面失败")?; + let fanart_path = episode_dir.join(format!("fanart.{ext}")); + std::fs::write(&fanart_path, fanart_data) + .context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?; + } + } + EpisodeInfo::Cheese(info, ep_id) => { + let tvshow_nfo = info + .to_tvshow_nfo() + .context("将课程信息转换为tvshow NFO失败")?; + let tvshow_nfo_path = episode_dir.join("tvshow.nfo"); + std::fs::write(&tvshow_nfo_path, tvshow_nfo) + .context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?; + + let episode_details_nfo = info + .to_episode_details_nfo(*ep_id) + .context("将课程信息转换为episodedetail NFO失败")?; + let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo")); + std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!( + "保存课程NFO到`{}`失败", + episode_details_nfo_path.display() + ))?; + + let poster_url = &info.cover; + let (poster_data, ext) = bili_client + .get_cover_data_and_ext(poster_url) + .await + .context("获取课程封面失败")?; + let poster_path = episode_dir.join(format!("poster.{ext}")); + std::fs::write(&poster_path, poster_data) + .context(format!("保存课程封面到`{}`失败", poster_path.display()))?; + } + } + + download_task.update_progress(|p| p.nfo_task.completed = true); + + Ok(()) + } } #[derive(YaSerialize, YaDeserialize)] diff --git a/src-tauri/src/downloader/tasks/subtitle_task.rs b/src-tauri/src/downloader/tasks/subtitle_task.rs index 981a6bd..689204a 100644 --- a/src-tauri/src/downloader/tasks/subtitle_task.rs +++ b/src-tauri/src/downloader/tasks/subtitle_task.rs @@ -1,6 +1,16 @@ +use std::sync::Arc; + +use anyhow::Context; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::{ + downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, + extensions::{AppHandleExt, GetOrInitPlayerInfo}, + types::player_info::PlayerInfo, + utils, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct SubtitleTask { pub selected: bool, @@ -11,4 +21,49 @@ impl SubtitleTask { 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<()> { + use std::fmt::Write; + + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let player_info = player_info + .get_or_init(&download_task.app, progress) + .await?; + + let bili_client = download_task.app.get_bili_client(); + + for subtitle_detail in &player_info.subtitle.subtitles { + let url = format!("http:{}", subtitle_detail.subtitle_url); + let subtitle = bili_client + .get_subtitle(&url) + .await + .context("获取字幕失败")?; + + let mut srt_content = String::new(); + for (i, b) in subtitle.body.iter().enumerate() { + let index = i + 1; + let content = &b.content; + let start_time = utils::seconds_to_srt_time(b.from); + let end_time = utils::seconds_to_srt_time(b.to); + let _ = writeln!( + &mut srt_content, + "{index}\n{start_time} --> {end_time}\n{content}\n" + ); + } + + let lan = utils::filename_filter(&subtitle_detail.lan); + let save_path = episode_dir.join(format!("{filename}.{lan}.srt")); + std::fs::write(save_path, srt_content)?; + } + + download_task.update_progress(|p| p.subtitle_task.completed = true); + + Ok(()) + } } 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..97edf6f --- /dev/null +++ b/src-tauri/src/downloader/tasks/video_process_task.rs @@ -0,0 +1,389 @@ +use std::{path::PathBuf, 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 ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; + + 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 metadata_path = self + .create_chapter_metadata(&download_task.app, progress, player_info) + .await + .context("创建章节元数据失败")?; + + let output_path = episode_dir.join(format!("{filename}-merged.mp4")); + + 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); + + if let Some(metadata_path) = metadata_path_clone { + command + .arg("-i") + .arg(metadata_path) + .arg("-map_metadata") + .arg("2"); + } + + command + .arg("-c") + .arg("copy") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("1:a:0"); + + command.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() + ))?; + + if let Some(metadata_path) = metadata_path { + 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 ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; + + 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 output_path = episode_dir.join(format!("{filename}-embed.mp4")); + + let metadata_path = self + .create_chapter_metadata(&download_task.app, progress, player_info) + .await + .context("创建章节元数据失败")?; + + let Some(metadata_path) = metadata_path else { + download_task.update_progress(|p| p.video_process_task.completed = true); + return Ok(()); + }; + + 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_metadata( + &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 let (true, Some(bvid)) = (self.embed_skip_selected, &progress.bvid) { + let bili_client = app.get_bili_client(); + 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); + } + } + } + + if chapter_segments.segments.is_empty() { + return Ok(None); + } + + let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration); + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); + std::fs::write(&metadata_path, metadata_content) + .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; + + Ok(Some(metadata_path)) + } +} diff --git a/src-tauri/src/downloader/tasks/video_task.rs b/src-tauri/src/downloader/tasks/video_task.rs index 5f631e5..80cd81d 100644 --- a/src-tauri/src/downloader/tasks/video_task.rs +++ b/src-tauri/src/downloader/tasks/video_task.rs @@ -1,18 +1,28 @@ -use std::cmp::Reverse; +use std::{ + cmp::Reverse, + fs::{File, OpenOptions}, + sync::Arc, +}; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; +use fs4::fs_std::FileExt; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::AppHandle; use tokio::task::JoinSet; use crate::{ - downloader::media_chunk::MediaChunk, - extensions::AppHandleExt, + downloader::{ + download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress, + download_task::DownloadTask, media_chunk::MediaChunk, + }, + extensions::{AnyhowErrorToStringChain, AppHandleExt}, types::{ bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl, codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality, }, + utils, }; const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB @@ -288,6 +298,123 @@ impl VideoTask { 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 temp_file_path = episode_dir.join(format!( + "{filename}.mp4.com.lanyeeee.bilibili-video-downloader" + )); + + let (video_task, episode_title, ids_string) = { + let progress = download_task.progress.read(); + ( + progress.video_task.clone(), + progress.episode_title.clone(), + progress.get_ids_string(), + ) + }; + + let file = if temp_file_path.exists() { + // 如果临时文件已存在,则打开它 + OpenOptions::new() + .read(true) + .write(true) + .open(&temp_file_path)? + } else { + // 如果临时文件不存在,创建它并预分配空间 + let file = File::create(&temp_file_path)?; + file.allocate(video_task.content_length)?; + file + }; + let file = Arc::new(Mutex::new(file)); + + let chunk_count = video_task.chunks.len(); + + let mut join_set = JoinSet::new(); + for (i, chunk) in video_task.chunks.iter().enumerate() { + if chunk.completed { + continue; + } + + let (start, end) = (chunk.start, chunk.end); + + let download_chunk_task = DownloadChunkTask { + download_task: download_task.clone(), + start, + end, + url: video_task.url.to_string(), + file: file.clone(), + chunk_index: i, + }; + + let chunk_order = i + 1; + + join_set.spawn(async move { + download_chunk_task.process().await.context(format!( + "分片`{chunk_order}/{chunk_count}`下载失败({start}-{end})" + )) + }); + } + + while let Some(Ok(download_video_result)) = join_set.join_next().await { + match download_video_result { + Ok(i) => download_task.update_progress(|p| p.video_task.chunks[i].completed = true), + Err(err) => { + let err_title = format!("{ids_string} `{episode_title}`视频的一个分片下载失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + } + } + // 检查视频是否已下载完成 + let download_completed = download_task + .progress + .read() + .video_task + .chunks + .iter() + .all(|chunk| chunk.completed); + if !download_completed { + return Err(anyhow!( + "视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", + temp_file_path.display() + )); + } + + let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( + "检查视频文件`{}`是否完整失败", + temp_file_path.display() + ))?; + + if !is_video_file_complete { + download_task.update_progress(|p| p.video_task.mark_uncompleted()); + return Err(anyhow!( + "视频文件`{}`不完整,[继续]会重新下载所有分片", + temp_file_path.display() + )); + } + + // 重命名临时文件 + let mp4_path = episode_dir.join(format!("{filename}.mp4")); + if mp4_path.exists() { + std::fs::remove_file(&mp4_path) + .context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?; + } + std::fs::rename(&temp_file_path, &mp4_path).context(format!( + "将临时文件`{}`重命名为`{}`失败", + temp_file_path.display(), + mp4_path.display() + ))?; + + download_task.update_progress(|p| p.video_task.completed = true); + + Ok(()) + } } struct MediaForPrepare { diff --git a/src-tauri/src/extensions.rs b/src-tauri/src/extensions.rs index 85034a2..bedde53 100644 --- a/src-tauri/src/extensions.rs +++ b/src-tauri/src/extensions.rs @@ -1,8 +1,12 @@ +use anyhow::Context; use parking_lot::RwLock; -use tauri::{Manager, State}; +use tauri::{AppHandle, Manager, State}; use crate::{ - bili_client::BiliClient, config::Config, downloader::download_manager::DownloadManager, + bili_client::BiliClient, + config::Config, + downloader::{download_manager::DownloadManager, download_progress::DownloadProgress}, + types::player_info::PlayerInfo, }; pub trait AnyhowErrorToStringChain { @@ -43,3 +47,31 @@ impl AppHandleExt for tauri::AppHandle { self.state::() } } + +pub trait GetOrInitPlayerInfo { + async fn get_or_init<'a>( + &'a mut self, + app: &AppHandle, + progress: &DownloadProgress, + ) -> anyhow::Result<&'a mut PlayerInfo>; +} + +impl GetOrInitPlayerInfo for Option { + async fn get_or_init<'a>( + &'a mut self, + app: &AppHandle, + progress: &DownloadProgress, + ) -> anyhow::Result<&'a mut PlayerInfo> { + if let Some(info) = self { + return Ok(info); + } + + let bili_client = app.get_bili_client(); + let info = bili_client + .get_player_info(progress.aid, progress.cid) + .await + .context("获取播放器信息失败")?; + + Ok(self.insert(info)) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 53c5c3c..0862f71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,10 +40,12 @@ pub fn run() { get_qrcode_status, get_user_info, get_normal_info, + get_bangumi_info, get_user_video_info, get_fav_folders, get_fav_info, get_watch_later_info, + get_bangumi_follow_info, create_download_tasks, pause_download_tasks, resume_download_tasks, @@ -53,6 +55,7 @@ pub fn run() { search, get_logs_dir_size, show_path_in_file_manager, + get_skip_segments, ]) .events(tauri_specta::collect_events![LogEvent, DownloadEvent]); @@ -67,6 +70,10 @@ pub fn run() { ) .expect("Failed to export typescript bindings"); + // 解决Ubuntu24.04窗口全白的问题 + #[cfg(target_os = "linux")] + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) diff --git a/src-tauri/src/types/bangumi_follow_info.rs b/src-tauri/src/types/bangumi_follow_info.rs new file mode 100644 index 0000000..94580a3 --- /dev/null +++ b/src-tauri/src/types/bangumi_follow_info.rs @@ -0,0 +1,207 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct BangumiFollowInfo { + pub list: Vec, + pub pn: i64, + pub ps: i64, + pub total: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct EpInBangumiFollow { + pub season_id: i64, + pub media_id: i64, + pub season_type: i64, + pub season_type_name: String, + pub title: String, + pub cover: String, + pub total_count: i64, + pub is_finish: i64, + pub is_started: i64, + pub is_play: i64, + pub badge: String, + pub badge_type: i64, + pub rights: RightsInBangumiFollow, + pub stat: StatInBangumiFollow, + pub new_ep: NewEpInBangumiFollow, + pub rating: Option, + pub square_cover: String, + pub season_status: i64, + pub season_title: String, + pub badge_ep: String, + pub media_attr: i64, + pub season_attr: i64, + pub evaluate: String, + pub areas: Vec, + pub subtitle: String, + pub first_ep: i64, + pub can_watch: i64, + pub release_date_show: Option, + pub series: SeriesInBangumiFollow, + pub publish: PublishInBangumiFollow, + pub mode: i64, + pub section: Vec, + pub url: String, + pub badge_info: BadgeInfoInBangumiFollow, + pub renewal_time: Option, + pub first_ep_info: FirstEpInfo, + pub formal_ep_count: Option, + pub short_url: String, + pub badge_infos: Option, + pub season_version: Option, + pub horizontal_cover_16_9: Option, + pub horizontal_cover_16_10: Option, + pub subtitle_14: Option, + pub viewable_crowd_type: i64, + #[serde(default)] + pub producers: Vec, + pub summary: String, + #[serde(default)] + pub styles: Vec, + pub follow_status: i64, + pub is_new: i64, + pub progress: String, + pub both_follow: bool, + pub subtitle_25: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct RightsInBangumiFollow { + pub allow_review: Option, + pub allow_preview: Option, + pub is_selection: i64, + pub selection_style: i64, + pub is_rcmd: Option, + pub allow_bp_rank: Option, + pub allow_bp: Option, + pub allow_download: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct StatInBangumiFollow { + pub follow: i64, + pub view: i64, + pub danmaku: i64, + pub reply: i64, + pub coin: i64, + pub series_follow: Option, + pub series_view: Option, + pub likes: i64, + pub favorite: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct NewEpInBangumiFollow { + pub id: Option, + pub index_show: Option, + pub cover: Option, + pub title: Option, + pub long_title: Option, + pub pub_time: Option, + pub duration: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct RatingInBangumiFollow { + pub score: f64, + pub count: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct AreaInBangumiFollow { + pub id: i64, + pub name: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct SeriesInBangumiFollow { + pub series_id: Option, + pub title: Option, + pub season_count: Option, + pub new_season_id: Option, + pub series_ord: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct PublishInBangumiFollow { + pub pub_time: String, + pub pub_time_show: String, + pub release_date: String, + pub release_date_show: String, + pub pub_time_show_db: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct SectionInBangumiFollow { + pub section_id: i64, + pub season_id: i64, + pub limit_group: i64, + pub watch_platform: i64, + pub copyright: String, + pub ban_area_show: i64, + pub episode_ids: Vec, + #[serde(rename = "type")] + pub type_field: Option, + pub title: Option, + pub attr: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct BadgeInfoInBangumiFollow { + pub text: Option, + pub bg_color: String, + pub bg_color_night: String, + pub img: Option, + pub multi_img: MultiImg, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct MultiImg { + pub color: String, + pub medium_remind: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct FirstEpInfo { + pub id: i64, + pub cover: String, + pub title: String, + pub long_title: Option, + pub pub_time: String, + pub duration: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct BadgeInfos { + pub vip_or_pay: Option, + pub content_attr: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct VipOrPay { + pub text: String, + pub bg_color: String, + pub bg_color_night: String, + pub img: String, + pub multi_img: MultiImg, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct ContentAttr { + pub text: String, + pub bg_color: String, + pub bg_color_night: String, + pub img: String, + pub multi_img: MultiImg, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct Producer { + pub mid: i64, + #[serde(rename = "type")] + pub type_field: i64, + pub is_contribute: Option, + pub title: String, +} diff --git a/src-tauri/src/types/bangumi_info.rs b/src-tauri/src/types/bangumi_info.rs index 5a95ae7..abe39c6 100644 --- a/src-tauri/src/types/bangumi_info.rs +++ b/src-tauri/src/types/bangumi_info.rs @@ -7,7 +7,7 @@ pub struct BangumiInfo { pub activity: Activity, pub actors: String, pub alias: String, - pub areas: Vec, + pub areas: Vec, pub bkg_cover: String, pub cover: String, pub delivery_fragment_video: bool, @@ -24,15 +24,15 @@ pub struct BangumiInfo { pub payment: Option, pub play_strategy: Option, pub positive: Positive, - pub publish: Publish, - pub rating: Option, + pub publish: PublishInBangumi, + pub rating: Option, pub record: String, pub rights: RightsInBangumi, pub season_id: i64, pub season_title: String, pub seasons: Vec, pub section: Option>, - pub series: Series, + pub series: SeriesInBangumi, pub share_copy: String, pub share_sub_title: String, pub share_url: String, @@ -95,7 +95,7 @@ pub struct Activity { } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct Area { +pub struct AreaInBangumi { pub id: i64, pub name: String, } @@ -105,7 +105,7 @@ pub struct Area { pub struct EpInBangumi { pub aid: i64, pub badge: String, - pub badge_info: BadgeInfo, + pub badge_info: BadgeInfoInBangumi, pub badge_type: Option, pub bvid: Option, pub cid: i64, @@ -140,7 +140,7 @@ pub struct EpInBangumi { } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct BadgeInfo { +pub struct BadgeInfoInBangumi { pub bg_color: String, pub bg_color_night: String, pub text: String, @@ -228,7 +228,7 @@ pub struct Positive { } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct Publish { +pub struct PublishInBangumi { pub is_finish: i64, pub is_started: i64, pub pub_time: String, @@ -238,7 +238,7 @@ pub struct Publish { } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] -pub struct Rating { +pub struct RatingInBangumi { pub count: i64, pub score: f64, } @@ -266,7 +266,7 @@ pub struct RightsInBangumi { #[allow(clippy::struct_field_names)] pub struct Season { pub badge: String, - pub badge_info: BadgeInfo, + pub badge_info: BadgeInfoInBangumi, pub badge_type: i64, pub cover: String, pub enable_vt: bool, @@ -298,7 +298,7 @@ pub struct StatInSeason { #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] #[allow(clippy::struct_field_names)] -pub struct Series { +pub struct SeriesInBangumi { pub display_type: i64, pub series_id: i64, pub series_title: String, diff --git a/src-tauri/src/types/get_bangumi_follow_info_params.rs b/src-tauri/src/types/get_bangumi_follow_info_params.rs new file mode 100644 index 0000000..2e61fbe --- /dev/null +++ b/src-tauri/src/types/get_bangumi_follow_info_params.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct GetBangumiFollowInfoParams { + pub vmid: i64, + /// 1: 番剧 2: 电视剧或电影 + #[serde(rename = "type")] + pub type_field: i64, + pub pn: i64, + // 0: 全部 1: 想看 2: 在看 3: 看过 + pub follow_status: i64, +} diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs index a2cd515..cf5e5de 100644 --- a/src-tauri/src/types/mod.rs +++ b/src-tauri/src/types/mod.rs @@ -1,4 +1,5 @@ pub mod audio_quality; +pub mod bangumi_follow_info; pub mod bangumi_info; pub mod bangumi_media_url; pub mod cheese_info; @@ -7,6 +8,7 @@ pub mod codec_type; pub mod create_download_task_params; pub mod fav_folders; pub mod fav_info; +pub mod get_bangumi_follow_info_params; pub mod get_bangumi_info_params; pub mod get_cheese_info_params; pub mod get_fav_info_params; @@ -20,6 +22,7 @@ pub mod qrcode_data; pub mod qrcode_status; pub mod search_params; pub mod search_result; +pub mod skip_segments; pub mod subtitle; pub mod tags; pub mod user_info; diff --git a/src-tauri/src/types/skip_segments.rs b/src-tauri/src/types/skip_segments.rs new file mode 100644 index 0000000..f657e39 --- /dev/null +++ b/src-tauri/src/types/skip_segments.rs @@ -0,0 +1,52 @@ +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); + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct SkipSegment { + pub cid: String, + pub category: String, + #[serde(rename = "actionType")] + pub action_type: String, + pub segment: Vec, + #[serde(rename = "UUID")] + pub uuid: String, + #[serde(rename = "videoDuration")] + 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-tauri/src/types/user_video_info.rs b/src-tauri/src/types/user_video_info.rs index bea78c4..08f7403 100644 --- a/src-tauri/src/types/user_video_info.rs +++ b/src-tauri/src/types/user_video_info.rs @@ -64,7 +64,7 @@ pub struct MetaInUserVideo { pub attribute: i64, pub stat: StatInUserVideo, pub ep_count: i64, - pub first_aid: i64, + pub first_aid: Option, pub ptime: i64, pub ep_num: i64, } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7983de6..c75e45a 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,7 +1,7 @@ use std::{ fs::File, io::{BufReader, Read}, - path::Path, + path::{Path, PathBuf}, }; use anyhow::{anyhow, Context}; @@ -176,3 +176,13 @@ pub fn seconds_to_srt_time(seconds: f64) -> String { let h = total_m / 60; format!("{h:02}:{m:02}:{s:02},{ms:03}") } + +pub fn get_ffmpeg_program() -> anyhow::Result { + let ffmpeg_program = std::env::current_exe() + .context("获取当前可执行文件路径失败")? + .parent() + .context("获取当前可执行文件所在目录失败")? + .join("com.lanyeeee.bilibili-video-downloader-ffmpeg"); + + Ok(ffmpeg_program) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6a41466..3b0fbe9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -35,6 +35,9 @@ "SimpChinese" ] } + }, + "macOS": { + "signingIdentity": "-" } } } \ No newline at end of file diff --git a/src/AppContent.vue b/src/AppContent.vue index d56dc16..585e219 100644 --- a/src/AppContent.vue +++ b/src/AppContent.vue @@ -9,6 +9,7 @@ import { PhMagnifyingGlass, PhStar, PhClock, + PhHeart, PhDownload, } from '@phosphor-icons/vue' import AboutDialog from './dialogs/AboutDialog.vue' @@ -20,8 +21,9 @@ import FavPane from './panes/FavPane/FavPane.vue' import WatchLaterPane from './panes/WatchLaterPane/WatchLaterPane.vue' import DownloadPane from './panes/DownloadPane/DownloadPane.vue' import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts' +import BangumiFollowPane from './panes/BangumiFollow/BangumiFollowPane.vue' -export type CurrentNavName = 'search' | 'fav' | 'watch_later' | 'download' +export type CurrentNavName = 'search' | 'fav' | 'watch_later' | 'bangumi_follow' | 'download' const currentPlatform = platform() @@ -93,6 +95,18 @@ onMounted(() => { + + 追番追剧 + + + 下载 -
- - - - +
+ + + + + + + + + + + + + + +
@@ -171,4 +196,14 @@ onMounted(() => { :deep(.n-badge-sup) { @apply pointer-events-none; } + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/src/bindings.ts b/src/bindings.ts index aa49acf..0a65c08 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -48,6 +48,14 @@ async getNormalInfo(params: GetNormalInfoParams) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_bangumi_info", { params }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getUserVideoInfo(params: GetUserVideoInfoParams) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_user_video_info", { params }) }; @@ -80,6 +88,14 @@ async getWatchLaterInfo(page: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_bangumi_follow_info", { params }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async createDownloadTasks(params: CreateDownloadTaskParams) : Promise { await TAURI_INVOKE("create_download_tasks", { params }); }, @@ -126,6 +142,14 @@ async showPathInFileManager(path: string) : Promise> if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async getSkipSegments(bvid: string, cid: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_skip_segments", { bvid, cid }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -149,13 +173,17 @@ logEvent: "log-event" export type AbtestInfo = { style_abtest: number } export type Activity = { head_bg_url: string; id: number; title: string } export type Arc = { aid: number; videos: number; type_id: number; type_name: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; rights: RightsInNormalEp; author: Author; stat: StatInNormalEp; dynamic: string; dimension: Dimension; is_chargeable_season: boolean; is_blooper: boolean; enable_vt: number; vt_display: string; type_id_v2: number; type_name_v2: string; is_lesson_video: number } -export type Area = { id: number; name: string } +export type AreaInBangumi = { id: number; name: string } +export type AreaInBangumiFollow = { id: number; name: string } export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string } export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes" export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean } export type Author = { mid: number; name: string; face: string } -export type BadgeInfo = { bg_color: string; bg_color_night: string; text: string } -export type BangumiInfo = { activity: Activity; actors: string; alias: string; areas: Area[]; bkg_cover: string; cover: string; delivery_fragment_video: boolean; enable_vt: boolean; episodes: EpInBangumi[]; evaluate: string; hide_ep_vv_vt_dm: number; icon_font: IconFont; jp_title: string; link: string; media_id: number; mode: number; new_ep: NewEp; payment: PaymentInBangumi | null; play_strategy: PlayStrategy | null; positive: Positive; publish: Publish; rating: Rating | null; record: string; rights: RightsInBangumi; season_id: number; season_title: string; seasons: Season[]; section: SectionInBangumi[] | null; series: Series; share_copy: string; share_sub_title: string; share_url: string; show: Show; show_season_type: number; square_cover: string; staff: string; stat: StatInBangumi; status: number; styles: string[]; subtitle: string; title: string; total: number; type: number; up_info: UpInfoInBangumi | null; user_status: UserStatusInBangumi } +export type BadgeInfoInBangumi = { bg_color: string; bg_color_night: string; text: string } +export type BadgeInfoInBangumiFollow = { text: string | null; bg_color: string; bg_color_night: string; img: string | null; multi_img: MultiImg } +export type BadgeInfos = { vip_or_pay: VipOrPay | null; content_attr: ContentAttr | null } +export type BangumiFollowInfo = { list: EpInBangumiFollow[]; pn: number; ps: number; total: number } +export type BangumiInfo = { activity: Activity; actors: string; alias: string; areas: AreaInBangumi[]; bkg_cover: string; cover: string; delivery_fragment_video: boolean; enable_vt: boolean; episodes: EpInBangumi[]; evaluate: string; hide_ep_vv_vt_dm: number; icon_font: IconFont; jp_title: string; link: string; media_id: number; mode: number; new_ep: NewEp; payment: PaymentInBangumi | null; play_strategy: PlayStrategy | null; positive: Positive; publish: PublishInBangumi; rating: RatingInBangumi | null; record: string; rights: RightsInBangumi; season_id: number; season_title: string; seasons: Season[]; section: SectionInBangumi[] | null; series: SeriesInBangumi; share_copy: string; share_sub_title: string; share_url: string; show: Show; show_season_type: number; square_cover: string; staff: string; stat: StatInBangumi; status: number; styles: string[]; subtitle: string; title: string; total: number; type: number; up_info: UpInfoInBangumi | null; user_status: UserStatusInBangumi } export type BangumiSearchResult = { ep: EpInBangumi | null; info: BangumiInfo } export type Brief = { content: string; img: Img[]; title: string; type: number } export type CanvasConfig = { @@ -219,8 +247,9 @@ 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; 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 } export type Cooperation = { link: string } export type CoverTask = { selected: boolean; url: string; completed: boolean } @@ -234,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; 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 EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfo; 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 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 } export type EpInNormal = { season_id: number; section_id: number; id: number; aid: number; cid: number; title: string; attribute: number; arc: Arc; page: PageInNormalEp; bvid: string; pages: PageInNormalEp[] } export type EpInUserVideo = { comment: number; typeid: number; play: number; pic: string; subtitle: string; description: string; copyright: string; title: string; review: number; author: string; mid: number; created: number; length: string; video_review: number; aid: number; bvid: string; hide_click: boolean; is_pay: number; is_union_video: number; is_steins_gate: number; is_live_playback: number; is_lesson_video: number; is_lesson_finished: number; lesson_update_info: string; jump_url: string; meta: MetaInUserVideo | null; is_avoided: number; season_id: number; attribute: number; is_charging_arc: boolean; elec_arc_type: number; elec_arc_badge: string; vt: number; enable_vt: number; vt_display: string; playback_position: number; is_self_view: boolean } @@ -250,7 +280,13 @@ export type Faq1Item = { answer: string; question: string } export type FavFolders = { count: number; list: Folder[] } export type FavInfo = { info: Info; medias: MediaInFav[] | null; has_more: boolean; ttl: number } export type FavSearchResult = FavInfo +export type FirstEpInfo = { id: number; cover: string; title: string; long_title: string | null; pub_time: string; duration: number } export type Folder = { id: number; fid: number; mid: number; attr: number; title: string; fav_state: number; media_count: number } +export type GetBangumiFollowInfoParams = { vmid: number; +/** + * 1: 番剧 2: 电视剧或电影 + */ +type: number; pn: number; follow_status: number } export type GetBangumiInfoParams = { EpId: number } | { SeasonId: number } export type GetCheeseInfoParams = { EpId: number } | { SeasonId: number } export type GetFavInfoParams = { media_list_id: number; pn: number } @@ -270,9 +306,10 @@ 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; ptime: number; ep_num: number } +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 } +export type NewEpInBangumiFollow = { id: number | null; index_show: string | null; cover: string | null; title: string | null; long_title: string | null; pub_time: string | null; duration: number | null } export type NewEpInSeason = { cover: string; id: number; index_show: string } export type NfoTask = { selected: boolean; completed: boolean } export type NormalInfo = { bvid: string; aid: number; videos: number; tid: number; tid_v2: number; tname: string; tname_v2: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; desc_v2: DescV2[] | null; state: number; duration: number; rights: Rights; owner: OwnerInNormal; stat: StatInNormal; argue_info: ArgueInfo; dynamic: string; cid: number; dimension: Dimension; teenage_mode: number; is_chargeable_season: boolean; is_story: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_upower_preview: boolean; enable_vt: number; vt_display: string; is_upower_exclusive_with_qa: boolean; no_cache: boolean; pages: PageInNormal[]; subtitle: SubtitleInNormal; staff: Staff[] | null; ugc_season: UgcSeason | null; is_season_display: boolean; user_garb: UserGarb; honor_reply: HonorReply; like_icon: string; need_jump_bv: boolean; disable_show_up_info: boolean; is_story_play: number; is_view_self: boolean } @@ -298,30 +335,39 @@ export type PreferAudioQuality = "Best" | "64K" | "132K" | "192K" | "Dolby" | "H export type PreferCodecType = "Unknown" | "AVC" | "HEVC" | "AV1" export type PreferVideoQuality = "Best" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K" export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string } +export type Producer = { mid: number; type: number; is_contribute: number | null; title: string } export type ProxyMode = "NoProxy" | "System" | "Custom" -export type Publish = { is_finish: number; is_started: number; pub_time: string; pub_time_show: string; unknow_pub_date: number; weekday: number } +export type PublishInBangumi = { is_finish: number; is_started: number; pub_time: string; pub_time_show: string; unknow_pub_date: number; weekday: number } +export type PublishInBangumiFollow = { pub_time: string; pub_time_show: string; release_date: string; release_date_show: string; pub_time_show_db: string | null } export type PurchaseFormatNote = { content_list: ContentList[]; link: string; title: string } export type PurchaseNote = { content: string; link: string; title: string } export type PurchaseProtocol = { link: string; title: string } export type QrcodeData = { url: string; qrcode_key: string } export type QrcodeStatus = { url: string; refresh_token: string; timestamp: number; code: number; message: string } -export type Rating = { count: number; score: number } +export type RatingInBangumi = { count: number; score: number } +export type RatingInBangumiFollow = { score: number; count: number } export type RecommendSeason = { cover: string; ep_count: string; id: number; season_url: string; subtitle: string; title: string; view: number } export type Rights = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; no_background: number; clean_mode: number; is_stein_gate: number; is_360: number; no_share: number; arc_pay: number; free_watch: number } export type RightsInBangumi = { allow_bp: number; allow_bp_rank: number; allow_download: number; allow_review: number; area_limit: number; ban_area_show: number; can_watch: number; copyright: string; forbid_pre: number; freya_white: number; is_cover_show: number; is_preview: number; only_vip_download: number; resource: string; watch_platform: number } export type RightsInBangumiEp = { allow_dm: number; allow_download: number; area_limit: number } +export type RightsInBangumiFollow = { allow_review: number | null; allow_preview: number | null; is_selection: number; selection_style: number; is_rcmd: number | null; allow_bp_rank: number | null; allow_bp: number | null; allow_download: number | null } export type RightsInNormalEp = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; arc_pay: number; free_watch: number } export type RightsInWatchLater = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; no_background: number; arc_pay: number; pay_free_watch: number } export type SearchParams = { Normal: GetNormalInfoParams } | { Bangumi: GetBangumiInfoParams } | { Cheese: GetCheeseInfoParams } | { UserVideo: GetUserVideoInfoParams } | { Fav: GetFavInfoParams } export type SearchResult = { Normal: NormalSearchResult } | { Bangumi: BangumiSearchResult } | { Cheese: CheeseSearchResult } | { UserVideo: UserVideoSearchResult } | { Fav: FavSearchResult } -export type Season = { badge: string; badge_info: BadgeInfo; badge_type: number; cover: string; enable_vt: boolean; horizontal_cover_1610: string; horizontal_cover_169: string; icon_font: IconFont; media_id: number; new_ep: NewEpInSeason; season_id: number; season_title: string; season_type: number; stat: StatInSeason } +export type Season = { badge: string; badge_info: BadgeInfoInBangumi; badge_type: number; cover: string; enable_vt: boolean; horizontal_cover_1610: string; horizontal_cover_169: string; icon_font: IconFont; media_id: number; new_ep: NewEpInSeason; season_id: number; season_title: string; season_type: number; stat: StatInSeason } export type SectionInBangumi = { attr: number; episodes: EpInBangumi[]; id: number; title: string; type: number; type2: number } +export type SectionInBangumiFollow = { section_id: number; season_id: number; limit_group: number; watch_platform: number; copyright: string; ban_area_show: number; episode_ids: number[]; type: number | null; title: string | null; attr: number | null } export type SectionInNormal = { season_id: number; id: number; title: string; type: number; episodes: EpInNormal[] } -export type Series = { display_type: number; series_id: number; series_title: string } +export type SeriesInBangumi = { display_type: number; series_id: number; series_title: string } +export type SeriesInBangumiFollow = { series_id: number | null; title: string | null; season_count: number | null; new_season_id: number | null; series_ord: number | null } export type Show = { wide_screen: number } export type Skip = { ed: Ed; op: Op } +export type SkipSegment = { cid: string; category: string; actionType: string; segment: number[]; UUID: string; videoDuration: number; locked: number; votes: number; description: string } +export type SkipSegments = SkipSegment[] export type Staff = { mid: number; title: string; name: string; face: string; follower: number; label_style: number } export type StatInBangumi = { coins: number; danmakus: number; favorite: number; favorites: number; follow_text: string; likes: number; reply: number; share: number; views: number; vt: number } +export type StatInBangumiFollow = { follow: number; view: number; danmaku: number; reply: number; coin: number; series_follow: number | null; series_view: number | null; likes: number; favorite: number } export type StatInCheese = { play: number; play_desc: string; show_vt: boolean } export type StatInNormal = { aid: number; view: number; danmaku: number; reply: number; favorite: number; coin: number; share: number; now_rank: number; his_rank: number; like: number; dislike: number; evaluation: string; vt: number } export type StatInNormalEp = { aid: number; view: number; danmaku: number; reply: number; fav: number; coin: number; share: number; now_rank: number; his_rank: number; like: number; dislike: number; evaluation: string; argue_msg: string; vt: number; vv: number } @@ -345,10 +391,12 @@ 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 } export type VipLabel = { path: string; text: string; label_theme: string; text_color: string; bg_style: number; bg_color: string; border_color: string; use_img_label: boolean; img_label_uri_hans: string; img_label_uri_hant: string; img_label_uri_hans_static: string; img_label_uri_hant_static: string } +export type VipOrPay = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg } export type Wallet = { mid: number; bcoin_balance: number; coupon_balance: number; coupon_due_time: number } export type WatchLaterInfo = { count: number; list: MediaInWatchLater[] } export type WbiImg = { img_url: string; sub_url: string } diff --git a/src/components/SimpleCheckbox.vue b/src/components/SimpleCheckbox.vue index f8f4df3..c3c4a11 100644 --- a/src/components/SimpleCheckbox.vue +++ b/src/components/SimpleCheckbox.vue @@ -1,5 +1,6 @@ + + diff --git a/src/panes/BangumiFollow/components/BangumiFollowCard.vue b/src/panes/BangumiFollow/components/BangumiFollowCard.vue new file mode 100644 index 0000000..6082def --- /dev/null +++ b/src/panes/BangumiFollow/components/BangumiFollowCard.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/panes/BangumiFollow/components/BangumiFollowPanel.vue b/src/panes/BangumiFollow/components/BangumiFollowPanel.vue new file mode 100644 index 0000000..1f5e89f --- /dev/null +++ b/src/panes/BangumiFollow/components/BangumiFollowPanel.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/panes/DownloadPane/DownloadPane.vue b/src/panes/DownloadPane/DownloadPane.vue index 972eef6..9c9a0d2 100644 --- a/src/panes/DownloadPane/DownloadPane.vue +++ b/src/panes/DownloadPane/DownloadPane.vue @@ -96,7 +96,7 @@ onMounted(async () => { const videoTask = progressData.video_task const audioTask = progressData.audio_task - const mergeTask = progressData.merge_task + const videoProcessTask = progressData.video_process_task const danmakuTask = progressData.danmaku_task const subtitleTask = progressData.subtitle_task const coverTask = progressData.cover_task @@ -115,9 +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) { - 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 = '弹幕' @@ -129,10 +138,10 @@ onMounted(async () => { progressData.taskIndicator = '封面' } else if (nfoTask.selected && !nfoTask.completed) { progressData.percentage = 100 - progressData.taskIndicator = 'nfo元数据' + progressData.taskIndicator = 'nfo刮削' } else if (jsonTask.selected && !jsonTask.completed) { progressData.percentage = 100 - progressData.taskIndicator = 'json元数据' + progressData.taskIndicator = 'json刮削' } }) } diff --git a/src/panes/DownloadPane/components/DownloadProgress.vue b/src/panes/DownloadPane/components/DownloadProgress.vue index a0c90ce..44a9c6c 100644 --- a/src/panes/DownloadPane/components/DownloadProgress.vue +++ b/src/panes/DownloadPane/components/DownloadProgress.vue @@ -153,14 +153,17 @@ function handleSearchClick() { P{{ p.part_order }} {{ p.part_title }} -
+
视频(编码:{{ p.video_task.codec_type }} 画质:{{ p.video_task.video_quality }}) 音频(音质:{{ p.audio_task.audio_quality }}) - 自动合并 + + 自动合并 + 标记章节 + 标记广告 xml弹幕 ass弹幕 @@ -169,8 +172,8 @@ function handleSearchClick() { 字幕 封面 - nfo元数据 - json元数据 + nfo刮削 + json刮削
diff --git a/src/panes/SearchPane/components/BangumiPanel.vue b/src/panes/SearchPane/components/BangumiPanel.vue index 05527d4..84aae75 100644 --- a/src/panes/SearchPane/components/BangumiPanel.vue +++ b/src/panes/SearchPane/components/BangumiPanel.vue @@ -104,6 +104,11 @@ watch( async () => { const episode = props.bangumiResult.ep if (episode === null) { + currentTabIndex.value = 0 + selectedIds.value.clear() + checkedIds.value.clear() + selectionAreaRef.value?.selection?.clearSelection() + selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' }) return } diff --git a/src/panes/SearchPane/components/CheesePanel.vue b/src/panes/SearchPane/components/CheesePanel.vue index 7f22e1d..c6c800d 100644 --- a/src/panes/SearchPane/components/CheesePanel.vue +++ b/src/panes/SearchPane/components/CheesePanel.vue @@ -90,6 +90,10 @@ watch( async () => { const ep = props.cheeseResult.ep if (ep === null) { + selectedIds.value.clear() + checkedIds.value.clear() + selectionAreaRef.value?.selection?.clearSelection() + selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' }) return } selectedIds.value = new Set([ep.id])