feat: 后端支持字幕下载

This commit is contained in:
lanyeeee
2025-07-26 04:47:56 +08:00
parent 874494d15d
commit 3eed0de812
10 changed files with 145 additions and 5 deletions
+17 -2
View File
@@ -24,8 +24,8 @@ use crate::{
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams, get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams,
normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo, normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo,
qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, user_info::UserInfo, qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, subtitle::Subtitle,
watch_later_info::WatchLaterInfo, user_info::UserInfo, watch_later_info::WatchLaterInfo,
}, },
}; };
@@ -670,6 +670,21 @@ impl BiliClient {
Ok(replies) Ok(replies)
} }
pub async fn get_subtitle(&self, url: &str) -> anyhow::Result<Subtitle> {
let request = self.api_client.read().get(url);
let http_resp = request.send().await?;
let status = http_resp.status();
let body = http_resp.text().await?;
if status != StatusCode::OK {
return Err(anyhow!("预料之外的状态码({status}): {body}"));
}
// 尝试将body解析为Subtitle
let subtitle: Subtitle =
serde_json::from_str(&body).context(format!("将body解析为Subtitle失败: {body}"))?;
Ok(subtitle)
}
fn get_cookie(&self) -> String { fn get_cookie(&self) -> String {
let sessdata = self.app.get_config().read().sessdata.clone(); let sessdata = self.app.get_config().read().sessdata.clone();
format!("SESSDATA={sessdata}") format!("SESSDATA={sessdata}")
+2
View File
@@ -22,6 +22,7 @@ pub struct Config {
pub download_xml_danmaku: bool, pub download_xml_danmaku: bool,
pub download_ass_danmaku: bool, pub download_ass_danmaku: bool,
pub download_json_danmaku: bool, pub download_json_danmaku: bool,
pub download_subtitle: 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,
@@ -99,6 +100,7 @@ impl Config {
download_xml_danmaku: true, download_xml_danmaku: true,
download_ass_danmaku: true, download_ass_danmaku: true,
download_json_danmaku: true, download_json_danmaku: true,
download_subtitle: 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(),
+19 -1
View File
@@ -13,7 +13,7 @@ use crate::{
config::Config, config::Config,
downloader::tasks::{ downloader::tasks::{
audio_task::AudioTask, danmaku_task::DanmakuTask, merge_task::MergeTask, audio_task::AudioTask, danmaku_task::DanmakuTask, merge_task::MergeTask,
video_task::VideoTask, subtitle_task::SubtitleTask, video_task::VideoTask,
}, },
extensions::AppHandleExt, extensions::AppHandleExt,
types::{ types::{
@@ -52,6 +52,7 @@ pub struct DownloadProgress {
pub video_task: VideoTask, pub video_task: VideoTask,
pub audio_task: AudioTask, pub audio_task: AudioTask,
pub merge_task: MergeTask, pub merge_task: MergeTask,
pub subtitle_task: SubtitleTask,
pub danmaku_task: DanmakuTask, pub danmaku_task: DanmakuTask,
pub create_ts: u64, pub create_ts: u64,
pub completed_ts: Option<u64>, pub completed_ts: Option<u64>,
@@ -121,6 +122,7 @@ impl DownloadProgress {
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -168,6 +170,7 @@ impl DownloadProgress {
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -304,6 +307,7 @@ impl DownloadProgress {
&& self.audio_task.is_completed() && self.audio_task.is_completed()
&& self.merge_task.is_completed() && self.merge_task.is_completed()
&& self.danmaku_task.is_completed() && self.danmaku_task.is_completed()
&& self.subtitle_task.is_completed()
} }
pub fn mark_uncompleted(&mut self) { pub fn mark_uncompleted(&mut self) {
@@ -311,6 +315,7 @@ impl DownloadProgress {
self.audio_task.mark_uncompleted(); self.audio_task.mark_uncompleted();
self.merge_task.completed = false; self.merge_task.completed = false;
self.danmaku_task.completed = false; self.danmaku_task.completed = false;
self.subtitle_task.completed = false;
} }
pub fn get_ids_string(&self) -> String { pub fn get_ids_string(&self) -> String {
@@ -360,6 +365,7 @@ fn create_normal_progresses_for_single(
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -396,6 +402,7 @@ fn create_normal_progresses_for_single(
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -432,6 +439,7 @@ fn create_normal_progresses_for_single(
audio_task: tasks.audio.clone(), audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(), merge_task: tasks.merge.clone(),
danmaku_task: tasks.danmaku.clone(), danmaku_task: tasks.danmaku.clone(),
subtitle_task: tasks.subtitle.clone(),
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -500,6 +508,7 @@ fn create_normal_progresses_for_season(
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -536,6 +545,7 @@ fn create_normal_progresses_for_season(
audio_task: tasks.audio, audio_task: tasks.audio,
merge_task: tasks.merge, merge_task: tasks.merge,
danmaku_task: tasks.danmaku, danmaku_task: tasks.danmaku,
subtitle_task: tasks.subtitle,
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -573,6 +583,7 @@ fn create_normal_progresses_for_season(
audio_task: tasks.audio.clone(), audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(), merge_task: tasks.merge.clone(),
danmaku_task: tasks.danmaku.clone(), danmaku_task: tasks.danmaku.clone(),
subtitle_task: tasks.subtitle.clone(),
create_ts, create_ts,
completed_ts: None, completed_ts: None,
}; };
@@ -591,6 +602,7 @@ struct Tasks {
audio: AudioTask, audio: AudioTask,
merge: MergeTask, merge: MergeTask,
danmaku: DanmakuTask, danmaku: DanmakuTask,
subtitle: SubtitleTask,
} }
impl Tasks { impl Tasks {
@@ -626,11 +638,17 @@ impl Tasks {
completed: false, completed: false,
}; };
let subtitle = SubtitleTask {
selected: config.download_subtitle,
completed: false,
};
Self { Self {
video, video,
audio, audio,
merge, merge,
danmaku, danmaku,
subtitle,
} }
} }
} }
+53
View File
@@ -311,6 +311,13 @@ impl DownloadTask {
tracing::debug!("{ids_string} `{filename}`弹幕下载完成"); tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
} }
if !progress.subtitle_task.is_completed() {
self.download_subtitle(&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())
@@ -668,6 +675,52 @@ impl DownloadTask {
Ok(()) Ok(())
} }
async fn download_subtitle(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
use std::fmt::Write;
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let (aid, cid) = {
let progress = self.progress.read();
(progress.aid, progress.cid)
};
let bili_client = self.app.get_bili_client();
let player_info = bili_client
.get_player_info(aid, cid)
.await
.context("获取播放器信息失败")?;
let subtitle = &player_info.subtitle;
for subtitle_detail in &subtitle.subtitles {
let url = format!("http:{}", subtitle_detail.subtitle_url);
let subtitle = bili_client
.get_subtitle(&url)
.await
.context("获取字幕失败")?;
let mut srt_content = String::new();
for (i, b) in subtitle.body.iter().enumerate() {
let index = i + 1;
let content = &b.content;
let start_time = utils::seconds_to_srt_time(b.from);
let end_time = utils::seconds_to_srt_time(b.to);
let _ = writeln!(
&mut srt_content,
"{index}\n{start_time} --> {end_time}\n{content}\n"
);
}
let lan = utils::filename_filter(&subtitle_detail.lan);
let save_path = episode_dir.join(format!("{filename}.{lan}.srt"));
std::fs::write(save_path, srt_content)?;
}
self.update_progress(|p| p.subtitle_task.completed = true);
Ok(())
}
async fn 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;
+1
View File
@@ -1,4 +1,5 @@
pub mod audio_task; pub mod audio_task;
pub mod danmaku_task; pub mod danmaku_task;
pub mod merge_task; pub mod merge_task;
pub mod subtitle_task;
pub mod video_task; pub mod video_task;
@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct SubtitleTask {
pub selected: bool,
pub completed: bool,
}
impl SubtitleTask {
pub fn is_completed(&self) -> bool {
!self.selected || self.completed
}
}
+1
View File
@@ -17,6 +17,7 @@ pub mod normal_media_url;
pub mod player_info; pub mod player_info;
pub mod qrcode_data; pub mod qrcode_data;
pub mod qrcode_status; pub mod qrcode_status;
pub mod subtitle;
pub mod user_info; pub mod user_info;
pub mod video_quality; pub mod video_quality;
pub mod watch_later_info; pub mod watch_later_info;
+21
View File
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Subtitle {
pub font_size: f64,
pub font_color: String,
pub background_alpha: f64,
pub background_color: String,
#[serde(rename = "Stroke")]
pub stroke: String,
pub body: Vec<Body>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct Body {
pub from: f64,
pub to: f64,
pub location: i64,
pub content: String,
}
+14
View File
@@ -162,3 +162,17 @@ impl ToXml for Vec<DmSegMobileReply> {
Ok(xml) Ok(xml)
} }
} }
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::similar_names)]
pub fn seconds_to_srt_time(seconds: f64) -> String {
let total_ms = (seconds * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let s = total_s % 60;
let total_m = total_s / 60;
let m = total_m % 60;
let h = total_m / 60;
format!("{h:02}:{m:02}:{s:02},{ms:03}")
}
+3 -2
View File
@@ -240,7 +240,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 = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig } export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig }
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 }
@@ -258,7 +258,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; audio_task: AudioTask; merge_task: MergeTask; danmaku_task: DanmakuTask; 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; merge_task: MergeTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; 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 }
@@ -370,6 +370,7 @@ export type SubtitleDetailInNormal = { id: number; lan: string; lan_doc: string;
export type SubtitleDetailInPlayerInfo = { id: number; lan: string; lan_doc: string; is_lock: boolean; subtitle_url: string; type: number; id_str: string; ai_type: number; ai_status: number } export type SubtitleDetailInPlayerInfo = { id: number; lan: string; lan_doc: string; is_lock: boolean; subtitle_url: string; type: number; id_str: string; ai_type: number; ai_status: number }
export type SubtitleInNormal = { allow_submit: boolean; list: SubtitleDetailInNormal[] } export type SubtitleInNormal = { allow_submit: boolean; list: SubtitleDetailInNormal[] }
export type SubtitleInPlayerInfo = { allow_submit: boolean; lan: string; lan_doc: string; subtitles: SubtitleDetailInPlayerInfo[] } export type SubtitleInPlayerInfo = { allow_submit: boolean; lan: string; lan_doc: string; subtitles: SubtitleDetailInPlayerInfo[] }
export type SubtitleTask = { selected: boolean; completed: boolean }
export type SupportFormatInBangumi = { display_desc: string; has_preview: boolean; sub_description: string; superscript: string; need_login: boolean | null; codecs: string[]; format: string; description: string; need_vip: boolean | null; attribute: number; quality: number; new_description: string } export type SupportFormatInBangumi = { display_desc: string; has_preview: boolean; sub_description: string; superscript: string; need_login: boolean | null; codecs: string[]; format: string; description: string; need_vip: boolean | null; attribute: number; quality: number; new_description: string }
export type SupportFormatInCheese = { display_desc: string; superscript: string; need_login: boolean; codecs: string[]; format: string; description: string; quality: number; new_description: string } export type SupportFormatInCheese = { display_desc: string; superscript: string; need_login: boolean; codecs: string[]; format: string; description: string; quality: number; new_description: string }
export type SupportFormatInNormal = { quality: number; format: string; new_description: string; display_desc: string; superscript: string; codecs: string[] } export type SupportFormatInNormal = { quality: number; format: string; new_description: string; display_desc: string; superscript: string; codecs: string[] }