refactor: 将各任务处理逻辑移动到自己对应的文件中

This commit is contained in:
lanyeeee
2025-08-28 05:09:15 +08:00
parent 91f8c6d50c
commit 387d50030d
14 changed files with 1019 additions and 861 deletions

View 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(())
}
}

View File

@@ -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))
}
}

View 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))
}
}

View File

@@ -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;

View File

@@ -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)]

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -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)]

View File

@@ -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(())
}
}

View File

@@ -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 {

View File

@@ -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))
}
}