mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-08 09:06:53 +08:00
feat: 后端支持音频下载
This commit is contained in:
@@ -13,7 +13,9 @@ pub struct Config {
|
|||||||
pub sessdata: String,
|
pub sessdata: String,
|
||||||
pub prefer_video_quality: PreferVideoQuality,
|
pub prefer_video_quality: PreferVideoQuality,
|
||||||
pub prefer_codec_type: PreferCodecType,
|
pub prefer_codec_type: PreferCodecType,
|
||||||
|
pub prefer_audio_quality: PreferAudioQuality,
|
||||||
pub download_video: bool,
|
pub download_video: bool,
|
||||||
|
pub download_audio: bool,
|
||||||
pub dir_fmt: String,
|
pub dir_fmt: String,
|
||||||
pub dir_fmt_for_part: String,
|
pub dir_fmt_for_part: String,
|
||||||
pub time_fmt: String,
|
pub time_fmt: String,
|
||||||
@@ -83,7 +85,9 @@ impl Config {
|
|||||||
sessdata: String::new(),
|
sessdata: String::new(),
|
||||||
prefer_video_quality: PreferVideoQuality::Best,
|
prefer_video_quality: PreferVideoQuality::Best,
|
||||||
prefer_codec_type: PreferCodecType::AVC,
|
prefer_codec_type: PreferCodecType::AVC,
|
||||||
|
prefer_audio_quality: PreferAudioQuality::Best,
|
||||||
download_video: true,
|
download_video: true,
|
||||||
|
download_audio: true,
|
||||||
dir_fmt: "{collection_title}/{episode_title}".to_string(),
|
dir_fmt: "{collection_title}/{episode_title}".to_string(),
|
||||||
dir_fmt_for_part: DEFAULT_FMT_FOR_PART.to_string(),
|
dir_fmt_for_part: DEFAULT_FMT_FOR_PART.to_string(),
|
||||||
time_fmt: "%Y-%m-%d_%H-%M-%S".to_string(),
|
time_fmt: "%Y-%m-%d_%H-%M-%S".to_string(),
|
||||||
@@ -162,3 +166,31 @@ pub enum PreferCodecType {
|
|||||||
HEVC = 12,
|
HEVC = 12,
|
||||||
AV1 = 13,
|
AV1 = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Default,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Type,
|
||||||
|
IntoPrimitive,
|
||||||
|
FromPrimitive,
|
||||||
|
)]
|
||||||
|
#[repr(i64)]
|
||||||
|
pub enum PreferAudioQuality {
|
||||||
|
#[default]
|
||||||
|
Best = -1,
|
||||||
|
#[serde(rename = "64K")]
|
||||||
|
Audio64K = 30216,
|
||||||
|
#[serde(rename = "132K")]
|
||||||
|
Audio132K = 30232,
|
||||||
|
#[serde(rename = "192K")]
|
||||||
|
Audio192K = 30280,
|
||||||
|
#[serde(rename = "Dolby")]
|
||||||
|
AudioDolby = 30250,
|
||||||
|
#[serde(rename = "HiRes")]
|
||||||
|
AudioHiRes = 30251,
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
downloader::tasks::video_task::VideoTask,
|
downloader::tasks::{audio_task::AudioTask, video_task::VideoTask},
|
||||||
extensions::AppHandleExt,
|
extensions::AppHandleExt,
|
||||||
types::{
|
types::{
|
||||||
|
audio_quality::AudioQuality,
|
||||||
bangumi_info::BangumiInfo,
|
bangumi_info::BangumiInfo,
|
||||||
cheese_info::CheeseInfo,
|
cheese_info::CheeseInfo,
|
||||||
codec_type::CodecType,
|
codec_type::CodecType,
|
||||||
@@ -46,6 +47,7 @@ pub struct DownloadProgress {
|
|||||||
pub episode_dir: PathBuf,
|
pub episode_dir: PathBuf,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub video_task: VideoTask,
|
pub video_task: VideoTask,
|
||||||
|
pub audio_task: AudioTask,
|
||||||
pub create_ts: u64,
|
pub create_ts: u64,
|
||||||
pub completed_ts: Option<u64>,
|
pub completed_ts: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,7 @@ impl DownloadProgress {
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -155,6 +158,7 @@ impl DownloadProgress {
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -169,9 +173,11 @@ impl DownloadProgress {
|
|||||||
pub async fn prepare(&mut self, app: &AppHandle) -> anyhow::Result<()> {
|
pub async fn prepare(&mut self, app: &AppHandle) -> anyhow::Result<()> {
|
||||||
let video_selected = self.video_task.selected;
|
let video_selected = self.video_task.selected;
|
||||||
let video_completed = self.video_task.completed;
|
let video_completed = self.video_task.completed;
|
||||||
|
let audio_selected = self.audio_task.selected;
|
||||||
|
let audio_completed = self.audio_task.completed;
|
||||||
|
|
||||||
if !video_selected && video_completed {
|
if (!video_selected && !audio_selected) || (video_completed && audio_completed) {
|
||||||
// 如果视频没有选中,或者已经完成,则不需要准备
|
// 如果视频和音频都没有选中,或者都已经完成,则不需要准备
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +197,11 @@ impl DownloadProgress {
|
|||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
self.video_task.prepare_normal(app, &media_url).await?;
|
self.video_task.prepare_normal(app, &media_url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if audio_selected && !audio_completed {
|
||||||
|
// 如果音频被选中且未完成,则准备音频任务
|
||||||
|
self.audio_task.prepare_normal(app, &media_url).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
EpisodeType::Bangumi => {
|
EpisodeType::Bangumi => {
|
||||||
let media_url = bili_client
|
let media_url = bili_client
|
||||||
@@ -202,6 +213,11 @@ impl DownloadProgress {
|
|||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
self.video_task.prepare_bangumi(app, &media_url).await?;
|
self.video_task.prepare_bangumi(app, &media_url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if audio_selected && !audio_completed {
|
||||||
|
// 如果音频被选中且未完成,则准备音频任务
|
||||||
|
self.audio_task.prepare_bangumi(app, &media_url).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
EpisodeType::Cheese => {
|
EpisodeType::Cheese => {
|
||||||
let Some(ep_id) = self.ep_id else {
|
let Some(ep_id) = self.ep_id else {
|
||||||
@@ -216,6 +232,11 @@ impl DownloadProgress {
|
|||||||
// 如果视频被选中且未完成,则准备视频任务
|
// 如果视频被选中且未完成,则准备视频任务
|
||||||
self.video_task.prepare_cheese(app, &media_url).await?;
|
self.video_task.prepare_cheese(app, &media_url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if audio_selected && !audio_completed {
|
||||||
|
// 如果音频被选中且未完成,则准备音频任务
|
||||||
|
self.audio_task.prepare_cheese(app, &media_url).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,11 +291,12 @@ impl DownloadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
self.video_task.is_completed()
|
self.video_task.is_completed() && self.audio_task.is_completed()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_uncompleted(&mut self) {
|
pub fn mark_uncompleted(&mut self) {
|
||||||
self.video_task.mark_uncompleted();
|
self.video_task.mark_uncompleted();
|
||||||
|
self.audio_task.mark_uncompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_ids_string(&self) -> String {
|
pub fn get_ids_string(&self) -> String {
|
||||||
@@ -321,6 +343,7 @@ fn create_normal_progresses_for_single(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -354,6 +377,7 @@ fn create_normal_progresses_for_single(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -387,6 +411,7 @@ fn create_normal_progresses_for_single(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
|
audio_task: tasks.audio.clone(),
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -452,6 +477,7 @@ fn create_normal_progresses_for_season(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -485,6 +511,7 @@ fn create_normal_progresses_for_season(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
|
audio_task: tasks.audio,
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -519,6 +546,7 @@ fn create_normal_progresses_for_season(
|
|||||||
episode_dir: PathBuf::new(),
|
episode_dir: PathBuf::new(),
|
||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
|
audio_task: tasks.audio.clone(),
|
||||||
create_ts,
|
create_ts,
|
||||||
completed_ts: None,
|
completed_ts: None,
|
||||||
};
|
};
|
||||||
@@ -534,6 +562,7 @@ fn create_normal_progresses_for_season(
|
|||||||
|
|
||||||
struct Tasks {
|
struct Tasks {
|
||||||
video: VideoTask,
|
video: VideoTask,
|
||||||
|
audio: AudioTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tasks {
|
impl Tasks {
|
||||||
@@ -548,6 +577,15 @@ impl Tasks {
|
|||||||
completed: false,
|
completed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self { video }
|
let audio = AudioTask {
|
||||||
|
selected: config.download_audio,
|
||||||
|
url: String::new(),
|
||||||
|
audio_quality: AudioQuality::Unknown,
|
||||||
|
content_length: 0,
|
||||||
|
chunks: Vec::new(),
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { video, audio }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,14 @@ impl DownloadTask {
|
|||||||
tracing::debug!("{ids_string} `{filename}`视频下载完成");
|
tracing::debug!("{ids_string} `{filename}`视频下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !progress.audio_task.is_completed() && progress.audio_task.content_length != 0 {
|
||||||
|
// 如果音频任务被选中且未完成且有要下载的内容,则下载音频
|
||||||
|
self.download_audio(&progress)
|
||||||
|
.await
|
||||||
|
.context(format!("{ids_string} `{filename}`下载音频文件失败"))?;
|
||||||
|
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
||||||
|
}
|
||||||
|
|
||||||
let completed_ts = SystemTime::now()
|
let completed_ts = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
@@ -412,6 +420,116 @@ impl DownloadTask {
|
|||||||
Ok(())
|
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 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;
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
downloader::media_chunk::MediaChunk,
|
||||||
|
extensions::AppHandleExt,
|
||||||
|
types::{
|
||||||
|
audio_quality::AudioQuality, bangumi_media_url::BangumiMediaUrl,
|
||||||
|
cheese_media_url::CheeseMediaUrl, normal_media_url::NormalMediaUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
pub struct AudioTask {
|
||||||
|
pub selected: bool,
|
||||||
|
pub url: String,
|
||||||
|
pub audio_quality: AudioQuality,
|
||||||
|
pub content_length: u64,
|
||||||
|
pub chunks: Vec<MediaChunk>,
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioTask {
|
||||||
|
pub async fn prepare_normal(
|
||||||
|
&mut self,
|
||||||
|
app: &AppHandle,
|
||||||
|
media_url: &NormalMediaUrl,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut join_set = JoinSet::new();
|
||||||
|
|
||||||
|
if let Some(medias) = &media_url.dash.audio {
|
||||||
|
for media in medias {
|
||||||
|
let app = app.clone();
|
||||||
|
let id = media.id;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
urls.extend_from_slice(&media.backup_url);
|
||||||
|
urls.push(media.base_url.clone());
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let url_with_content_length =
|
||||||
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
|
MediaForPrepare {
|
||||||
|
id,
|
||||||
|
url_with_content_length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(medias) = &media_url.dash.dolby.audio {
|
||||||
|
for media in medias {
|
||||||
|
let app = app.clone();
|
||||||
|
let id = media.id;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
urls.extend_from_slice(&media.backup_url);
|
||||||
|
urls.push(media.base_url.clone());
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let url_with_content_length =
|
||||||
|
bili_client.get_url_with_content_length(urls).await;
|
||||||
|
MediaForPrepare {
|
||||||
|
id,
|
||||||
|
url_with_content_length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flac = media_url.dash.flac.as_ref();
|
||||||
|
if let Some(media) = flac.and_then(|flac| flac.audio.as_ref()) {
|
||||||
|
let app = app.clone();
|
||||||
|
let id = media.id;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
urls.extend_from_slice(&media.backup_url);
|
||||||
|
urls.push(media.base_url.clone());
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
|
MediaForPrepare {
|
||||||
|
id,
|
||||||
|
url_with_content_length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(Ok(media)) = join_set.join_next().await {
|
||||||
|
if !media.url_with_content_length.is_empty() {
|
||||||
|
medias.push(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if medias.is_empty() {
|
||||||
|
return Err(anyhow!("获取音频地址失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prepare(app, medias);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prepare_bangumi(
|
||||||
|
&mut self,
|
||||||
|
app: &AppHandle,
|
||||||
|
media_url: &BangumiMediaUrl,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some(dash) = &media_url.dash else {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(medias) = &dash.audio else {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if medias.is_empty() {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut join_set = JoinSet::new();
|
||||||
|
|
||||||
|
for media in medias {
|
||||||
|
let app = app.clone();
|
||||||
|
let id = media.id;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
urls.extend_from_slice(&media.backup_url);
|
||||||
|
urls.push(media.base_url.clone());
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
|
MediaForPrepare {
|
||||||
|
id,
|
||||||
|
url_with_content_length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(Ok(media)) = join_set.join_next().await {
|
||||||
|
if !media.url_with_content_length.is_empty() {
|
||||||
|
medias.push(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if medias.is_empty() {
|
||||||
|
return Err(anyhow!("获取音频地址失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prepare(app, medias);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prepare_cheese(
|
||||||
|
&mut self,
|
||||||
|
app: &AppHandle,
|
||||||
|
media_url: &CheeseMediaUrl,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some(dash) = &media_url.dash else {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(medias) = &dash.audio else {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if medias.is_empty() {
|
||||||
|
// 如果没有音频,则直接返回
|
||||||
|
self.completed = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut join_set = JoinSet::new();
|
||||||
|
|
||||||
|
for media in medias {
|
||||||
|
let app = app.clone();
|
||||||
|
let id = media.id;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
urls.extend_from_slice(&media.backup_url);
|
||||||
|
urls.push(media.base_url.clone());
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let url_with_content_length = bili_client.get_url_with_content_length(urls).await;
|
||||||
|
MediaForPrepare {
|
||||||
|
id,
|
||||||
|
url_with_content_length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut medias: Vec<MediaForPrepare> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(Ok(media)) = join_set.join_next().await {
|
||||||
|
if !media.url_with_content_length.is_empty() {
|
||||||
|
medias.push(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if medias.is_empty() {
|
||||||
|
return Err(anyhow!("获取音频地址失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prepare(app, medias);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(&mut self, app: &AppHandle, mut medias: Vec<MediaForPrepare>) {
|
||||||
|
medias.sort_by_key(|m| Reverse(m.id.to_audio_quality_for_prepare()));
|
||||||
|
let best_quality_id = medias[0].id;
|
||||||
|
|
||||||
|
let prefer_quality = app.get_config().read().prefer_audio_quality;
|
||||||
|
let prefer_quality_id: i64 = prefer_quality.into();
|
||||||
|
let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id);
|
||||||
|
let quality_filtered_medias: Vec<MediaForPrepare> = if prefer_quality_found {
|
||||||
|
// 如果用户指定质量存在,则使用用户指定的质量
|
||||||
|
medias
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| m.id == prefer_quality_id)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
// 否则使用最高质量
|
||||||
|
medias
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| m.id == best_quality_id)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let media = &quality_filtered_medias[0];
|
||||||
|
|
||||||
|
self.audio_quality = media.id.into();
|
||||||
|
|
||||||
|
let (url, content_length) = media
|
||||||
|
.url_with_content_length
|
||||||
|
.iter()
|
||||||
|
.find(|(url, _)| url.starts_with("https://upos-"))
|
||||||
|
.unwrap_or(&media.url_with_content_length[0])
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
self.url = url;
|
||||||
|
|
||||||
|
if self.content_length != content_length {
|
||||||
|
let chunk_count = content_length.div_ceil(CHUNK_SIZE);
|
||||||
|
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
let mut chunks = Vec::with_capacity(chunk_count as usize);
|
||||||
|
for i in 0..chunk_count {
|
||||||
|
let start = i * CHUNK_SIZE;
|
||||||
|
let end = std::cmp::min(start + CHUNK_SIZE, content_length) - 1;
|
||||||
|
chunks.push(MediaChunk {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content_length = content_length;
|
||||||
|
self.chunks = chunks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_uncompleted(&mut self) {
|
||||||
|
self.completed = false;
|
||||||
|
self.chunks.iter_mut().for_each(|chunk| {
|
||||||
|
chunk.completed = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_completed(&self) -> bool {
|
||||||
|
!self.selected || self.completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MediaForPrepare {
|
||||||
|
pub id: i64,
|
||||||
|
pub url_with_content_length: Vec<(String, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum AudioQualityForPrepare {
|
||||||
|
Audio64K,
|
||||||
|
Audio132K,
|
||||||
|
Audio192K,
|
||||||
|
AudioDolby,
|
||||||
|
AudioHiRes,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait ToAudioQualityForPrepare {
|
||||||
|
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToAudioQualityForPrepare for i64 {
|
||||||
|
fn to_audio_quality_for_prepare(self) -> Option<AudioQualityForPrepare> {
|
||||||
|
let audio_quality: AudioQuality = self.into();
|
||||||
|
match audio_quality {
|
||||||
|
AudioQuality::Audio64K => Some(AudioQualityForPrepare::Audio64K),
|
||||||
|
AudioQuality::Audio132K => Some(AudioQualityForPrepare::Audio132K),
|
||||||
|
AudioQuality::Audio192K => Some(AudioQualityForPrepare::Audio192K),
|
||||||
|
AudioQuality::AudioDolby => Some(AudioQualityForPrepare::AudioDolby),
|
||||||
|
AudioQuality::AudioHiRes => Some(AudioQualityForPrepare::AudioHiRes),
|
||||||
|
AudioQuality::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod audio_task;
|
||||||
pub mod video_task;
|
pub mod video_task;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Default,
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Type,
|
||||||
|
IntoPrimitive,
|
||||||
|
FromPrimitive,
|
||||||
|
)]
|
||||||
|
#[repr(i64)]
|
||||||
|
pub enum AudioQuality {
|
||||||
|
#[default]
|
||||||
|
Unknown = -1,
|
||||||
|
|
||||||
|
#[serde(rename = "64K")]
|
||||||
|
Audio64K = 30216,
|
||||||
|
#[serde(rename = "132K")]
|
||||||
|
Audio132K = 30232,
|
||||||
|
#[serde(rename = "192K")]
|
||||||
|
Audio192K = 30280,
|
||||||
|
#[serde(rename = "Dolby")]
|
||||||
|
AudioDolby = 30250,
|
||||||
|
#[serde(rename = "HiRes")]
|
||||||
|
AudioHiRes = 30251,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod audio_quality;
|
||||||
pub mod bangumi_info;
|
pub mod bangumi_info;
|
||||||
pub mod bangumi_media_url;
|
pub mod bangumi_media_url;
|
||||||
pub mod cheese_info;
|
pub mod cheese_info;
|
||||||
|
|||||||
+5
-2
@@ -170,6 +170,8 @@ export type Activity = { head_bg_url: string; id: number; title: string }
|
|||||||
export type Arc = { aid: number; videos: number; type_id: number; type_name: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; rights: RightsInNormalEp; author: Author; stat: StatInNormalEp; dynamic: string; dimension: Dimension; is_chargeable_season: boolean; is_blooper: boolean; enable_vt: number; vt_display: string; type_id_v2: number; type_name_v2: string; is_lesson_video: number }
|
export type Arc = { aid: number; videos: number; type_id: number; type_name: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; rights: RightsInNormalEp; author: Author; stat: StatInNormalEp; dynamic: string; dimension: Dimension; is_chargeable_season: boolean; is_blooper: boolean; enable_vt: number; vt_display: string; type_id_v2: number; type_name_v2: string; is_lesson_video: number }
|
||||||
export type Area = { id: number; name: string }
|
export type Area = { id: number; name: string }
|
||||||
export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string }
|
export type ArgueInfo = { argue_msg: string; argue_type: number; argue_link: string }
|
||||||
|
export type AudioQuality = "Unknown" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
|
||||||
|
export type AudioTask = { selected: boolean; url: string; audio_quality: AudioQuality; content_length: number; chunks: MediaChunk[]; completed: boolean }
|
||||||
export type Author = { mid: number; name: string; face: string }
|
export type Author = { mid: number; name: string; face: string }
|
||||||
export type AvatarIcon = { icon_resource: IconResource }
|
export type AvatarIcon = { icon_resource: IconResource }
|
||||||
export type BadgeInfo = { bg_color: string; bg_color_night: string; text: string }
|
export type BadgeInfo = { bg_color: string; bg_color_night: string; text: string }
|
||||||
@@ -183,7 +185,7 @@ export type CntInfo = { collect: number; play: number; thumb_up: number; share:
|
|||||||
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
||||||
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||||
export type CommandError = { err_title: string; err_message: string }
|
export type CommandError = { err_title: string; err_message: string }
|
||||||
export type Config = { downloadDir: string; enableFileLogger: boolean; sessdata: string; preferVideoQuality: PreferVideoQuality; preferCodecType: PreferCodecType; downloadVideo: boolean; dirFmt: string; dirFmtForPart: string; timeFmt: string; taskConcurrency: number; taskDownloadIntervalSec: number; chunkConcurrency: number; chunkDownloadIntervalSec: number }
|
export type Config = { downloadDir: string; enableFileLogger: boolean; sessdata: string; preferVideoQuality: PreferVideoQuality; preferCodecType: PreferCodecType; preferAudioQuality: PreferAudioQuality; downloadVideo: boolean; downloadAudio: boolean; dirFmt: string; dirFmtForPart: string; timeFmt: string; taskConcurrency: number; taskDownloadIntervalSec: number; chunkConcurrency: number; chunkDownloadIntervalSec: number }
|
||||||
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
||||||
export type ContentList = { bold: boolean; content: string; number: string }
|
export type ContentList = { bold: boolean; content: string; number: string }
|
||||||
export type Cooperation = { link: string }
|
export type Cooperation = { link: string }
|
||||||
@@ -200,7 +202,7 @@ export type DimensionInBangumi = { height: number; rotate: number; width: number
|
|||||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||||
export type Dolby = { type: number; audio: MediaInNormal[] | null }
|
export type Dolby = { type: number; audio: MediaInNormal[] | null }
|
||||||
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
||||||
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; create_ts: number; completed_ts: number | null }
|
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; create_ts: number; completed_ts: number | null }
|
||||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||||
export type DurlDetailInBangumi = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
export type DurlDetailInBangumi = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||||
export type DurlDetailInCheese = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
export type DurlDetailInCheese = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||||
@@ -271,6 +273,7 @@ export type PlayStrategy = { strategies: string[] }
|
|||||||
export type PlayViewBusinessInfo = { user_status: UserStatusInCheeseUrl }
|
export type PlayViewBusinessInfo = { user_status: UserStatusInCheeseUrl }
|
||||||
export type PlayerInfo = { aid: number; bvid: string; allow_bp: boolean; no_share: boolean; cid: number; max_limit: number; page_no: number; has_next: boolean; ip_info: IpInfo; login_mid: number; login_mid_hash: string; is_owner: boolean; name: string; permission: string; level_info: LevelInfoInPlayerInfo; vip: VipInPlayerInfo; answer_status: number; block_time: number; role: string; last_play_time: number; last_play_cid: number; now_time: number; online_count: number; need_login_subtitle: boolean; subtitle: SubtitleInPlayerInfo; view_points: ViewPoint[]; preview_toast: string; options: Options; online_switch: OnlineSwitch; fawkes: Fawkes; show_switch: ShowSwitch; toast_block: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_ugc_pay_preview: boolean; elec_high_level: ElecHighLevel; disable_show_up_info: boolean; is_upower_exclusive_with_qa: boolean }
|
export type PlayerInfo = { aid: number; bvid: string; allow_bp: boolean; no_share: boolean; cid: number; max_limit: number; page_no: number; has_next: boolean; ip_info: IpInfo; login_mid: number; login_mid_hash: string; is_owner: boolean; name: string; permission: string; level_info: LevelInfoInPlayerInfo; vip: VipInPlayerInfo; answer_status: number; block_time: number; role: string; last_play_time: number; last_play_cid: number; now_time: number; online_count: number; need_login_subtitle: boolean; subtitle: SubtitleInPlayerInfo; view_points: ViewPoint[]; preview_toast: string; options: Options; online_switch: OnlineSwitch; fawkes: Fawkes; show_switch: ShowSwitch; toast_block: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_ugc_pay_preview: boolean; elec_high_level: ElecHighLevel; disable_show_up_info: boolean; is_upower_exclusive_with_qa: boolean }
|
||||||
export type Positive = { id: number; title: string }
|
export type Positive = { id: number; title: string }
|
||||||
|
export type PreferAudioQuality = "Best" | "64K" | "132K" | "192K" | "Dolby" | "HiRes"
|
||||||
export type PreferCodecType = "Unknown" | "AVC" | "HEVC" | "AV1"
|
export type PreferCodecType = "Unknown" | "AVC" | "HEVC" | "AV1"
|
||||||
export type PreferVideoQuality = "Best" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
|
export type PreferVideoQuality = "Best" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
|
||||||
export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string }
|
export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user