diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 8e99dcb..c923998 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -13,7 +13,9 @@ pub struct Config { pub sessdata: String, pub prefer_video_quality: PreferVideoQuality, pub prefer_codec_type: PreferCodecType, + pub prefer_audio_quality: PreferAudioQuality, pub download_video: bool, + pub download_audio: bool, pub dir_fmt: String, pub dir_fmt_for_part: String, pub time_fmt: String, @@ -83,7 +85,9 @@ impl Config { sessdata: String::new(), prefer_video_quality: PreferVideoQuality::Best, prefer_codec_type: PreferCodecType::AVC, + prefer_audio_quality: PreferAudioQuality::Best, download_video: true, + download_audio: true, dir_fmt: "{collection_title}/{episode_title}".to_string(), dir_fmt_for_part: DEFAULT_FMT_FOR_PART.to_string(), time_fmt: "%Y-%m-%d_%H-%M-%S".to_string(), @@ -162,3 +166,31 @@ pub enum PreferCodecType { HEVC = 12, 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, +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index d662077..c948108 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -11,9 +11,10 @@ use uuid::Uuid; use crate::{ config::Config, - downloader::tasks::video_task::VideoTask, + downloader::tasks::{audio_task::AudioTask, video_task::VideoTask}, extensions::AppHandleExt, types::{ + audio_quality::AudioQuality, bangumi_info::BangumiInfo, cheese_info::CheeseInfo, codec_type::CodecType, @@ -46,6 +47,7 @@ pub struct DownloadProgress { pub episode_dir: PathBuf, pub filename: String, pub video_task: VideoTask, + pub audio_task: AudioTask, pub create_ts: u64, pub completed_ts: Option, } @@ -111,6 +113,7 @@ impl DownloadProgress { episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -155,6 +158,7 @@ impl DownloadProgress { episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -169,9 +173,11 @@ impl DownloadProgress { pub async fn prepare(&mut self, app: &AppHandle) -> anyhow::Result<()> { let video_selected = self.video_task.selected; 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(()); } @@ -191,6 +197,11 @@ impl DownloadProgress { // 如果视频被选中且未完成,则准备视频任务 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 => { let media_url = bili_client @@ -202,6 +213,11 @@ impl DownloadProgress { // 如果视频被选中且未完成,则准备视频任务 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 => { let Some(ep_id) = self.ep_id else { @@ -216,6 +232,11 @@ impl DownloadProgress { // 如果视频被选中且未完成,则准备视频任务 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 { - self.video_task.is_completed() + self.video_task.is_completed() && self.audio_task.is_completed() } pub fn mark_uncompleted(&mut self) { self.video_task.mark_uncompleted(); + self.audio_task.mark_uncompleted(); } pub fn get_ids_string(&self) -> String { @@ -321,6 +343,7 @@ fn create_normal_progresses_for_single( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -354,6 +377,7 @@ fn create_normal_progresses_for_single( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -387,6 +411,7 @@ fn create_normal_progresses_for_single( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video.clone(), + audio_task: tasks.audio.clone(), create_ts, completed_ts: None, }; @@ -452,6 +477,7 @@ fn create_normal_progresses_for_season( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -485,6 +511,7 @@ fn create_normal_progresses_for_season( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video, + audio_task: tasks.audio, create_ts, completed_ts: None, }; @@ -519,6 +546,7 @@ fn create_normal_progresses_for_season( episode_dir: PathBuf::new(), filename: String::new(), video_task: tasks.video.clone(), + audio_task: tasks.audio.clone(), create_ts, completed_ts: None, }; @@ -534,6 +562,7 @@ fn create_normal_progresses_for_season( struct Tasks { video: VideoTask, + audio: AudioTask, } impl Tasks { @@ -548,6 +577,15 @@ impl Tasks { 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 } } } diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index 659c22a..58d3f1f 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -288,6 +288,14 @@ impl DownloadTask { 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() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) @@ -412,6 +420,116 @@ impl DownloadTask { Ok(()) } + async fn download_audio(self: &Arc, progress: &DownloadProgress) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let temp_file_path = episode_dir.join(format!( + "{filename}.m4a.com.lanyeeee.bilibili-video-downloader" + )); + let (audio_task, episode_title, ids_string) = { + let progress = self.progress.read(); + ( + progress.audio_task.clone(), + progress.episode_title.clone(), + progress.get_ids_string(), + ) + }; + + let file = if temp_file_path.exists() { + // 如果文件已存在,则打开它 + OpenOptions::new() + .read(true) + .write(true) + .open(&temp_file_path)? + } else { + // 如果文件不存在,创建它并预分配空间 + let file = File::create(&temp_file_path)?; + file.allocate(audio_task.content_length)?; + file + }; + let file = Arc::new(Mutex::new(file)); + + let chunk_count = audio_task.chunks.len(); + + let mut join_set = JoinSet::new(); + for (chunk_index, chunk) in audio_task.chunks.iter().enumerate() { + if chunk.completed { + continue; + } + + let (start, end) = (chunk.start, chunk.end); + + let download_chunk_task = DownloadChunkTask { + download_task: self.clone(), + start, + end, + url: audio_task.url.to_string(), + file: file.clone(), + chunk_index, + }; + + join_set.spawn(async move { + download_chunk_task.process().await.context(format!( + "分片`{chunk_index}/{chunk_count}`下载失败({start}-{end})" + )) + }); + } + + while let Some(Ok(download_video_result)) = join_set.join_next().await { + match download_video_result { + Ok(i) => self.update_progress(|p| p.audio_task.chunks[i].completed = true), + Err(err) => { + let err_title = format!("{ids_string} `{episode_title}`音频的一个分片下载失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + } + } + + let download_completed = self + .progress + .read() + .audio_task + .chunks + .iter() + .all(|chunk| chunk.completed); + if !download_completed { + return Err(anyhow!( + "音频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", + temp_file_path.display() + )); + } + + let is_audio_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( + "检查音频文件`{}`是否完整失败", + temp_file_path.display() + ))?; + + if !is_audio_file_complete { + self.update_progress(|p| p.video_task.mark_uncompleted()); + return Err(anyhow!( + "音频文件`{}`不完整,[继续]会重新下载所有分片", + temp_file_path.display() + )); + } + + // 重命名临时文件 + let m4a_path = episode_dir.join(format!("{filename}.m4a")); + if m4a_path.exists() { + std::fs::remove_file(&m4a_path) + .context(format!("删除已存在的音频文件`{}`失败", m4a_path.display()))?; + } + std::fs::rename(&temp_file_path, &m4a_path).context(format!( + "将临时文件`{}`重命名为`{}`失败", + temp_file_path.display(), + m4a_path.display() + ))?; + + self.update_progress(|p| p.audio_task.completed = true); + + Ok(()) + } + async fn sleep_between_task(&self) { let task_id = &self.task_id; let mut remaining_sec = self.app.get_config().read().task_download_interval_sec; diff --git a/src-tauri/src/downloader/tasks/audio_task.rs b/src-tauri/src/downloader/tasks/audio_task.rs new file mode 100644 index 0000000..b3586e8 --- /dev/null +++ b/src-tauri/src/downloader/tasks/audio_task.rs @@ -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, + 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 = 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 = 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 = 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) { + 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 = 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; +} + +impl ToAudioQualityForPrepare for i64 { + fn to_audio_quality_for_prepare(self) -> Option { + 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, + } + } +} diff --git a/src-tauri/src/downloader/tasks/mod.rs b/src-tauri/src/downloader/tasks/mod.rs index d66b1a7..b9b46ac 100644 --- a/src-tauri/src/downloader/tasks/mod.rs +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -1 +1,2 @@ +pub mod audio_task; pub mod video_task; diff --git a/src-tauri/src/types/audio_quality.rs b/src-tauri/src/types/audio_quality.rs new file mode 100644 index 0000000..2743e10 --- /dev/null +++ b/src-tauri/src/types/audio_quality.rs @@ -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, +} diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs index f190178..03a7e3f 100644 --- a/src-tauri/src/types/mod.rs +++ b/src-tauri/src/types/mod.rs @@ -1,3 +1,4 @@ +pub mod audio_quality; pub mod bangumi_info; pub mod bangumi_media_url; pub mod cheese_info; diff --git a/src/bindings.ts b/src/bindings.ts index 3416ceb..ebe1db9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 Area = { id: number; name: 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 AvatarIcon = { icon_resource: IconResource } 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 CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1" 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 ContentList = { bold: boolean; content: string; number: 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 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 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 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 } @@ -271,6 +273,7 @@ export type PlayStrategy = { strategies: string[] } 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 Positive = { id: number; title: string } +export type PreferAudioQuality = "Best" | "64K" | "132K" | "192K" | "Dolby" | "HiRes" 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 PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string }