mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-05-06 20:02:57 +08:00
refactor: 将各任务处理逻辑移动到自己对应的文件中
This commit is contained in:
118
src-tauri/src/downloader/download_chunk_task.rs
Normal file
118
src-tauri/src/downloader/download_chunk_task.rs
Normal file
@@ -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<DownloadTask>,
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
pub url: String,
|
||||
pub file: Arc<Mutex<File>>,
|
||||
pub chunk_index: usize,
|
||||
}
|
||||
|
||||
impl DownloadChunkTask {
|
||||
pub async fn process(self) -> anyhow::Result<usize> {
|
||||
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<usize> {
|
||||
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<SemaphorePermit<'a>>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Self>, 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<Self>, 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<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
self.update_progress(|p| 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<Self>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> 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<PlayerInfo>,
|
||||
) -> 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<EpisodeInfo>,
|
||||
) -> 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<EpisodeInfo>,
|
||||
) -> 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<DownloadTask>,
|
||||
start: u64,
|
||||
end: u64,
|
||||
url: String,
|
||||
file: Arc<Mutex<File>>,
|
||||
chunk_index: usize,
|
||||
}
|
||||
|
||||
impl DownloadChunkTask {
|
||||
async fn process(self) -> anyhow::Result<usize> {
|
||||
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<usize> {
|
||||
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<SemaphorePermit<'a>>,
|
||||
) -> 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<EpisodeInfo> {
|
||||
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<PlayerInfo> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
70
src-tauri/src/downloader/episode_info.rs
Normal file
70
src-tauri/src/downloader/episode_info.rs
Normal file
@@ -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<EpisodeInfo> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
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)]
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
download_task.update_progress(|p| p.embed_chapter_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
|
||||
|
||||
let player_info = player_info
|
||||
.get_or_init(&download_task.app, progress)
|
||||
.await?;
|
||||
|
||||
let metadata_content = player_info.generate_chapter_metadata();
|
||||
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||
|
||||
std::fs::write(&metadata_path, metadata_content)
|
||||
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let metadata_path_clone = metadata_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command
|
||||
.arg("-i")
|
||||
.arg(video_path_clone)
|
||||
.arg("-i")
|
||||
.arg(metadata_path_clone)
|
||||
.arg("-map_metadata")
|
||||
.arg("1")
|
||||
.arg("-c")
|
||||
.arg("copy")
|
||||
.arg(output_path_clone)
|
||||
.arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
std::fs::remove_file(&metadata_path).context(format!(
|
||||
"删除章节元数据文件`{}`失败",
|
||||
metadata_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.embed_chapter_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||
if !video_path.exists() {
|
||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||
if !audio_path.exists() {
|
||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||
|
||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let video_path_clone = video_path.clone();
|
||||
let audio_path_clone = audio_path.clone();
|
||||
let output_path_clone = output_path.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(ffmpeg_program);
|
||||
|
||||
command
|
||||
.arg("-i")
|
||||
.arg(video_path_clone)
|
||||
.arg("-i")
|
||||
.arg(audio_path_clone)
|
||||
.arg("-c")
|
||||
.arg("copy")
|
||||
.arg("-map")
|
||||
.arg("0:v:0")
|
||||
.arg("-map")
|
||||
.arg("1:a:0")
|
||||
.arg(output_path_clone)
|
||||
.arg("-y");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 隐藏窗口
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = command.output();
|
||||
|
||||
let _ = tx.send(output);
|
||||
});
|
||||
|
||||
let output = rx.await??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||
.context(format!("STDERR: {stderr}"))
|
||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&video_path)
|
||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||
std::fs::remove_file(&audio_path)
|
||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||
std::fs::rename(&output_path, &video_path).context(format!(
|
||||
"将`{}`重命名为`{}`失败",
|
||||
output_path.display(),
|
||||
video_path.display()
|
||||
))?;
|
||||
|
||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> 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)]
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
progress: &DownloadProgress,
|
||||
player_info: &mut Option<PlayerInfo>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadTask>,
|
||||
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 {
|
||||
|
||||
@@ -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::<DownloadManager>()
|
||||
}
|
||||
}
|
||||
|
||||
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<PlayerInfo> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user