feat: 后端支持音频下载

This commit is contained in:
lanyeeee
2025-07-23 06:03:07 +08:00
parent a1649e6a07
commit 17c98a423e
8 changed files with 565 additions and 7 deletions
+32
View File
@@ -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,
}
+43 -5
View File
@@ -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<u64>,
}
@@ -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 }
}
}
+118
View File
@@ -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<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) {
let task_id = &self.task_id;
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
View File
@@ -1 +1,2 @@
pub mod audio_task;
pub mod video_task;
+32
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod audio_quality;
pub mod bangumi_info;
pub mod bangumi_media_url;
pub mod cheese_info;
+5 -2
View File
@@ -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 }