mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-08 17:16:52 +08:00
refactor: 将各任务处理逻辑移动到自己对应的文件中
This commit is contained in:
@@ -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::{
|
use std::{
|
||||||
fs::{File, OpenOptions},
|
|
||||||
io::{Seek, Write as IoWrite},
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
use fs4::fs_std::FileExt;
|
use parking_lot::RwLock;
|
||||||
use parking_lot::{Mutex, RwLock};
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{watch, SemaphorePermit},
|
sync::{watch, SemaphorePermit},
|
||||||
task::JoinSet,
|
|
||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
danmaku_xml_to_ass::xml_to_ass,
|
|
||||||
downloader::episode_type::EpisodeType,
|
|
||||||
events::DownloadEvent,
|
events::DownloadEvent,
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
extensions::{AnyhowErrorToStringChain, AppHandleExt, GetOrInitPlayerInfo},
|
||||||
types::{
|
types::{create_download_task_params::CreateDownloadTaskParams, player_info::PlayerInfo},
|
||||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo,
|
utils::{self},
|
||||||
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},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
||||||
@@ -288,27 +276,39 @@ impl DownloadTask {
|
|||||||
episode_dir.display()
|
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 player_info = None;
|
||||||
let mut episode_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
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载视频文件失败"))?;
|
.context(format!("{ids_string} `{filename}`下载视频文件失败"))?;
|
||||||
tracing::debug!("{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
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载音频文件失败"))?;
|
.context(format!("{ids_string} `{filename}`下载音频文件失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
let merge_completed = progress.merge_task.is_completed();
|
let merge_completed = merge_task.is_completed();
|
||||||
let embed_completed = progress.embed_chapter_task.is_completed();
|
let embed_completed = embed_chapter_task.is_completed();
|
||||||
|
|
||||||
if !merge_completed && !embed_completed {
|
if !merge_completed && !embed_completed {
|
||||||
// 如果合并任务和嵌入章节任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成
|
// 如果合并任务和嵌入章节任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成
|
||||||
@@ -320,48 +320,55 @@ impl DownloadTask {
|
|||||||
tracing::debug!("{ids_string} `{filename}`视频和音频合并+嵌入章节元数据完成");
|
tracing::debug!("{ids_string} `{filename}`视频和音频合并+嵌入章节元数据完成");
|
||||||
} else if !merge_completed {
|
} else if !merge_completed {
|
||||||
// 如果合并任务未完成,嵌入章节任务已完成,则只合并
|
// 如果合并任务未完成,嵌入章节任务已完成,则只合并
|
||||||
self.merge_video_audio(&progress)
|
merge_task
|
||||||
|
.process(self, &progress)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`合并视频和音频失败"))?;
|
.context(format!("{ids_string} `{filename}`合并视频和音频失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
|
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
|
||||||
} else if !embed_completed {
|
} else if !embed_completed {
|
||||||
// 如果嵌入章节任务未完成,合并任务已完成,则只嵌入
|
// 如果嵌入章节任务未完成,合并任务已完成,则只嵌入
|
||||||
self.embed_chapter(&progress, &mut player_info)
|
embed_chapter_task
|
||||||
|
.process(self, &progress, &mut player_info)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`嵌入章节元数据失败"))?;
|
.context(format!("{ids_string} `{filename}`嵌入章节元数据失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`嵌入章节元数据完成");
|
tracing::debug!("{ids_string} `{filename}`嵌入章节元数据完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.danmaku_task.is_completed() {
|
if !danmaku_task.is_completed() {
|
||||||
self.download_danmaku(&progress)
|
danmaku_task
|
||||||
|
.process(self, &progress)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
|
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
|
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.subtitle_task.is_completed() {
|
if !subtitle_task.is_completed() {
|
||||||
self.download_subtitle(&progress, &mut player_info)
|
subtitle_task
|
||||||
|
.process(self, &progress, &mut player_info)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载字幕失败"))?;
|
.context(format!("{ids_string} `{filename}`下载字幕失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`字幕下载完成");
|
tracing::debug!("{ids_string} `{filename}`字幕下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.cover_task.is_completed() {
|
if !cover_task.is_completed() {
|
||||||
self.download_cover(&progress)
|
cover_task
|
||||||
|
.process(self, &progress)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载封面失败"))?;
|
.context(format!("{ids_string} `{filename}`下载封面失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.nfo_task.is_completed() {
|
if !nfo_task.is_completed() {
|
||||||
self.download_nfo(&progress, &mut episode_info)
|
nfo_task
|
||||||
|
.process(self, &progress, &mut episode_info)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载NFO失败"))?;
|
.context(format!("{ids_string} `{filename}`下载NFO失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`NFO下载完成");
|
tracing::debug!("{ids_string} `{filename}`NFO下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.json_task.is_completed() {
|
if !json_task.is_completed() {
|
||||||
self.download_json(&progress, &mut episode_info)
|
json_task
|
||||||
|
.process(self, &progress, &mut episode_info)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载JSON元数据失败"))?;
|
.context(format!("{ids_string} `{filename}`下载JSON元数据失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`JSON元数据下载完成");
|
tracing::debug!("{ids_string} `{filename}`JSON元数据下载完成");
|
||||||
@@ -378,394 +385,8 @@ impl DownloadTask {
|
|||||||
Ok(())
|
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(
|
async fn merge_and_embed(
|
||||||
&self,
|
self: &Arc<Self>,
|
||||||
progress: &DownloadProgress,
|
progress: &DownloadProgress,
|
||||||
player_info: &mut Option<PlayerInfo>,
|
player_info: &mut Option<PlayerInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -784,7 +405,9 @@ impl DownloadTask {
|
|||||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||||
if !audio_path.exists() {
|
if !audio_path.exists() {
|
||||||
// 如果音频文件不存在,则只嵌入章节元数据
|
// 如果音频文件不存在,则只嵌入章节元数据
|
||||||
self.embed_chapter(progress, player_info)
|
progress
|
||||||
|
.embed_chapter_task
|
||||||
|
.process(self, progress, player_info)
|
||||||
.await
|
.await
|
||||||
.context("嵌入章节元数据失败")?;
|
.context("嵌入章节元数据失败")?;
|
||||||
|
|
||||||
@@ -876,242 +499,6 @@ impl DownloadTask {
|
|||||||
Ok(())
|
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) {
|
async fn sleep_between_task(&self) {
|
||||||
let task_id = &self.task_id;
|
let task_id = &self.task_id;
|
||||||
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
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 updated_progress = {
|
||||||
let mut progress = self.progress.write();
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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_manager;
|
||||||
pub mod download_progress;
|
pub mod download_progress;
|
||||||
pub mod download_task;
|
pub mod download_task;
|
||||||
pub mod download_task_state;
|
pub mod download_task_state;
|
||||||
|
pub mod episode_info;
|
||||||
pub mod episode_type;
|
pub mod episode_type;
|
||||||
pub mod fmt_params;
|
pub mod fmt_params;
|
||||||
pub mod media_chunk;
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::media_chunk::MediaChunk,
|
downloader::{
|
||||||
extensions::AppHandleExt,
|
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||||
|
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||||
|
},
|
||||||
|
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||||
types::{
|
types::{
|
||||||
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
||||||
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
||||||
},
|
},
|
||||||
|
utils,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||||
@@ -297,6 +307,119 @@ impl AudioTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||||
|
extensions::AppHandleExt,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct CoverTask {
|
pub struct CoverTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
@@ -12,4 +20,26 @@ impl CoverTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
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)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct DanmakuTask {
|
pub struct DanmakuTask {
|
||||||
@@ -14,4 +24,49 @@ impl DanmakuTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
|
!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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
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)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct EmbedChapterTask {
|
pub struct EmbedChapterTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
@@ -11,4 +21,91 @@ impl EmbedChapterTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::downloader::{
|
||||||
|
download_progress::DownloadProgress,
|
||||||
|
download_task::DownloadTask,
|
||||||
|
episode_info::{EpisodeInfo, GetOrInitEpisodeInfo},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct JsonTask {
|
pub struct JsonTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
@@ -11,4 +20,36 @@ impl JsonTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct MergeTask {
|
pub struct MergeTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
@@ -11,4 +19,87 @@ impl MergeTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 anyhow::{anyhow, Context};
|
||||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use yaserde::{YaDeserialize, YaSerialize};
|
use yaserde::{YaDeserialize, YaSerialize};
|
||||||
|
|
||||||
use crate::types::{
|
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,
|
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
@@ -18,6 +28,116 @@ impl NfoTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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)]
|
#[derive(YaSerialize, YaDeserialize)]
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
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)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct SubtitleTask {
|
pub struct SubtitleTask {
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
@@ -11,4 +21,49 @@ impl SubtitleTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::media_chunk::MediaChunk,
|
downloader::{
|
||||||
extensions::AppHandleExt,
|
download_chunk_task::DownloadChunkTask, download_progress::DownloadProgress,
|
||||||
|
download_task::DownloadTask, media_chunk::MediaChunk,
|
||||||
|
},
|
||||||
|
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||||
types::{
|
types::{
|
||||||
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
|
bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl,
|
||||||
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
|
codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality,
|
||||||
},
|
},
|
||||||
|
utils,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||||
@@ -288,6 +298,123 @@ impl VideoTask {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
!self.selected || self.completed
|
!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 {
|
struct MediaForPrepare {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
use anyhow::Context;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tauri::{Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
use crate::{
|
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 {
|
pub trait AnyhowErrorToStringChain {
|
||||||
@@ -43,3 +47,31 @@ impl AppHandleExt for tauri::AppHandle {
|
|||||||
self.state::<DownloadManager>()
|
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