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_task.rs b/src-tauri/src/downloader/download_task.rs index dcb700b..191bffa 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -1,34 +1,22 @@ use std::{ - fs::{File, OpenOptions}, - io::{Seek, Write as IoWrite}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::{anyhow, Context}; -use fs4::fs_std::FileExt; -use parking_lot::{Mutex, RwLock}; +use parking_lot::RwLock; use tauri::AppHandle; use tauri_specta::Event; use tokio::{ sync::{watch, SemaphorePermit}, - task::JoinSet, time::sleep, }; use crate::{ - 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, - player_info::PlayerInfo, - }, - utils::{self, ToXml}, + extensions::{AnyhowErrorToStringChain, AppHandleExt, GetOrInitPlayerInfo}, + types::{create_download_task_params::CreateDownloadTaskParams, player_info::PlayerInfo}, + utils::{self}, }; use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState}; @@ -288,27 +276,39 @@ impl DownloadTask { episode_dir.display() ))?; + let video_task = &progress.video_task; + let audio_task = &progress.audio_task; + let merge_task = &progress.merge_task; + let embed_chapter_task = &progress.embed_chapter_task; + let 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 !progress.video_task.is_completed() && progress.video_task.content_length != 0 { + if !video_task.is_completed() && video_task.content_length != 0 { // 如果视频任务被选中且未完成且有要下载的内容,则下载视频 - self.download_video(&progress) + 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 { + if !audio_task.is_completed() && audio_task.content_length != 0 { // 如果音频任务被选中且未完成且有要下载的内容,则下载音频 - self.download_audio(&progress) + audio_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`下载音频文件失败"))?; tracing::debug!("{ids_string} `{filename}`音频下载完成"); } - let merge_completed = progress.merge_task.is_completed(); - let embed_completed = progress.embed_chapter_task.is_completed(); + let merge_completed = merge_task.is_completed(); + let embed_completed = embed_chapter_task.is_completed(); if !merge_completed && !embed_completed { // 如果合并任务和嵌入章节任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成 @@ -320,48 +320,55 @@ impl DownloadTask { tracing::debug!("{ids_string} `{filename}`视频和音频合并+嵌入章节元数据完成"); } else if !merge_completed { // 如果合并任务未完成,嵌入章节任务已完成,则只合并 - self.merge_video_audio(&progress) + merge_task + .process(self, &progress) .await .context(format!("{ids_string} `{filename}`合并视频和音频失败"))?; tracing::debug!("{ids_string} `{filename}`视频和音频合并完成"); } else if !embed_completed { // 如果嵌入章节任务未完成,合并任务已完成,则只嵌入 - self.embed_chapter(&progress, &mut player_info) + embed_chapter_task + .process(self, &progress, &mut player_info) .await .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, &mut player_info) + 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}`封面下载完成"); } - 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元数据下载完成"); @@ -378,394 +385,8 @@ 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 = 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() - ))?; - - self.update_progress(|p| p.merge_task.completed = true); - - Ok(()) - } - - async fn embed_chapter( - &self, - progress: &DownloadProgress, - player_info: &mut Option, - ) -> anyhow::Result<()> { - let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); - - let video_path = episode_dir.join(format!("{filename}.mp4")); - if !video_path.exists() { - self.update_progress(|p| p.embed_chapter_task.completed = true); - return Ok(()); - } - - let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; - let output_path = episode_dir.join(format!("{filename}-embed.mp4")); - - let player_info = player_info.get_or_init(&self.app, progress).await?; - - let metadata_content = player_info.generate_chapter_metadata(); - let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); - - std::fs::write(&metadata_path, metadata_content) - .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; - - let (tx, rx) = tokio::sync::oneshot::channel(); - let video_path_clone = video_path.clone(); - let metadata_path_clone = metadata_path.clone(); - let output_path_clone = output_path.clone(); - - tauri::async_runtime::spawn_blocking(move || { - let mut command = std::process::Command::new(ffmpeg_program); - - command - .arg("-i") - .arg(video_path_clone) - .arg("-i") - .arg(metadata_path_clone) - .arg("-map_metadata") - .arg("1") - .arg("-c") - .arg("copy") - .arg(output_path_clone) - .arg("-y"); - - #[cfg(target_os = "windows")] - { - // 隐藏窗口 - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); - } - - let output = command.output(); - - let _ = tx.send(output); - }); - - let output = rx.await??; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let err = anyhow!(format!("STDOUT: {stdout}")) - .context(format!("STDERR: {stderr}")) - .context("原因可能是视频或音频文件损坏,建议[重来]试试"); - return Err(err); - } - - std::fs::remove_file(&video_path) - .context(format!("删除视频文件`{}`失败", video_path.display()))?; - std::fs::rename(&output_path, &video_path).context(format!( - "将`{}`重命名为`{}`失败", - output_path.display(), - video_path.display() - ))?; - std::fs::remove_file(&metadata_path).context(format!( - "删除章节元数据文件`{}`失败", - metadata_path.display() - ))?; - - self.update_progress(|p| p.embed_chapter_task.completed = true); - - Ok(()) - } - async fn merge_and_embed( - &self, + self: &Arc, progress: &DownloadProgress, player_info: &mut Option, ) -> anyhow::Result<()> { @@ -784,7 +405,9 @@ impl DownloadTask { let audio_path = episode_dir.join(format!("{filename}.m4a")); if !audio_path.exists() { // 如果音频文件不存在,则只嵌入章节元数据 - self.embed_chapter(progress, player_info) + progress + .embed_chapter_task + .process(self, progress, player_info) .await .context("嵌入章节元数据失败")?; @@ -876,242 +499,6 @@ impl DownloadTask { 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, - 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(&self.app, progress).await?; - - let bili_client = self.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)?; - } - - 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 episode_info = episode_info.get_or_init(&self.app, progress).await?; - - let bili_client = self.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()))?; - } - } - - 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 episode_info = episode_info.get_or_init(&self.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()))?; - - 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; @@ -1218,7 +605,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(); @@ -1240,193 +627,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, - 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)) - } -} - -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/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..73822a2 100644 --- a/src-tauri/src/downloader/mod.rs +++ b/src-tauri/src/downloader/mod.rs @@ -1,7 +1,9 @@ +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/embed_chapter_task.rs b/src-tauri/src/downloader/tasks/embed_chapter_task.rs index 5e5cf31..cd9f075 100644 --- a/src-tauri/src/downloader/tasks/embed_chapter_task.rs +++ b/src-tauri/src/downloader/tasks/embed_chapter_task.rs @@ -1,6 +1,16 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Context}; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::{ + downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, + extensions::GetOrInitPlayerInfo, + types::player_info::PlayerInfo, + utils, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct EmbedChapterTask { pub selected: bool, @@ -11,4 +21,91 @@ impl EmbedChapterTask { pub fn is_completed(&self) -> bool { !self.selected || self.completed } + + pub async fn process( + &self, + download_task: &Arc, + progress: &DownloadProgress, + player_info: &mut Option, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let video_path = episode_dir.join(format!("{filename}.mp4")); + if !video_path.exists() { + download_task.update_progress(|p| p.embed_chapter_task.completed = true); + return Ok(()); + } + + let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; + let output_path = episode_dir.join(format!("{filename}-embed.mp4")); + + let player_info = player_info + .get_or_init(&download_task.app, progress) + .await?; + + let metadata_content = player_info.generate_chapter_metadata(); + let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini")); + + std::fs::write(&metadata_path, metadata_content) + .context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?; + + let (tx, rx) = tokio::sync::oneshot::channel(); + let video_path_clone = video_path.clone(); + let metadata_path_clone = metadata_path.clone(); + let output_path_clone = output_path.clone(); + + tauri::async_runtime::spawn_blocking(move || { + let mut command = std::process::Command::new(ffmpeg_program); + + command + .arg("-i") + .arg(video_path_clone) + .arg("-i") + .arg(metadata_path_clone) + .arg("-map_metadata") + .arg("1") + .arg("-c") + .arg("copy") + .arg(output_path_clone) + .arg("-y"); + + #[cfg(target_os = "windows")] + { + // 隐藏窗口 + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + + let output = command.output(); + + let _ = tx.send(output); + }); + + let output = rx.await??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let err = anyhow!(format!("STDOUT: {stdout}")) + .context(format!("STDERR: {stderr}")) + .context("原因可能是视频或音频文件损坏,建议[重来]试试"); + return Err(err); + } + + std::fs::remove_file(&video_path) + .context(format!("删除视频文件`{}`失败", video_path.display()))?; + std::fs::rename(&output_path, &video_path).context(format!( + "将`{}`重命名为`{}`失败", + output_path.display(), + video_path.display() + ))?; + std::fs::remove_file(&metadata_path).context(format!( + "删除章节元数据文件`{}`失败", + metadata_path.display() + ))?; + + download_task.update_progress(|p| p.embed_chapter_task.completed = true); + + Ok(()) + } } diff --git a/src-tauri/src/downloader/tasks/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 index 731b20a..3e959b6 100644 --- a/src-tauri/src/downloader/tasks/merge_task.rs +++ b/src-tauri/src/downloader/tasks/merge_task.rs @@ -1,6 +1,14 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Context}; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::{ + downloader::{download_progress::DownloadProgress, download_task::DownloadTask}, + utils, +}; + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct MergeTask { pub selected: bool, @@ -11,4 +19,87 @@ impl MergeTask { pub fn is_completed(&self) -> bool { !self.selected || self.completed } + + pub async fn process( + &self, + download_task: &Arc, + progress: &DownloadProgress, + ) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let video_path = episode_dir.join(format!("{filename}.mp4")); + if !video_path.exists() { + download_task.update_progress(|p| p.merge_task.completed = true); + return Ok(()); + } + + let audio_path = episode_dir.join(format!("{filename}.m4a")); + if !audio_path.exists() { + download_task.update_progress(|p| p.merge_task.completed = true); + return Ok(()); + } + + let output_path = episode_dir.join(format!("{filename}-merged.mp4")); + + let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?; + + let (tx, rx) = tokio::sync::oneshot::channel(); + let video_path_clone = video_path.clone(); + let audio_path_clone = audio_path.clone(); + let output_path_clone = output_path.clone(); + + tauri::async_runtime::spawn_blocking(move || { + let mut command = std::process::Command::new(ffmpeg_program); + + command + .arg("-i") + .arg(video_path_clone) + .arg("-i") + .arg(audio_path_clone) + .arg("-c") + .arg("copy") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("1:a:0") + .arg(output_path_clone) + .arg("-y"); + + #[cfg(target_os = "windows")] + { + // 隐藏窗口 + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + + let output = command.output(); + + let _ = tx.send(output); + }); + + let output = rx.await??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let err = anyhow!(format!("STDOUT: {stdout}")) + .context(format!("STDERR: {stderr}")) + .context("原因可能是视频或音频文件损坏,建议[重来]试试"); + return Err(err); + } + + std::fs::remove_file(&video_path) + .context(format!("删除视频文件`{}`失败", video_path.display()))?; + std::fs::remove_file(&audio_path) + .context(format!("删除音频文件`{}`失败", audio_path.display()))?; + std::fs::rename(&output_path, &video_path).context(format!( + "将`{}`重命名为`{}`失败", + output_path.display(), + video_path.display() + ))?; + + download_task.update_progress(|p| p.merge_task.completed = true); + + Ok(()) + } } diff --git a/src-tauri/src/downloader/tasks/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_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)) + } +}