Compare commits

..
17 changed files with 269 additions and 91 deletions
+2
View File
@@ -192,6 +192,8 @@ pub mod v1 {
Video1080P60 = 116,
#[serde(rename = "4K")]
Video4K = 120,
#[serde(rename = "SDR")]
VideoSDR = 122,
#[serde(rename = "HDR")]
VideoHDR = 125,
#[serde(rename = "Dolby")]
+47
View File
@@ -301,6 +301,7 @@ dependencies = [
"parking_lot 0.12.4",
"prost",
"rand 0.9.1",
"rayon",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
@@ -322,6 +323,7 @@ dependencies = [
"tracing-appender",
"tracing-error",
"tracing-subscriber",
"urlencoding",
"uuid",
"yaserde",
]
@@ -678,6 +680,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -3360,6 +3381,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
@@ -5093,6 +5134,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
+2
View File
@@ -60,6 +60,8 @@ memchr = { version = "2.7.5" }
md-5 = { version = "0.10.6" }
rand = { version = "0.9.1" }
base64 = { version = "0.22.1" }
rayon = { version = "1.12.0" }
urlencoding = { version = "2.1.3" }
[profile.release]
strip = true
+9 -4
View File
@@ -435,7 +435,7 @@ impl BiliClient {
let params = json!({
"cid": cid,
"qn": 127,
"fnval": 4048,
"fnval": 139216,
"drm_tech_type": 2,
});
// 发送获取番剧url的请求
@@ -478,7 +478,7 @@ impl BiliClient {
let params = json!({
"cid": cid,
"qn": 127,
"fnval": 4048,
"fnval": 139216,
"drm_tech_type": 2,
"from_client": "BROWSER",
});
@@ -522,7 +522,7 @@ impl BiliClient {
let params = json!({
"ep_id": ep_id,
"qn": 127,
"fnval": 4048,
"fnval": 139216,
"drm_tech_type": 2,
});
// 发送获取课程url的请求
@@ -1098,7 +1098,12 @@ impl BiliClient {
pub fn get_cookie(&self) -> String {
let sessdata = self.app.get_config().read().sessdata.clone();
format!("SESSDATA={}", sessdata.trim_end_matches(';'))
let trimmed = sessdata.trim_end_matches(';');
let decoded = urlencoding::decode(trimmed).unwrap_or_else(|_| trimmed.into());
let encoded = urlencoding::encode(&decoded);
format!("SESSDATA={encoded}")
}
}
+5 -3
View File
@@ -11,6 +11,7 @@ use tracing::instrument;
use crate::{
config::Config,
downloader::download_task::RestoredDownloadTask,
errors::{CommandError, CommandResult},
extensions::AppHandleExt,
logger,
@@ -310,13 +311,14 @@ pub fn restart_download_task(app: AppHandle, params: RestartDownloadTaskParams)
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> {
pub async fn restore_download_tasks(app: AppHandle) -> CommandResult<Vec<RestoredDownloadTask>> {
let download_manager = app.get_download_manager();
download_manager
let restored_tasks = download_manager
.restore_download_tasks()
.await
.map_err(|err| CommandError::from("恢复下载任务失败", err))?;
tracing::debug!("恢复下载任务成功");
Ok(())
Ok(restored_tasks)
}
#[tauri::command(async)]
+54 -26
View File
@@ -53,7 +53,7 @@ impl Config {
let app_data_dir = app.path().app_data_dir()?;
let config_path = app_data_dir.join("config.json");
let config = if config_path.exists() {
let mut config = if config_path.exists() {
let config_string = std::fs::read_to_string(config_path)?;
match serde_json::from_str(&config_string) {
// 如果能够直接解析为Config,则直接返回
@@ -65,6 +65,8 @@ impl Config {
} else {
Config::default(&app_data_dir)
};
config.reconcile_priority_lists();
config.save(app)?;
Ok(config)
}
@@ -78,6 +80,18 @@ impl Config {
Ok(())
}
fn reconcile_priority_lists(&mut self) {
reconcile(
&mut self.video_quality_priority,
DEFAULT_VIDEO_QUALITY_PRIORITY,
);
reconcile(
&mut self.audio_quality_priority,
DEFAULT_AUDIO_QUALITY_PRIORITY,
);
reconcile(&mut self.codec_type_priority, DEFAULT_CODEC_TYPE_PRIORITY);
}
fn merge_config(config_string: &str, app_data_dir: &Path) -> Config {
let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(config_string) else {
return Config::default(app_data_dir);
@@ -103,36 +117,14 @@ impl Config {
fn default(app_data_dir: &Path) -> Config {
const DEFAULT_FMT_FOR_PART: &str =
"{collection_title}/{episode_title}/{episode_title}-P{part_order} {part_title}";
let default_video_quality_priority = vec![
VideoQuality::Video8K,
VideoQuality::VideoDolby,
VideoQuality::VideoHDR,
VideoQuality::Video4K,
VideoQuality::Video1080P60,
VideoQuality::Video1080PPlus,
VideoQuality::Video1080P,
VideoQuality::VideoAiRepair,
VideoQuality::Video720P60,
VideoQuality::Video720P,
VideoQuality::Video480P,
VideoQuality::Video360P,
VideoQuality::Video240P,
];
let default_audio_quality_priority = vec![
AudioQuality::AudioHiRes,
AudioQuality::AudioDolby,
AudioQuality::Audio192K,
AudioQuality::Audio132K,
AudioQuality::Audio64K,
];
Config {
download_dir: app_data_dir.join("视频下载"),
enable_file_logger: true,
sessdata: String::new(),
video_quality_priority: default_video_quality_priority,
codec_type_priority: vec![CodecType::AVC, CodecType::HEVC, CodecType::AV1],
audio_quality_priority: default_audio_quality_priority,
video_quality_priority: DEFAULT_VIDEO_QUALITY_PRIORITY.to_vec(),
codec_type_priority: DEFAULT_CODEC_TYPE_PRIORITY.to_vec(),
audio_quality_priority: DEFAULT_AUDIO_QUALITY_PRIORITY.to_vec(),
download_video: true,
download_audio: true,
auto_merge: true,
@@ -162,6 +154,14 @@ impl Config {
}
}
fn reconcile<T: Copy + Eq>(priority: &mut Vec<T>, all: &[T]) {
for &item in all {
if !priority.contains(&item) {
priority.push(item);
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)]
pub enum ProxyMode {
#[default]
@@ -176,3 +176,31 @@ pub enum FileExistAction {
Overwrite,
Skip,
}
const DEFAULT_VIDEO_QUALITY_PRIORITY: &[VideoQuality] = &[
VideoQuality::Video8K,
VideoQuality::VideoDolby,
VideoQuality::VideoHDR,
VideoQuality::VideoSDR,
VideoQuality::Video4K,
VideoQuality::Video1080P60,
VideoQuality::Video1080PPlus,
VideoQuality::Video1080P,
VideoQuality::VideoAiRepair,
VideoQuality::Video720P60,
VideoQuality::Video720P,
VideoQuality::Video480P,
VideoQuality::Video360P,
VideoQuality::Video240P,
];
const DEFAULT_AUDIO_QUALITY_PRIORITY: &[AudioQuality] = &[
AudioQuality::AudioHiRes,
AudioQuality::AudioDolby,
AudioQuality::Audio192K,
AudioQuality::Audio132K,
AudioQuality::Audio64K,
];
const DEFAULT_CODEC_TYPE_PRIORITY: &[CodecType] =
&[CodecType::AVC, CodecType::HEVC, CodecType::AV1];
+78 -22
View File
@@ -10,12 +10,17 @@ use std::{
use eyre::{WrapErr, eyre};
use parking_lot::RwLock;
use rayon::prelude::*;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tokio::sync::Semaphore;
use tracing::instrument;
use tokio::{
sync::{Semaphore, oneshot},
task::JoinSet,
};
use tracing::{Instrument, instrument};
use crate::{
downloader::download_progress::DownloadProgress,
events::DownloadEvent,
extensions::{AppHandleExt, EyreReportToMessage},
types::{
@@ -24,7 +29,7 @@ use crate::{
},
};
use super::{download_progress::DownloadProgress, download_task::DownloadTask};
use super::download_task::{DownloadTask, RestoredDownloadTask};
pub struct DownloadManager {
pub app: AppHandle,
@@ -58,41 +63,92 @@ impl DownloadManager {
}
#[instrument(level = "error", skip_all)]
pub fn restore_download_tasks(&self) -> eyre::Result<()> {
pub async fn restore_download_tasks(&self) -> eyre::Result<Vec<RestoredDownloadTask>> {
struct TaskFile {
path: PathBuf,
content: String,
}
let task_dir = self.get_task_dir()?;
std::fs::create_dir_all(&task_dir)
.wrap_err(format!("创建下载任务目录`{}`失败", task_dir.display()))?;
let mut tasks = self.download_tasks.write();
let mut join_set = JoinSet::new();
for entry in std::fs::read_dir(&task_dir)?.filter_map(Result::ok) {
let path = entry.path();
let extension = path.extension().and_then(|s| s.to_str());
if extension != Some("json") {
// 如果这个文件不是json则删除
let _ = std::fs::remove_file(&path);
continue;
}
let read_task_file = async move {
let path = entry.path();
let progress_json = std::fs::read_to_string(&path)?;
let extension = path.extension().and_then(|s| s.to_str());
if extension != Some("json") {
// 如果这个文件不是json则删除
let _ = tokio::fs::remove_file(path).await;
return None;
}
let progress: DownloadProgress =
if let Ok(progress) = serde_json::from_str(&progress_json) {
progress
} else {
// 如果这个json解析失败则删除
let _ = std::fs::remove_file(&path);
continue;
let content = match tokio::fs::read_to_string(&path)
.await
.map_err(eyre::Report::from)
{
Ok(content) => content,
Err(err) => {
let err_title = format!("读取下载任务文件`{}`失败", path.display());
let message = err.to_message();
tracing::error!(err_title, message);
return None;
}
};
let new_task = DownloadTask::from_progress(self.app.clone(), progress);
Some(TaskFile { path, content })
};
join_set.spawn(read_task_file.in_current_span());
}
let mut task_files = Vec::new();
while let Some(join_result) = join_set.join_next().await {
let Ok(Some(task_file)) = join_result else {
continue;
};
task_files.push(task_file);
}
let (progresses_sender, progresses_receiver) = oneshot::channel();
rayon::spawn(move || {
let progresses = task_files
.into_par_iter()
.filter_map(|task_file| {
if let Ok(progress) = serde_json::from_str(&task_file.content) {
Some(progress)
} else {
// 如果这个json解析失败则删除
let _ = std::fs::remove_file(&task_file.path);
None
}
})
.collect();
let _ = progresses_sender.send(progresses);
});
let progresses: Vec<DownloadProgress> = progresses_receiver.await?;
let mut tasks = self.download_tasks.write();
let mut restored_tasks = Vec::new();
for progress in progresses {
let new_task = DownloadTask::from_progress(self.app.clone(), progress.clone());
let state = *new_task.state_sender.borrow();
let old_task = tasks.insert(new_task.task_id.clone(), new_task);
if let Some(old_task) = old_task {
// 如果同一个ID的下载任务已经存在,则取消旧的任务
old_task.cancel();
}
restored_tasks.push(RestoredDownloadTask { state, progress });
}
Ok(())
Ok(restored_tasks)
}
pub fn create_download_tasks(&self, params: &CreateDownloadTaskParams) {
@@ -217,6 +217,9 @@ impl DownloadProgress {
self.prepare(app).await.wrap_err("准备下载失败")?;
self.completed_ts = None; // 重置完成时间戳
download_task.update_progress(|p| *p = self.clone());
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::AfterPrepare(AfterPrepareContext::new(self)))
@@ -225,9 +228,6 @@ impl DownloadProgress {
download_task.update_progress(|p| *p = self.clone());
}
self.completed_ts = None; // 重置完成时间戳
download_task.update_progress(|p| *p = self.clone());
std::fs::create_dir_all(&self.episode_dir)
.wrap_err(format!("创建目录`{}`失败", self.episode_dir.display()))?;
@@ -250,6 +250,7 @@ impl DownloadProgress {
tracing::debug!("音频下载任务完成");
}
*self = download_task.progress.read().clone();
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::BeforeVideoProcess(
@@ -324,6 +325,7 @@ impl DownloadProgress {
download_task.update_progress(|p| p.completed_ts = Some(completed_ts));
}
*self = download_task.progress.read().clone();
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::OnCompleted(OnCompletedContext::new(self)))
+17 -9
View File
@@ -1,7 +1,9 @@
use std::{sync::Arc, time::Duration};
use std::{sync::Arc, time::Duration};
use eyre::WrapErr;
use parking_lot::RwLock;
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tokio::{
@@ -33,6 +35,12 @@ pub struct DownloadTask {
pub progress: RwLock<DownloadProgress>,
}
#[derive(Debug, Clone, Serialize, Type)]
pub struct RestoredDownloadTask {
pub state: DownloadTaskState,
pub progress: DownloadProgress,
}
impl DownloadTask {
#[allow(clippy::too_many_lines)]
#[instrument(level = "error", skip_all)]
@@ -168,7 +176,7 @@ impl DownloadTask {
progress: RwLock::new(progress),
});
tauri::async_runtime::spawn(task.clone().process());
tauri::async_runtime::spawn(task.clone().process(true));
tasks.push(task);
}
@@ -198,7 +206,7 @@ impl DownloadTask {
progress: RwLock::new(progress),
});
tauri::async_runtime::spawn(task.clone().process());
tauri::async_runtime::spawn(task.clone().process(false));
task
}
@@ -287,14 +295,14 @@ impl DownloadTask {
up_uid = self.trace_fields.up_uid,
)
)]
async fn process(self: Arc<Self>) {
let state = *self.state_sender.borrow();
let progress = self.progress.read().clone();
let _ = DownloadEvent::TaskCreate { state, progress }.emit(&self.app);
async fn process(self: Arc<Self>, emit_create_events: bool) {
if emit_create_events {
let state = *self.state_sender.borrow();
let progress = self.progress.read().clone();
let _ = DownloadEvent::TaskCreate { state, progress }.emit(&self.app);
}
let mut state_receiver = self.state_sender.subscribe();
state_receiver.mark_changed();
let mut restart_receiver = self.restart_sender.subscribe();
let mut cancel_receiver = self.cancel_sender.subscribe();
let mut delete_receiver = self.delete_sender.subscribe();
@@ -67,7 +67,7 @@ impl DanmakuTask {
let replies = bili_client
.get_danmaku(progress.aid, progress.cid, progress.duration)
.await
.wrap_err("获取弹幕失败")?;
.wrap_err("获取弹幕失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[下载弹幕]相关的所有选项")?;
let xml = replies
.to_xml(progress.cid)
@@ -47,10 +47,9 @@ impl SubtitleTask {
for subtitle_detail in &player_info.subtitle.subtitles {
let url = format!("http:{}", subtitle_detail.subtitle_url);
let subtitle = bili_client
.get_subtitle(&url)
.await
.wrap_err("获取字幕失败")?;
let subtitle = bili_client.get_subtitle(&url).await.wrap_err(
"获取字幕失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[下载字幕]",
)?;
let mut srt_content = String::new();
for (i, b) in subtitle.body.iter().enumerate() {
@@ -292,7 +292,10 @@ impl VideoProcessTask {
let bili_client = app.get_bili_client();
let cid = Some(progress.cid);
let skip_segments = bili_client.get_skip_segments(bvid, cid).await?;
let skip_segments = bili_client
.get_skip_segments(bvid, cid)
.await
.wrap_err("获取广告片段失败,请[继续]以重试。如果重试多次依旧失败,请在设置中取消勾选[标记广告]")?;
for segment in skip_segments.0 {
if let Some(chapter_segment) = segment.into_chapter_segment() {
chapter_segments.insert(chapter_segment);
+1 -1
View File
@@ -172,7 +172,7 @@ pub struct PaidJump {
pub struct Payment {
pub bp_enough: i64,
pub desc: String,
pub my_bp: i64,
pub my_bp: f64,
pub pay_shade: String,
pub price: f64,
pub price_format: String,
+2
View File
@@ -41,6 +41,8 @@ pub enum VideoQuality {
Video1080P60 = 116,
#[serde(rename = "4K")]
Video4K = 120,
#[serde(rename = "SDR")]
VideoSDR = 122,
#[serde(rename = "HDR")]
VideoHDR = 125,
#[serde(rename = "Dolby")]
+3 -2
View File
@@ -125,7 +125,7 @@ async restartDownloadTasks(taskIds: string[]) : Promise<void> {
async restartDownloadTask(params: RestartDownloadTaskParams) : Promise<void> {
await TAURI_INVOKE("restart_download_task", { params });
},
async restoreDownloadTasks() : Promise<Result<null, CommandError>> {
async restoreDownloadTasks() : Promise<Result<RestoredDownloadTask[], CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("restore_download_tasks") };
} catch (e) {
@@ -429,6 +429,7 @@ export type RatingInBangumi = { count: number; score: number }
export type RatingInBangumiFollow = { score: number; count: number }
export type RecommendSeason = { cover: string; ep_count: string; id: number; season_url: string; subtitle: string; title: string; view: number }
export type RestartDownloadTaskParams = { task_id: string; video_task_selected: boolean; audio_task_selected: boolean; merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; subtitle_task_selected: boolean; xml_danmaku_selected: boolean; ass_danmaku_selected: boolean; json_danmaku_selected: boolean; cover_task_selected: boolean; nfo_task_selected: boolean; json_task_selected: boolean; video_quality: VideoQuality; codec_type: CodecType; audio_quality: AudioQuality }
export type RestoredDownloadTask = { state: DownloadTaskState; progress: DownloadProgress }
export type Rights = { bp: number; elec: number; download: number; movie: number; pay: number; hd5: number; no_reprint: number; autoplay: number; ugc_pay: number; is_cooperation: number; ugc_pay_preview: number; no_background: number; clean_mode: number; is_stein_gate: number; is_360: number; no_share: number; arc_pay: number; free_watch: number }
export type RightsInBangumi = { allow_bp: number; allow_bp_rank: number; allow_download: number; allow_review: number; area_limit: number; ban_area_show: number; can_watch: number; copyright: string; forbid_pre: number; freya_white: number; is_cover_show: number; is_preview: number; only_vip_download: number; resource: string; watch_platform: number }
export type RightsInBangumiEp = { allow_dm: number; allow_download: number; area_limit: number }
@@ -474,7 +475,7 @@ export type UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
export type UserVideoList = { vlist: EpInUserVideo[] }
export type UserVideoSearchResult = UserVideoInfo
export type VideoProcessTask = { merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; completed: boolean; skipped: boolean }
export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "SDR" | "HDR" | "Dolby" | "8K"
export type VideoQualityAndCodecType = { video_quality: VideoQuality; codec_type: CodecType }
export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean; skipped: boolean }
export type VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number }
+35 -15
View File
@@ -33,7 +33,7 @@ onMounted(async () => {
...progress,
state,
percentage: 0,
stateIndicator: '',
stateIndicator: getStateIndicator(state),
taskIndicator: '',
}
store.updateProgresses((progresses) => {
@@ -48,21 +48,8 @@ onMounted(async () => {
return
}
let stateIndicator = ''
if (state === 'Pending') {
stateIndicator = '排队中'
} else if (state === 'Downloading') {
stateIndicator = '下载中'
} else if (state === 'Paused') {
stateIndicator = '已暂停'
} else if (state === 'Completed') {
stateIndicator = '下载完成'
} else if (state === 'Failed') {
stateIndicator = '下载失败'
}
progressData.state = state
progressData.stateIndicator = stateIndicator
progressData.stateIndicator = getStateIndicator(state)
})
} else if (event === 'TaskSleeping') {
const { task_id, remaining_sec } = data
@@ -151,8 +138,41 @@ onMounted(async () => {
const result = await commands.restoreDownloadTasks()
if (result.status === 'error') {
console.error(result.error)
return
}
store.updateProgresses((progresses) => {
for (const { state, progress } of result.data) {
const progressData: ProgressData = {
...progress,
state,
percentage: 0,
stateIndicator: getStateIndicator(state),
taskIndicator: '',
}
progresses.set(progress.task_id, progressData)
}
})
})
function getStateIndicator(state: DownloadTaskState) {
let stateIndicator = ''
if (state === 'Pending') {
stateIndicator = '排队中'
} else if (state === 'Downloading') {
stateIndicator = '下载中'
} else if (state === 'Paused') {
stateIndicator = '已暂停'
} else if (state === 'Completed') {
stateIndicator = '下载完成'
} else if (state === 'Failed') {
stateIndicator = '下载失败'
}
return stateIndicator
}
</script>
<template>
+1
View File
@@ -249,6 +249,7 @@ const videoQualityNameMap: Record<VideoQuality, string> = {
'1080P+': '1080P 高码率',
'1080P60': '1080P 60帧',
'4K': '4K 超高清',
SDR: '4K SDR增强',
HDR: 'HDR 真彩色',
Dolby: '杜比视界',
'8K': '8K 超高清',