diff --git a/src-tauri/src/bili_client.rs b/src-tauri/src/bili_client.rs index 63583e7..9666c34 100644 --- a/src-tauri/src/bili_client.rs +++ b/src-tauri/src/bili_client.rs @@ -24,8 +24,8 @@ use crate::{ get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams, get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo, - qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, user_info::UserInfo, - watch_later_info::WatchLaterInfo, + qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, subtitle::Subtitle, + user_info::UserInfo, watch_later_info::WatchLaterInfo, }, }; @@ -670,6 +670,21 @@ impl BiliClient { Ok(replies) } + pub async fn get_subtitle(&self, url: &str) -> anyhow::Result { + 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 { let sessdata = self.app.get_config().read().sessdata.clone(); format!("SESSDATA={sessdata}") diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 867b9d7..4edd51c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -22,6 +22,7 @@ pub struct Config { pub download_xml_danmaku: bool, pub download_ass_danmaku: bool, pub download_json_danmaku: bool, + pub download_subtitle: bool, pub dir_fmt: String, pub dir_fmt_for_part: String, pub time_fmt: String, @@ -99,6 +100,7 @@ impl Config { download_xml_danmaku: true, download_ass_danmaku: true, download_json_danmaku: true, + download_subtitle: 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(), diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index b749eb0..482e978 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -13,7 +13,7 @@ use crate::{ config::Config, downloader::tasks::{ audio_task::AudioTask, danmaku_task::DanmakuTask, merge_task::MergeTask, - video_task::VideoTask, + subtitle_task::SubtitleTask, video_task::VideoTask, }, extensions::AppHandleExt, types::{ @@ -52,6 +52,7 @@ pub struct DownloadProgress { pub video_task: VideoTask, pub audio_task: AudioTask, pub merge_task: MergeTask, + pub subtitle_task: SubtitleTask, pub danmaku_task: DanmakuTask, pub create_ts: u64, pub completed_ts: Option, @@ -121,6 +122,7 @@ impl DownloadProgress { audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -168,6 +170,7 @@ impl DownloadProgress { audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -304,6 +307,7 @@ impl DownloadProgress { && self.audio_task.is_completed() && self.merge_task.is_completed() && self.danmaku_task.is_completed() + && self.subtitle_task.is_completed() } pub fn mark_uncompleted(&mut self) { @@ -311,6 +315,7 @@ impl DownloadProgress { self.audio_task.mark_uncompleted(); self.merge_task.completed = false; self.danmaku_task.completed = false; + self.subtitle_task.completed = false; } pub fn get_ids_string(&self) -> String { @@ -360,6 +365,7 @@ fn create_normal_progresses_for_single( audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -396,6 +402,7 @@ fn create_normal_progresses_for_single( audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -432,6 +439,7 @@ fn create_normal_progresses_for_single( audio_task: tasks.audio.clone(), merge_task: tasks.merge.clone(), danmaku_task: tasks.danmaku.clone(), + subtitle_task: tasks.subtitle.clone(), create_ts, completed_ts: None, }; @@ -500,6 +508,7 @@ fn create_normal_progresses_for_season( audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -536,6 +545,7 @@ fn create_normal_progresses_for_season( audio_task: tasks.audio, merge_task: tasks.merge, danmaku_task: tasks.danmaku, + subtitle_task: tasks.subtitle, create_ts, completed_ts: None, }; @@ -573,6 +583,7 @@ fn create_normal_progresses_for_season( audio_task: tasks.audio.clone(), merge_task: tasks.merge.clone(), danmaku_task: tasks.danmaku.clone(), + subtitle_task: tasks.subtitle.clone(), create_ts, completed_ts: None, }; @@ -591,6 +602,7 @@ struct Tasks { audio: AudioTask, merge: MergeTask, danmaku: DanmakuTask, + subtitle: SubtitleTask, } impl Tasks { @@ -626,11 +638,17 @@ impl Tasks { completed: false, }; + let subtitle = SubtitleTask { + selected: config.download_subtitle, + completed: false, + }; + Self { video, audio, merge, danmaku, + subtitle, } } } diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs index 9b92e67..eca7dbe 100644 --- a/src-tauri/src/downloader/download_task.rs +++ b/src-tauri/src/downloader/download_task.rs @@ -311,6 +311,13 @@ impl DownloadTask { 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() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) @@ -668,6 +675,52 @@ impl DownloadTask { 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) { 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/mod.rs b/src-tauri/src/downloader/tasks/mod.rs index b66b5bc..fc488f3 100644 --- a/src-tauri/src/downloader/tasks/mod.rs +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -1,4 +1,5 @@ pub mod audio_task; pub mod danmaku_task; pub mod merge_task; +pub mod subtitle_task; pub mod video_task; diff --git a/src-tauri/src/downloader/tasks/subtitle_task.rs b/src-tauri/src/downloader/tasks/subtitle_task.rs new file mode 100644 index 0000000..981a6bd --- /dev/null +++ b/src-tauri/src/downloader/tasks/subtitle_task.rs @@ -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 + } +} diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs index 03a7e3f..8a3e106 100644 --- a/src-tauri/src/types/mod.rs +++ b/src-tauri/src/types/mod.rs @@ -17,6 +17,7 @@ pub mod normal_media_url; pub mod player_info; pub mod qrcode_data; pub mod qrcode_status; +pub mod subtitle; pub mod user_info; pub mod video_quality; pub mod watch_later_info; diff --git a/src-tauri/src/types/subtitle.rs b/src-tauri/src/types/subtitle.rs new file mode 100644 index 0000000..8d54dbf --- /dev/null +++ b/src-tauri/src/types/subtitle.rs @@ -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, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct Body { + pub from: f64, + pub to: f64, + pub location: i64, + pub content: String, +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7209751..7983de6 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -162,3 +162,17 @@ impl ToXml for Vec { 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}") +} diff --git a/src/bindings.ts b/src/bindings.ts index 42cdbdc..16398ad 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1" 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 ContentList = { bold: boolean; content: string; number: 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 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; 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 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 } @@ -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 SubtitleInNormal = { allow_submit: boolean; list: SubtitleDetailInNormal[] } 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 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[] }