diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3614a16..045d3f1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -268,7 +268,12 @@ name = "bilibili-video-downloader" version = "0.1.0" dependencies = [ "anyhow", + "byteorder", + "bytes", + "chrono", + "fs4", "notify", + "num_enum", "parking_lot 0.12.4", "reqwest", "reqwest-middleware", @@ -277,6 +282,7 @@ dependencies = [ "serde_json", "specta", "specta-typescript", + "strfmt", "tauri", "tauri-build", "tauri-plugin-opener", @@ -285,6 +291,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] @@ -513,8 +520,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -1044,6 +1053,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3824,6 +3843,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strfmt" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65" + [[package]] name = "string_cache" version = "0.8.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b293309..06dfe35 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,13 @@ tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-ti tracing-appender = { version = "0.2.3" } notify = { version = "8.0.0" } tokio = { version = "1.46.0", features = ["full"] } +byteorder = { version = "1.5.0" } +strfmt = { version = "0.2.4" } +uuid = { version = "1.17.0", features = ["v4"] } +bytes = { version = "1.10.1" } +fs4 = { version = "0.13.1" } +num_enum = { version = "0.7.4" } +chrono = { version = "0.4.41" } [profile.release] strip = true diff --git a/src-tauri/src/bili_client.rs b/src-tauri/src/bili_client.rs index fb55b11..8f80552 100644 --- a/src-tauri/src/bili_client.rs +++ b/src-tauri/src/bili_client.rs @@ -1,8 +1,9 @@ use std::time::Duration; use anyhow::{anyhow, Context}; +use bytes::Bytes; use parking_lot::RwLock; -use reqwest::StatusCode; +use reqwest::{Client, StatusCode}; use reqwest_middleware::ClientWithMiddleware; use reqwest_retry::{policies::ExponentialBackoff, Jitter, RetryTransientMiddleware}; use serde::{Deserialize, Serialize}; @@ -11,6 +12,7 @@ use tauri::{ http::{HeaderMap, HeaderValue}, AppHandle, }; +use tokio::task::JoinSet; use crate::{ extensions::AppHandleExt, @@ -31,6 +33,8 @@ const REFERRER: &str = "https://www.bilibili.com/"; pub struct BiliClient { app: AppHandle, api_client: RwLock, + media_client: RwLock, + content_length_client: RwLock, } impl BiliClient { @@ -38,7 +42,18 @@ impl BiliClient { let api_client = create_api_client(&app); let api_client = RwLock::new(api_client); - Self { app, api_client } + let media_client = create_media_client(&app); + let media_client = RwLock::new(media_client); + + let content_length_client = create_content_length_client(&app); + let content_length_client = RwLock::new(content_length_client); + + Self { + app, + api_client, + media_client, + content_length_client, + } } pub async fn generate_qrcode(&self) -> anyhow::Result { @@ -536,6 +551,72 @@ impl BiliClient { Ok(watch_later_info) } + pub async fn get_media_chunk( + &self, + media_url: &str, + start: u64, + end: u64, + ) -> anyhow::Result { + let request = self + .media_client + .read() + .get(media_url) + .header("range", format!("bytes={start}-{end}")); + let http_resp = request.send().await?; + // 检查http响应状态码 + let status = http_resp.status(); + if status != StatusCode::PARTIAL_CONTENT { + return Err(anyhow!("预料之外的状态码({status})")); + } + + let bytes = http_resp.bytes().await?; + + Ok(bytes) + } + + pub async fn get_content_length(&self, media_url: &str) -> anyhow::Result { + let request = self.content_length_client.read().head(media_url); + let http_resp = request.send().await?; + // 检查http响应状态码 + let status = http_resp.status(); + if status != StatusCode::OK { + return Err(anyhow!("预料之外的状态码({status})")); + } + + let headers = http_resp.headers(); + let content_length = headers + .get("Content-Length") + .context("缺少 Content-Length 响应头")? + .to_str() + .context("Content-Length 响应头无法转换为字符串")? + .parse::() + .context("Content-Length 响应头无法转换为整数")?; + + Ok(content_length) + } + + pub async fn get_url_with_content_length(&self, urls: Vec) -> Vec<(String, u64)> { + let mut url_with_content_length = Vec::new(); + let mut join_set = JoinSet::new(); + + for url in urls { + let app = self.app.clone(); + join_set.spawn(async move { + let bili_client = app.get_bili_client(); + let Ok(content_length) = bili_client.get_content_length(&url).await else { + return None; + }; + Some((url, content_length)) + }); + } + + while let Some(Ok(Some((url, content_length)))) = join_set.join_next().await { + url_with_content_length.push((url, content_length)); + } + + url_with_content_length + } + fn get_cookie(&self) -> String { let sessdata = self.app.get_config().read().sessdata.clone(); format!("SESSDATA={sessdata}") @@ -563,6 +644,38 @@ fn create_api_client(_app: &AppHandle) -> ClientWithMiddleware { .build() } +fn create_media_client(_app: &AppHandle) -> ClientWithMiddleware { + let retry_policy = ExponentialBackoff::builder() + .base(1) + .jitter(Jitter::Bounded) + .build_with_max_retries(3); + + let mut headers = HeaderMap::new(); + headers.insert("user-agent", HeaderValue::from_static(USER_AGENT)); + headers.insert("referer", HeaderValue::from_static(REFERRER)); + + let client = reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap(); + + reqwest_middleware::ClientBuilder::new(client) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build() +} + +fn create_content_length_client(_app: &AppHandle) -> Client { + let mut headers = HeaderMap::new(); + headers.insert("user-agent", HeaderValue::from_static(USER_AGENT)); + headers.insert("referer", HeaderValue::from_static(REFERRER)); + + reqwest::ClientBuilder::new() + .timeout(Duration::from_secs(5)) + .default_headers(headers) + .build() + .unwrap() +} + #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct BiliResp { pub code: i64, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f199748..9eed2d1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,12 +8,12 @@ use crate::{ logger, types::{ bangumi_info::BangumiInfo, bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo, - cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo, - 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, + cheese_media_url::CheeseMediaUrl, create_download_task_params::CreateDownloadTaskParams, + fav_folders::FavFolders, fav_info::FavInfo, 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, }, }; @@ -222,3 +222,56 @@ pub async fn get_watch_later_info(app: AppHandle, page: i32) -> CommandResult) { + let download_manager = app.get_download_manager(); + download_manager.pause_download_tasks(&task_ids); +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +pub fn resume_download_tasks(app: AppHandle, task_ids: Vec) { + let download_manager = app.get_download_manager(); + download_manager.resume_download_tasks(&task_ids); +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +pub fn delete_download_tasks(app: AppHandle, task_ids: Vec) { + let download_manager = app.get_download_manager(); + download_manager.delete_download_tasks(&task_ids); +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +pub fn restart_download_tasks(app: AppHandle, task_ids: Vec) { + let download_manager = app.get_download_manager(); + download_manager.restart_download_tasks(&task_ids); +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +pub fn restore_download_tasks(app: AppHandle) -> CommandResult<()> { + let download_manager = app.get_download_manager(); + download_manager + .restore_download_tasks() + .map_err(|err| CommandError::from("恢复下载任务失败", err))?; + tracing::debug!("恢复下载任务成功"); + Ok(()) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 055b2ac..8e99dcb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use num_enum::{FromPrimitive, IntoPrimitive}; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, Manager}; @@ -10,6 +11,16 @@ pub struct Config { pub download_dir: PathBuf, pub enable_file_logger: bool, pub sessdata: String, + pub prefer_video_quality: PreferVideoQuality, + pub prefer_codec_type: PreferCodecType, + pub download_video: bool, + pub dir_fmt: String, + pub dir_fmt_for_part: String, + pub time_fmt: String, + pub task_concurrency: usize, + pub task_download_interval_sec: u64, + pub chunk_concurrency: usize, + pub chunk_download_interval_sec: u64, } impl Config { @@ -64,10 +75,90 @@ 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}"; Config { download_dir: app_data_dir.join("视频下载"), enable_file_logger: true, sessdata: String::new(), + prefer_video_quality: PreferVideoQuality::Best, + prefer_codec_type: PreferCodecType::AVC, + download_video: 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(), + task_concurrency: 3, + task_download_interval_sec: 0, + chunk_concurrency: 16, + chunk_download_interval_sec: 0, } } } + +#[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Serialize, + Deserialize, + Type, + IntoPrimitive, + FromPrimitive, +)] +#[repr(i64)] +pub enum PreferVideoQuality { + #[default] + Best = -1, + + #[serde(rename = "240P")] + Video240P = 6, + #[serde(rename = "360P")] + Video360P = 16, + #[serde(rename = "480P")] + Video480P = 32, + #[serde(rename = "720P")] + Video720P = 64, + #[serde(rename = "720P60")] + Video720P60 = 74, + #[serde(rename = "1080P")] + Video1080P = 80, + #[serde(rename = "AiRepair")] + VideoAiRepair = 100, + #[serde(rename = "1080P+")] + Video1080PPlus = 112, + #[serde(rename = "1080P60")] + Video1080P60 = 116, + #[serde(rename = "4K")] + Video4K = 120, + #[serde(rename = "HDR")] + VideoHDR = 125, + #[serde(rename = "Dolby")] + VideoDolby = 126, + #[serde(rename = "8K")] + Video8K = 127, +} + +#[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Serialize, + Deserialize, + Type, + IntoPrimitive, + FromPrimitive, +)] +#[repr(i64)] +#[allow(clippy::upper_case_acronyms)] +pub enum PreferCodecType { + #[default] + Unknown = -1, + + AVC = 7, + HEVC = 12, + AV1 = 13, +} diff --git a/src-tauri/src/downloader/download_manager.rs b/src-tauri/src/downloader/download_manager.rs new file mode 100644 index 0000000..3452ac2 --- /dev/null +++ b/src-tauri/src/downloader/download_manager.rs @@ -0,0 +1,212 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; + +use anyhow::Context; +use parking_lot::RwLock; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; +use tokio::sync::Semaphore; + +use crate::{ + events::DownloadEvent, + extensions::{AnyhowErrorToStringChain, AppHandleExt}, + types::create_download_task_params::CreateDownloadTaskParams, +}; + +use super::{ + download_progress::DownloadProgress, download_task::DownloadTask, + download_task_state::DownloadTaskState, +}; + +pub struct DownloadManager { + pub app: AppHandle, + pub task_sem: Arc, + pub media_chunk_sem: Arc, + pub byte_per_sec: Arc, + pub download_tasks: RwLock>>, +} + +impl DownloadManager { + pub fn new(app: AppHandle) -> Self { + let (task_concurrency, chunk_concurrency) = { + let config = app.get_config().inner().read(); + (config.task_concurrency, config.chunk_concurrency) + }; + + let manager = Self { + app, + task_sem: Arc::new(Semaphore::new(task_concurrency)), + media_chunk_sem: Arc::new(Semaphore::new(chunk_concurrency)), + byte_per_sec: Arc::new(AtomicU64::new(0)), + download_tasks: RwLock::new(HashMap::new()), + }; + + tauri::async_runtime::spawn(Self::emit_download_speed_loop( + manager.app.clone(), + manager.byte_per_sec.clone(), + )); + + manager + } + + pub fn restore_download_tasks(&self) -> anyhow::Result<()> { + let task_dir = self.get_task_dir()?; + std::fs::create_dir_all(&task_dir) + .context(format!("创建下载任务目录`{}`失败", task_dir.display()))?; + + let mut tasks = self.download_tasks.write(); + 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 progress_json = std::fs::read_to_string(&path)?; + + let progress: DownloadProgress = + if let Ok(progress) = serde_json::from_str(&progress_json) { + progress + } else { + // 如果这个json解析失败则删除 + let _ = std::fs::remove_file(&path); + continue; + }; + + let new_task = DownloadTask::from_progress(self.app.clone(), progress); + let old_task = tasks.insert(new_task.task_id.clone(), new_task); + if let Some(old_task) = old_task { + // 如果同一个ID的下载任务已经存在,则取消旧的任务 + let _ = old_task.cancel_sender.send(()); + } + } + + Ok(()) + } + + pub fn create_download_tasks(&self, params: &CreateDownloadTaskParams) { + let new_tasks = DownloadTask::from_params(&self.app, params); + let mut tasks = self.download_tasks.write(); + for new_task in new_tasks { + tasks.insert(new_task.task_id.clone(), new_task); + } + } + + pub fn pause_download_tasks(&self, task_ids: &Vec) { + let tasks = self.download_tasks.read(); + for task_id in task_ids { + let Some(task) = tasks.get(task_id) else { + let err_title = "暂停下载任务失败"; + let err_msg = format!("未找到ID为`{task_id}`的下载任务"); + tracing::error!(err_title, message = err_msg); + continue; + }; + task.set_state(DownloadTaskState::Paused); + tracing::debug!("已将ID为`{task_id}`的下载任务状态设置为`Paused`"); + } + } + + pub fn resume_download_tasks(&self, task_ids: &Vec) { + let tasks = self.download_tasks.read(); + for task_id in task_ids { + let Some(task) = tasks.get(task_id) else { + let err_title = "继续下载任务失败"; + let err_msg = format!("未找到ID为`{task_id}`的下载任务"); + tracing::error!(err_title, message = err_msg); + continue; + }; + task.set_state(DownloadTaskState::Pending); + tracing::debug!("已将ID为`{task_id}`的下载任务状态设置为`Pending`"); + } + } + + pub fn delete_download_tasks(&self, task_ids: &Vec) { + let mut tasks = self.download_tasks.write(); + for task_id in task_ids { + let Some(task) = tasks.remove(task_id) else { + let err_title = "删除下载任务失败"; + let err_msg = format!("未找到ID为`{task_id}`的下载任务"); + tracing::error!(err_title, message = err_msg); + continue; + }; + + if let Err(err) = self.delete_progress_file(task_id) { + let err_title = "删除下载任务失败"; + let err_msg = format!("删除ID为`{task_id}`的下载任务文件失败: {err}"); + tracing::error!(err_title, message = err_msg); + tasks.insert(task_id.clone(), task); + continue; + } + + if let Err(err) = task.delete_sender.send(()).map_err(anyhow::Error::from) { + let err_title = "删除下载任务失败"; + let err = err.context(format!("通知ID为`{task_id}`的下载任务删除失败")); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + tasks.insert(task_id.clone(), task); + continue; + } + + tracing::debug!("已通知ID为`{task_id}`的下载任务删除"); + } + } + + pub fn restart_download_tasks(&self, task_ids: &Vec) { + let tasks = self.download_tasks.read(); + for task_id in task_ids { + let Some(task) = tasks.get(task_id) else { + let err_title = "重来下载任务失败"; + let err_msg = format!("未找到ID为`{task_id}`的下载任务"); + tracing::error!(err_title, message = err_msg); + continue; + }; + + if let Err(err) = task.restart_sender.send(()).map_err(anyhow::Error::from) { + let err_title = "重来下载任务失败"; + let err = err.context(format!("通知ID为`{task_id}`的下载任务重来失败")); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + continue; + } + + tracing::debug!("已通知ID为`{task_id}`的下载任务重来"); + } + } + + async fn emit_download_speed_loop(app: AppHandle, byte_per_sec: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + + loop { + interval.tick().await; + let byte_per_sec = byte_per_sec.swap(0, Ordering::Relaxed); + #[allow(clippy::cast_precision_loss)] + let mega_byte_per_sec = byte_per_sec as f64 / 1024.0 / 1024.0; + let speed = format!("{mega_byte_per_sec:.2}MB/s"); + let _ = DownloadEvent::Speed { speed }.emit(&app); + } + } + + fn get_task_dir(&self) -> anyhow::Result { + let app_data_dir = self.app.path().app_data_dir()?; + let task_dir = app_data_dir.join(".下载任务"); + Ok(task_dir) + } + + fn delete_progress_file(&self, task_id: &str) -> anyhow::Result<()> { + let task_dir = self.get_task_dir()?; + let task_file = task_dir.join(format!("{task_id}.json")); + if task_file.exists() { + std::fs::remove_file(task_file)?; + } + Ok(()) + } +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs new file mode 100644 index 0000000..d662077 --- /dev/null +++ b/src-tauri/src/downloader/download_progress.rs @@ -0,0 +1,553 @@ +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +use crate::{ + config::Config, + downloader::tasks::video_task::VideoTask, + extensions::AppHandleExt, + types::{ + bangumi_info::BangumiInfo, + cheese_info::CheeseInfo, + codec_type::CodecType, + normal_info::{NormalInfo, UgcSeason}, + video_quality::VideoQuality, + }, +}; + +use super::{episode_type::EpisodeType, fmt_params::FmtParams}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct DownloadProgress { + pub task_id: String, + pub episode_type: EpisodeType, + pub aid: i64, + pub bvid: Option, + pub cid: i64, + pub ep_id: Option, + pub duration: u64, + pub pub_ts: i64, + pub collection_title: String, + pub part_title: Option, + pub part_order: Option, + pub episode_title: String, + pub episode_order: i64, + pub up_name: Option, + pub up_uid: Option, + pub up_avatar: Option, + pub episode_dir: PathBuf, + pub filename: String, + pub video_task: VideoTask, + pub create_ts: u64, + pub completed_ts: Option, +} + +impl DownloadProgress { + pub fn from_normal( + app: &AppHandle, + info: &NormalInfo, + aid: i64, + cid: Option, + ) -> anyhow::Result> { + let config = app.get_config().read().clone(); + + if let Some(ugc_season) = &info.ugc_season { + create_normal_progresses_for_season(ugc_season, info, aid, cid, &config) + } else { + create_normal_progresses_for_single(info, cid, &config) + } + } + + #[allow(clippy::cast_possible_wrap)] + pub fn from_bangumi(app: &AppHandle, info: &BangumiInfo, ep_id: i64) -> anyhow::Result { + let (episode, episode_order) = info.get_episode_with_order(ep_id)?; + let Some(duration) = episode.duration else { + return Err(anyhow!("找不到ep_id为`{ep_id}`的番剧的时长")); + }; + // 将毫秒转换为秒 + let duration = duration / 1000; + + let config = app.get_config().read().clone(); + + let tasks = Tasks::new(&config, &episode.cover); + + let (up_name, up_uid, up_avatar) = if let Some(up_info) = &info.up_info { + ( + Some(up_info.uname.clone()), + Some(up_info.mid), + Some(up_info.avatar.clone()), + ) + } else { + (None, None, None) + }; + + let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let mut progress = Self { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Bangumi, + aid: episode.aid, + bvid: episode.bvid.clone(), + cid: episode.cid, + ep_id: Some(episode.id), + duration, + pub_ts: episode.pub_time, + collection_title: info.title.clone(), + part_title: None, + part_order: None, + episode_title: episode.show_title.clone().unwrap_or(episode.title.clone()), + episode_order, + up_name, + up_uid, + up_avatar, + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(&config) + .context("更新需要格式化的字段失败")?; + + Ok(progress) + } + + pub fn from_cheese(app: &AppHandle, info: &CheeseInfo, ep_id: i64) -> anyhow::Result { + let episode = info + .episodes + .iter() + .find(|ep| ep.id == ep_id) + .context(format!("找不到ep_id为`{ep_id}`的课程"))?; + + let config = app.get_config().read().clone(); + + let tasks = Tasks::new(&config, &episode.cover); + + let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let mut progress = Self { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Cheese, + aid: episode.aid, + bvid: None, + cid: episode.cid, + ep_id: Some(episode.id), + duration: episode.duration, + pub_ts: episode.release_date, + collection_title: info.title.clone(), + part_title: None, + part_order: None, + episode_title: episode.title.clone(), + episode_order: episode.index, + up_name: Some(info.up_info.uname.clone()), + up_uid: Some(info.up_info.mid), + up_avatar: Some(info.up_info.avatar.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(&config) + .context("更新需要格式化的字段失败")?; + + Ok(progress) + } + + pub async fn prepare(&mut self, app: &AppHandle) -> anyhow::Result<()> { + let video_selected = self.video_task.selected; + let video_completed = self.video_task.completed; + + if !video_selected && video_completed { + // 如果视频没有选中,或者已经完成,则不需要准备 + return Ok(()); + } + + let bili_client = app.get_bili_client(); + + match self.episode_type { + EpisodeType::Normal => { + let Some(bvid) = &self.bvid else { + return Err(anyhow!("progress中的bvid为None,无法获取视频链接")); + }; + let media_url = bili_client + .get_normal_url(bvid, self.cid) + .await + .context("获取视频链接失败")?; + + if video_selected && !video_completed { + // 如果视频被选中且未完成,则准备视频任务 + self.video_task.prepare_normal(app, &media_url).await?; + } + } + EpisodeType::Bangumi => { + let media_url = bili_client + .get_bangumi_url(self.cid) + .await + .context("获取番剧视频链接失败")?; + + if video_selected && !video_completed { + // 如果视频被选中且未完成,则准备视频任务 + self.video_task.prepare_bangumi(app, &media_url).await?; + } + } + EpisodeType::Cheese => { + let Some(ep_id) = self.ep_id else { + return Err(anyhow!("progress中的ep_id为None,无法获取课程视频链接")); + }; + let media_url = bili_client + .get_cheese_url(ep_id) + .await + .context("获取课程视频链接失败")?; + + if video_selected && !video_completed { + // 如果视频被选中且未完成,则准备视频任务 + self.video_task.prepare_cheese(app, &media_url).await?; + } + } + } + + Ok(()) + } + + fn update_fmt_fields(&mut self, config: &Config) -> anyhow::Result<()> { + let fmt_params = self.create_fmt_params(); + + let (episode_dir, filename) = fmt_params.get_episode_dir_and_filename(config)?; + + self.episode_dir = episode_dir; + self.filename = filename; + + Ok(()) + } + + fn create_fmt_params(&self) -> FmtParams { + FmtParams { + task_id: self.task_id.clone(), + episode_type: self.episode_type, + aid: self.aid, + bvid: self.bvid.clone(), + cid: self.cid, + ep_id: self.ep_id, + duration: self.duration, + pub_ts: self.pub_ts, + collection_title: self.collection_title.clone(), + episode_title: self.episode_title.clone(), + episode_order: self.episode_order, + part_title: self.part_title.clone(), + part_order: self.part_order, + up_name: self.up_name.clone(), + up_uid: self.up_uid, + create_ts: self.create_ts, + } + } + + pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { + let progress = self.clone(); + let file_name = format!("{}.json", progress.task_id); + + let app_data_dir = app.path().app_data_dir()?; + let task_dir = app_data_dir.join(".下载任务"); + std::fs::create_dir_all(&task_dir)?; + + let save_path = task_dir.join(file_name); + let progress_json = serde_json::to_string(&progress)?; + std::fs::write(save_path, progress_json)?; + + Ok(()) + } + + pub fn is_completed(&self) -> bool { + self.video_task.is_completed() + } + + pub fn mark_uncompleted(&mut self) { + self.video_task.mark_uncompleted(); + } + + pub fn get_ids_string(&self) -> String { + let aid = self.aid; + let bvid = self.bvid.as_deref().unwrap_or("None"); + let cid = self.cid; + let ep_id = self.ep_id.map_or("None".to_string(), |id| id.to_string()); + format!("aid: {aid}, bvid: {bvid}, cid: {cid}, ep_id: {ep_id}") + } +} + +#[allow(clippy::too_many_lines)] +fn create_normal_progresses_for_single( + info: &NormalInfo, + cid: Option, + config: &Config, +) -> anyhow::Result> { + let tasks = Tasks::new(config, &info.pic); + + let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + if let Some(cid) = cid { + // 如果有cid,则说明是要下载单个分P + let Some(page) = info.pages.iter().find(|p| p.cid == cid) else { + return Err(anyhow!("找不到cid为`{cid}`的分P")); + }; + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: info.aid, + bvid: Some(info.bvid.clone()), + cid: page.cid, + ep_id: None, + duration: page.duration, + pub_ts: info.pubdate, + collection_title: info.title.clone(), + part_title: Some(page.part.clone()), + part_order: Some(page.page), + episode_title: info.title.clone(), + episode_order: 1, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + return Ok(vec![progress]); + } + + if info.pages.len() == 1 { + // 如果只有一个分P,则直接创建一个progress + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: info.aid, + bvid: Some(info.bvid.clone()), + cid: info.cid, + ep_id: None, + duration: info.duration, + pub_ts: info.pubdate, + collection_title: info.title.clone(), + part_title: None, + part_order: None, + episode_title: info.title.clone(), + episode_order: 1, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + return Ok(vec![progress]); + } + // 如果有多个分P,则为每个分P创建一个progress + let mut progresses = Vec::new(); + for page in &info.pages { + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: info.aid, + bvid: Some(info.bvid.clone()), + cid: page.cid, + ep_id: None, + duration: page.duration, + pub_ts: info.pubdate, + collection_title: info.title.clone(), + part_title: Some(page.part.clone()), + part_order: Some(page.page), + episode_title: info.title.clone(), + episode_order: 1, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video.clone(), + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + progresses.push(progress); + } + Ok(progresses) +} + +#[allow(clippy::too_many_lines)] +fn create_normal_progresses_for_season( + ugc_season: &UgcSeason, + info: &NormalInfo, + aid: i64, + cid: Option, + config: &Config, +) -> anyhow::Result> { + let section_index = ugc_season + .sections + .iter() + .position(|s| s.episodes.iter().any(|e| e.aid == aid)) + .context(format!("找不到含有aid为`{aid}`的ep的section"))?; + let section = &ugc_season.sections[section_index]; + #[allow(clippy::cast_possible_wrap)] + let (ep, episode_order) = section + .episodes + .iter() + .enumerate() + .map(|(i, e)| (e, i as i64 + 1)) + .find(|(e, _)| e.aid == aid) + .context(format!("在section中找不到aid为`{aid}`的ep"))?; + + let tasks = Tasks::new(config, &ep.arc.pic); + + let create_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + if let Some(cid) = cid { + // 如果有cid,则说明是要下载单个分P + let Some(page) = ep.pages.iter().find(|p| p.cid == cid) else { + return Err(anyhow!("找不到cid为`{cid}`的分P")); + }; + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: ep.aid, + bvid: Some(ep.bvid.clone()), + cid: page.cid, + ep_id: None, + duration: page.duration, + pub_ts: ep.arc.pubdate, + collection_title: ugc_season.title.clone(), + part_title: Some(page.part.clone()), + part_order: Some(page.page), + episode_title: ep.title.clone(), + episode_order, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + return Ok(vec![progress]); + } + + if ep.pages.len() == 1 { + // 如果只有一个分P,则直接创建一个progress + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: ep.aid, + bvid: Some(ep.bvid.clone()), + cid: ep.pages[0].cid, + ep_id: None, + duration: ep.arc.duration, + pub_ts: ep.arc.pubdate, + collection_title: ugc_season.title.clone(), + part_title: None, + part_order: None, + episode_title: ep.title.clone(), + episode_order, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video, + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + return Ok(vec![progress]); + } + + // 如果有多个分P,则为每个分P创建一个progress + let mut progresses = Vec::new(); + for page in &ep.pages { + let mut progress = DownloadProgress { + task_id: Uuid::new_v4().to_string(), + episode_type: EpisodeType::Normal, + aid: ep.aid, + bvid: Some(ep.bvid.clone()), + cid: page.cid, + ep_id: None, + duration: page.duration, + pub_ts: ep.arc.pubdate, + collection_title: ugc_season.title.clone(), + part_title: Some(page.part.clone()), + part_order: Some(page.page), + episode_title: ep.title.clone(), + episode_order, + up_name: Some(info.owner.name.clone()), + up_uid: Some(info.owner.mid), + up_avatar: Some(info.owner.face.clone()), + episode_dir: PathBuf::new(), + filename: String::new(), + video_task: tasks.video.clone(), + create_ts, + completed_ts: None, + }; + + progress + .update_fmt_fields(config) + .context("更新需要格式化的字段失败")?; + + progresses.push(progress); + } + Ok(progresses) +} + +struct Tasks { + video: VideoTask, +} + +impl Tasks { + fn new(config: &Config, _cover_url: &str) -> Self { + let video = VideoTask { + selected: config.download_video, + url: String::new(), + video_quality: VideoQuality::Unknown, + codec_type: CodecType::Unknown, + content_length: 0, + chunks: Vec::new(), + completed: false, + }; + + Self { video } + } +} diff --git a/src-tauri/src/downloader/download_task.rs b/src-tauri/src/downloader/download_task.rs new file mode 100644 index 0000000..659c22a --- /dev/null +++ b/src-tauri/src/downloader/download_task.rs @@ -0,0 +1,646 @@ +use std::{ + fs::{File, OpenOptions}, + io::{Seek, Write}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Context}; +use fs4::fs_std::FileExt; +use parking_lot::{Mutex, RwLock}; +use tauri::AppHandle; +use tauri_specta::Event; +use tokio::{ + sync::{watch, SemaphorePermit}, + task::JoinSet, + time::sleep, +}; + +use crate::{ + events::DownloadEvent, + extensions::{AnyhowErrorToStringChain, AppHandleExt}, + types::create_download_task_params::CreateDownloadTaskParams, + utils::{self}, +}; + +use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState}; + +pub struct DownloadTask { + pub app: AppHandle, + pub state_sender: watch::Sender, + pub restart_sender: watch::Sender<()>, + pub cancel_sender: watch::Sender<()>, + pub delete_sender: watch::Sender<()>, + pub task_id: String, + pub progress: RwLock, +} + +impl DownloadTask { + pub fn from_params(app: &AppHandle, params: &CreateDownloadTaskParams) -> Vec> { + use CreateDownloadTaskParams::{Bangumi, Cheese, Normal}; + + let mut progresses = Vec::new(); + match params { + Normal(params) => { + for &(aid, cid) in ¶ms.aid_cid_pairs { + let progress = match DownloadProgress::from_normal(app, ¶ms.info, aid, cid) + { + Ok(progress) => progress, + Err(err) => { + let cid = cid.map_or("None".to_string(), |id| id.to_string()); + let ids_string = format!("aid: {aid}, cid: {cid}"); + let err_title = format!("{ids_string} 创建普通视频的下载进度失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + continue; + } + }; + + progresses.extend(progress); + } + } + Bangumi(params) => { + for ep_id in ¶ms.ep_ids { + let progress = match DownloadProgress::from_bangumi(app, ¶ms.info, *ep_id) { + Ok(progress) => progress, + Err(err) => { + let ids_string = format!("ep_id: {ep_id}"); + let err_title = format!("{ids_string} 创建番剧的下载进度失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + continue; + } + }; + + progresses.push(progress); + } + } + Cheese(params) => { + for ep_id in ¶ms.ep_ids { + let progress = match DownloadProgress::from_cheese(app, ¶ms.info, *ep_id) { + Ok(progress) => progress, + Err(err) => { + let ids_string = format!("ep_id: {ep_id}"); + let err_title = format!("{ids_string} 创建课程的下载进度失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + continue; + } + }; + + progresses.push(progress); + } + } + } + + let mut tasks = Vec::new(); + for progress in progresses { + if let Err(err) = progress.save(app) { + let ids_string = progress.get_ids_string(); + let episode_title = &progress.episode_title; + let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + + let (state_sender, _) = watch::channel(DownloadTaskState::Pending); + let (restart_sender, _) = watch::channel(()); + let (cancel_sender, _) = watch::channel(()); + let (delete_sender, _) = watch::channel(()); + + let task = Arc::new(Self { + app: app.clone(), + state_sender, + restart_sender, + cancel_sender, + delete_sender, + task_id: progress.task_id.clone(), + progress: RwLock::new(progress), + }); + + tauri::async_runtime::spawn(task.clone().process()); + + tasks.push(task); + } + + tasks + } + + pub fn from_progress(app: AppHandle, progress: DownloadProgress) -> Arc { + let init_state = if progress.is_completed() { + DownloadTaskState::Completed + } else { + DownloadTaskState::Paused + }; + let (state_sender, _) = watch::channel(init_state); + let (restart_sender, _) = watch::channel(()); + let (cancel_sender, _) = watch::channel(()); + let (delete_sender, _) = watch::channel(()); + + let task = Arc::new(Self { + app, + state_sender, + restart_sender, + cancel_sender, + delete_sender, + task_id: progress.task_id.clone(), + progress: RwLock::new(progress), + }); + + tauri::async_runtime::spawn(task.clone().process()); + + task + } + + async fn process(self: Arc) { + let task_id = &self.task_id; + 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(); + + let mut permit = None; + let mut download_task_option = None; + + loop { + let state = *state_receiver.borrow(); + let state_is_downloading = state == DownloadTaskState::Downloading; + let state_is_pending = state == DownloadTaskState::Pending; + + let download_task = async { + download_task_option + .get_or_insert(Box::pin(self.download())) + .await; + }; + + tokio::select! { + () = download_task, if state_is_downloading && permit.is_some() => { + download_task_option = None; + if let Some(permit) = permit.take() { + drop(permit); + }; + } + + () = self.acquire_task_permit(&mut permit), if state_is_pending => {}, + + _ = state_receiver.changed() => { + self.handle_state_change(&mut permit, &mut state_receiver).await; + } + + _ = restart_receiver.changed() => { + self.handle_restart_notify(); + tracing::debug!("ID为`{task_id}`的下载任务已重来"); + download_task_option = None; + } + + _ = cancel_receiver.changed() => return, + + _ = delete_receiver.changed() => { + let _ = DownloadEvent::TaskDelete { + task_id: self.task_id.clone(), + } + .emit(&self.app); + + if permit.is_some() { + // 如果有permit则稍微等一下再退出 + // 这是为了避免大批量删除时,本应删除的任务因拿到permit而又稍微下载一小段 + sleep(Duration::from_millis(100)).await; + } + + tracing::debug!("ID为`{task_id}`的下载任务已删除"); + return; + } + } + } + } + + async fn download(self: &Arc) { + let mut progress = self.progress.read().clone(); + let ids_string = progress.get_ids_string(); + let episode_title = progress.episode_title.clone(); + + if progress.is_completed() { + tracing::info!("{ids_string} 跳过`{episode_title}`的下载,因为它已经完成"); + self.set_state(DownloadTaskState::Completed); + return; + } + + tracing::debug!("{ids_string} 开始准备`{episode_title}`的下载"); + let _ = DownloadEvent::ProgressPreparing { + task_id: self.task_id.clone(), + } + .emit(&self.app); + + if let Err(err) = progress.prepare(&self.app).await { + let err_title = format!("{ids_string} `{episode_title}`准备下载失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + + self.set_state(DownloadTaskState::Failed); + + return; + } + + progress.completed_ts = None; // 重置完成时间戳 + self.update_progress(|p| *p = progress.clone()); + + tracing::debug!("{ids_string} 开始下载`{episode_title}`"); + if let Err(err) = self + .handle_progress(progress) + .await + .context("[继续]失败的任务可以断点续传") + { + let err_title = format!("{ids_string} `{episode_title}`下载失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + + self.set_state(DownloadTaskState::Failed); + + return; + } + + self.sleep_between_task().await; + + self.set_state(DownloadTaskState::Completed); + tracing::info!("{ids_string} `{episode_title}`下载完成"); + } + + async fn handle_progress(self: &Arc, progress: DownloadProgress) -> anyhow::Result<()> { + let ids_string = progress.get_ids_string(); + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + std::fs::create_dir_all(episode_dir).context(format!( + "{ids_string} 创建目录`{}`失败", + episode_dir.display() + ))?; + + if !progress.video_task.is_completed() && progress.video_task.content_length != 0 { + // 如果视频任务被选中且未完成且有要下载的内容,则下载视频 + self.download_video(&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()) + .ok(); + if completed_ts.is_some() { + self.update_progress(|p| p.completed_ts = completed_ts); + } + + Ok(()) + } + + async fn download_video(self: &Arc, progress: &DownloadProgress) -> anyhow::Result<()> { + let (episode_dir, filename) = (&progress.episode_dir, &progress.filename); + + let temp_file_path = episode_dir.join(format!( + "{filename}.mp4.com.lanyeeee.bilibili-video-downloader" + )); + + let (video_task, episode_title, ids_string) = { + let progress = self.progress.read(); + ( + progress.video_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(video_task.content_length)?; + file + }; + let file = Arc::new(Mutex::new(file)); + + let chunk_count = video_task.chunks.len(); + + let mut join_set = JoinSet::new(); + for (i, chunk) in video_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: video_task.url.to_string(), + file: file.clone(), + chunk_index: i, + }; + + let chunk_order = i + 1; + + join_set.spawn(async move { + download_chunk_task.process().await.context(format!( + "分片`{chunk_order}/{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.video_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() + .video_task + .chunks + .iter() + .all(|chunk| chunk.completed); + if !download_completed { + return Err(anyhow!( + "视频文件`{}`有分片未下载完成,[继续]可以跳过已下载分片断点续传", + temp_file_path.display() + )); + } + + let is_video_file_complete = utils::is_mp4_complete(&temp_file_path).context(format!( + "检查视频文件`{}`是否完整失败", + temp_file_path.display() + ))?; + + if !is_video_file_complete { + self.update_progress(|p| p.video_task.mark_uncompleted()); + return Err(anyhow!( + "视频文件`{}`不完整,[继续]会重新下载所有分片", + temp_file_path.display() + )); + } + + // 重命名临时文件 + let mp4_path = episode_dir.join(format!("{filename}.mp4")); + if mp4_path.exists() { + std::fs::remove_file(&mp4_path) + .context(format!("删除已存在的视频文件`{}`失败", mp4_path.display()))?; + } + std::fs::rename(&temp_file_path, &mp4_path).context(format!( + "将临时文件`{}`重命名为`{}`失败", + temp_file_path.display(), + mp4_path.display() + ))?; + + self.update_progress(|p| p.video_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; + while remaining_sec > 0 { + // 发送章节休眠事件 + let _ = DownloadEvent::TaskSleeping { + task_id: task_id.clone(), + remaining_sec, + } + .emit(&self.app); + sleep(Duration::from_secs(1)).await; + remaining_sec -= 1; + } + } + + async fn acquire_task_permit<'a>(&'a self, permit: &mut Option>) { + let (episode_title, ids_string) = { + let progress = self.progress.read(); + (progress.episode_title.clone(), progress.get_ids_string()) + }; + + *permit = match permit.take() { + // 如果有permit,则直接用 + Some(permit) => Some(permit), + // 如果没有permit,则获取permit + None => match self + .app + .get_download_manager() + .inner() + .task_sem + .acquire() + .await + .map_err(anyhow::Error::from) + { + Ok(permit) => Some(permit), + Err(err) => { + let err_title = + format!("{ids_string} `{episode_title}`获取下载任务的permit失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + + self.set_state(DownloadTaskState::Failed); + + return; + } + }, + }; + // 如果当前任务状态不是`Pending`,则不将任务状态设置为`Downloading` + if *self.state_sender.borrow() != DownloadTaskState::Pending { + return; + } + // 将任务状态设置为`Downloading` + if let Err(err) = self + .state_sender + .send(DownloadTaskState::Downloading) + .map_err(anyhow::Error::from) + { + let err_title = format!("{ids_string} `{episode_title}`发送状态`Downloading`失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + + self.set_state(DownloadTaskState::Failed); + } + } + + async fn handle_state_change<'a>( + &'a self, + permit: &mut Option>, + state_receiver: &mut watch::Receiver, + ) { + let state = *state_receiver.borrow(); + let task_id = self.task_id.clone(); + let _ = DownloadEvent::TaskStateUpdate { task_id, state }.emit(&self.app); + + if state == DownloadTaskState::Paused { + // 稍微等一下再释放permit + // 避免大批量暂停时,本应暂停的任务因拿到permit而稍微下载一小段(虽然最终会被暂停) + sleep(Duration::from_millis(100)).await; + let task_id = &self.task_id; + tracing::debug!("ID为`{task_id}`的下载任务已暂停"); + if let Some(permit) = permit.take() { + drop(permit); + }; + } + } + + fn handle_restart_notify(&self) { + self.update_progress(|p| { + p.mark_uncompleted(); + }); + self.set_state(DownloadTaskState::Pending); + } + + pub fn set_state(&self, state: DownloadTaskState) { + let (episode_title, ids_string) = { + let progress = self.progress.read(); + (progress.episode_title.clone(), progress.get_ids_string()) + }; + + if let Err(err) = self.state_sender.send(state).map_err(anyhow::Error::from) { + let err_title = format!("{ids_string} `{episode_title}`发送状态`{state:?}`失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + } + + fn update_progress(&self, update_fn: impl FnOnce(&mut DownloadProgress)) { + // 修改数据 + let updated_progress = { + let mut progress = self.progress.write(); + update_fn(&mut progress); + progress + }; + // 发送更新事件并保存到文件 + let _ = DownloadEvent::ProgressUpdate { + progress: updated_progress.clone(), + } + .emit(&self.app); + + if let Err(err) = updated_progress.save(&self.app) { + let ids_string = updated_progress.get_ids_string(); + let episode_title = &updated_progress.episode_title; + let err_title = format!("{ids_string} `{episode_title}`保存下载进度到文件失败"); + let string_chain = err.to_string_chain(); + tracing::error!(err_title, message = string_chain); + } + } +} + +struct DownloadChunkTask { + download_task: Arc, + start: u64, + end: u64, + url: String, + file: Arc>, + chunk_index: usize, +} + +impl DownloadChunkTask { + async fn process(self) -> anyhow::Result { + let download_chunk_task = self.download_chunk(); + tokio::pin!(download_chunk_task); + + let mut state_receiver = self.download_task.state_sender.subscribe(); + state_receiver.mark_changed(); + + let mut restart_receiver = self.download_task.restart_sender.subscribe(); + let mut delete_receiver = self.download_task.delete_sender.subscribe(); + + let mut permit = None; + + loop { + let state_is_downloading = *state_receiver.borrow() == DownloadTaskState::Downloading; + tokio::select! { + result = &mut download_chunk_task, if state_is_downloading && permit.is_some() => break result, + + result = self.acquire_chunk_permit(&mut permit), if state_is_downloading && permit.is_none() => { + match result { + Ok(()) => {}, + Err(err) => break Err(err), + } + }, + + _ = state_receiver.changed() => { + if *state_receiver.borrow() == DownloadTaskState::Paused { + // 稍微等一下再释放permit + sleep(Duration::from_millis(100)).await; + if let Some(permit) = permit.take() { + drop(permit); + }; + } + }, + + _ = restart_receiver.changed() => break Ok(self.chunk_index), + + _ = delete_receiver.changed() => break Ok(self.chunk_index), + } + } + } + + pub async fn download_chunk(&self) -> anyhow::Result { + let bili_client = self.download_task.app.get_bili_client(); + let chunk_data = bili_client + .get_media_chunk(&self.url, self.start, self.end) + .await?; + + let len = chunk_data.len() as u64; + self.download_task + .app + .get_download_manager() + .byte_per_sec + .fetch_add(len, std::sync::atomic::Ordering::Relaxed); + // 将下载的内容写入文件 + { + let mut file = self.file.lock(); + file.seek(std::io::SeekFrom::Start(self.start))?; + file.write_all(&chunk_data)?; + } + + let chunk_download_interval_sec = self + .download_task + .app + .get_config() + .read() + .chunk_download_interval_sec; + sleep(Duration::from_secs(chunk_download_interval_sec)).await; + + Ok(self.chunk_index) + } + + async fn acquire_chunk_permit<'a>( + &'a self, + permit: &mut Option>, + ) -> anyhow::Result<()> { + *permit = match permit.take() { + // 如果有permit,则直接用 + Some(permit) => Some(permit), + // 如果没有permit,则获取permit + None => Some( + self.download_task + .app + .get_download_manager() + .inner() + .media_chunk_sem + .acquire() + .await?, + ), + }; + + Ok(()) + } +} diff --git a/src-tauri/src/downloader/download_task_state.rs b/src-tauri/src/downloader/download_task_state.rs new file mode 100644 index 0000000..0122e95 --- /dev/null +++ b/src-tauri/src/downloader/download_task_state.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +pub enum DownloadTaskState { + Pending, + Downloading, + Paused, + Completed, + Failed, +} diff --git a/src-tauri/src/downloader/episode_type.rs b/src-tauri/src/downloader/episode_type.rs new file mode 100644 index 0000000..f33df4c --- /dev/null +++ b/src-tauri/src/downloader/episode_type.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] +pub enum EpisodeType { + #[default] + Normal, + Bangumi, + Cheese, +} diff --git a/src-tauri/src/downloader/fmt_params.rs b/src-tauri/src/downloader/fmt_params.rs new file mode 100644 index 0000000..bcf7748 --- /dev/null +++ b/src-tauri/src/downloader/fmt_params.rs @@ -0,0 +1,110 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::{config::Config, utils::filename_filter}; + +use super::episode_type::EpisodeType; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FmtParams { + pub task_id: String, + pub episode_type: EpisodeType, + pub aid: i64, + pub bvid: Option, + pub cid: i64, + pub ep_id: Option, + pub duration: u64, + pub pub_ts: i64, + pub collection_title: String, + pub episode_title: String, + pub episode_order: i64, + pub part_title: Option, + pub part_order: Option, + pub up_name: Option, + pub up_uid: Option, + pub create_ts: u64, +} + +impl FmtParams { + pub fn get_episode_dir_and_filename( + &self, + config: &Config, + ) -> anyhow::Result<(PathBuf, String)> { + use strfmt::strfmt; + + let mut json_value = + serde_json::to_value(self).context("将FmtParams转为serde_json::Value失败")?; + + let json_map = json_value + .as_object_mut() + .context("FmtParams不是JSON对象")?; + // 格式化时间字段 + format_time_fields(json_map, &config.time_fmt); + + let vars: HashMap = json_map + .into_iter() + .map(|(k, v)| { + let key = k.clone(); + let value = match v { + Value::String(s) => s.clone(), + + Value::Null => String::new(), + _ => v.to_string(), + }; + (key, value) + }) + .collect(); + + let dir_fmt = if self.part_title.is_some() { + &config.dir_fmt_for_part + } else { + &config.dir_fmt + }; + + let dir_fmt_parts: Vec<&str> = dir_fmt.split('/').collect(); + let mut dir_names = Vec::new(); + for fmt in dir_fmt_parts { + let dir_name = strfmt(fmt, &vars).context("格式化目录名失败")?; + let dir_name = filename_filter(&dir_name); + if !dir_name.is_empty() { + dir_names.push(dir_name); + } + } + + // 最后一部分是文件名 + let filename = dir_names.pop().context("没有找到文件名部分")?; + // 剩下的部分是目录名 + let mut episode_dir = config.download_dir.clone(); + for dir_name in dir_names { + episode_dir = episode_dir.join(dir_name); + } + + Ok((episode_dir, filename)) + } +} + +#[allow(clippy::cast_possible_wrap)] +fn format_time_fields(json_map: &mut Map, time_fmt: &str) { + if let Some(ts) = json_map.get("pub_ts").and_then(Value::as_i64) { + if let Some(ts_string) = ts_to_string(ts, time_fmt) { + json_map.insert("pub_ts".to_string(), Value::String(ts_string)); + } + } + + if let Some(ts) = json_map.get("create_ts").and_then(Value::as_u64) { + if let Some(ts_string) = ts_to_string(ts as i64, time_fmt) { + json_map.insert("create_ts".to_string(), Value::String(ts_string)); + } + } +} + +pub fn ts_to_string(ts: i64, time_fmt: &str) -> Option { + let ts_string = chrono::DateTime::from_timestamp(ts, 0)? + .with_timezone(&chrono::Local) + .format(time_fmt) + .to_string(); + Some(ts_string) +} diff --git a/src-tauri/src/downloader/media_chunk.rs b/src-tauri/src/downloader/media_chunk.rs new file mode 100644 index 0000000..e612512 --- /dev/null +++ b/src-tauri/src/downloader/media_chunk.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct MediaChunk { + pub start: u64, + pub end: u64, + pub completed: bool, +} diff --git a/src-tauri/src/downloader/mod.rs b/src-tauri/src/downloader/mod.rs new file mode 100644 index 0000000..b8af363 --- /dev/null +++ b/src-tauri/src/downloader/mod.rs @@ -0,0 +1,8 @@ +pub mod download_manager; +pub mod download_progress; +pub mod download_task; +pub mod download_task_state; +pub mod episode_type; +pub mod fmt_params; +pub mod media_chunk; +pub mod tasks; diff --git a/src-tauri/src/downloader/tasks/mod.rs b/src-tauri/src/downloader/tasks/mod.rs new file mode 100644 index 0000000..d66b1a7 --- /dev/null +++ b/src-tauri/src/downloader/tasks/mod.rs @@ -0,0 +1 @@ +pub mod video_task; diff --git a/src-tauri/src/downloader/tasks/video_task.rs b/src-tauri/src/downloader/tasks/video_task.rs new file mode 100644 index 0000000..5f631e5 --- /dev/null +++ b/src-tauri/src/downloader/tasks/video_task.rs @@ -0,0 +1,297 @@ +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::{ + bangumi_media_url::BangumiMediaUrl, cheese_media_url::CheeseMediaUrl, + codec_type::CodecType, normal_media_url::NormalMediaUrl, video_quality::VideoQuality, + }, +}; + +const CHUNK_SIZE: u64 = 2 * 1024 * 1024; // 2MB + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct VideoTask { + pub selected: bool, + pub url: String, + pub video_quality: VideoQuality, + pub codec_type: CodecType, + pub content_length: u64, + pub chunks: Vec, + pub completed: bool, +} + +impl VideoTask { + pub async fn prepare_normal( + &mut self, + app: &AppHandle, + media_url: &NormalMediaUrl, + ) -> anyhow::Result<()> { + let mut join_set = JoinSet::new(); + + for media in &media_url.dash.video { + let app = app.clone(); + let id = media.id; + let codecid = media.codecid; + + 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, + codecid, + } + }); + } + + 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 mut medias: Vec = Vec::new(); + + let mut join_set = JoinSet::new(); + + if let Some(dash) = &media_url.dash { + for media in &dash.video { + let app = app.clone(); + let id = media.id; + let codecid = media.codecid; + + 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, + codecid, + } + }); + } + } + + for durl in &media_url.durls { + for media in &durl.durl { + let app = app.clone(); + let id = durl.quality; + let codecid = media_url.video_codecid; + + let mut urls = Vec::new(); + urls.extend_from_slice(&media.backup_url); + urls.push(media.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, + codecid, + } + }); + } + } + + 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 mut medias: Vec = Vec::new(); + + let mut join_set = JoinSet::new(); + + if let Some(dash) = &media_url.dash { + for media in &dash.video { + let app = app.clone(); + let id = media.id; + let codecid = media.codecid; + + 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, + codecid, + } + }); + } + } + + for durl in &media_url.durls { + for media in &durl.durl { + let app = app.clone(); + let id = durl.quality; + let codecid = media_url.video_codecid; + + let mut urls = Vec::new(); + urls.extend_from_slice(&media.backup_url); + urls.push(media.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, + codecid, + } + }); + } + } + + 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)); + let best_quality_id = medias[0].id; + + let (prefer_quality, prefer_codec_type) = { + let config = app.get_config().inner().read(); + (config.prefer_video_quality, config.prefer_codec_type) + }; + + let prefer_quality_id: i64 = prefer_quality.into(); + let prefer_codec_id: i64 = prefer_codec_type.into(); + let prefer_quality_found = medias.iter().any(|m| m.id == prefer_quality_id); + let mut 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() + }; + // 按照 AVC > HEVC > AV1 的顺序排列 + quality_filtered_medias.sort_by_key(|m| m.codecid); + + let media = quality_filtered_medias + .iter() + .find(|m| m.codecid == prefer_codec_id) + .unwrap_or(&quality_filtered_medias[0]); + + self.video_quality = media.id.into(); + self.codec_type = media.codecid.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 + } +} + +struct MediaForPrepare { + pub id: i64, + pub url_with_content_length: Vec<(String, u64)>, + pub codecid: i64, +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 7231d1a..13329cd 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize}; use specta::Type; use tauri_specta::Event; -use crate::types::log_level::LogLevel; +use crate::{ + downloader::{download_progress::DownloadProgress, download_task_state::DownloadTaskState}, + types::log_level::LogLevel, +}; #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[serde(rename_all = "camelCase")] @@ -17,3 +20,38 @@ pub struct LogEvent { #[serde(rename = "line_number")] pub line_number: i64, } + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(tag = "event", content = "data")] +pub enum DownloadEvent { + Speed { + speed: String, + }, + + TaskCreate { + state: DownloadTaskState, + progress: DownloadProgress, + }, + + TaskStateUpdate { + task_id: String, + state: DownloadTaskState, + }, + + TaskSleeping { + task_id: String, + remaining_sec: u64, + }, + + TaskDelete { + task_id: String, + }, + + ProgressPreparing { + task_id: String, + }, + + ProgressUpdate { + progress: DownloadProgress, + }, +} diff --git a/src-tauri/src/extensions.rs b/src-tauri/src/extensions.rs index f4b7b6c..85034a2 100644 --- a/src-tauri/src/extensions.rs +++ b/src-tauri/src/extensions.rs @@ -1,7 +1,9 @@ use parking_lot::RwLock; use tauri::{Manager, State}; -use crate::{bili_client::BiliClient, config::Config}; +use crate::{ + bili_client::BiliClient, config::Config, downloader::download_manager::DownloadManager, +}; pub trait AnyhowErrorToStringChain { /// 将 `anyhow::Error` 转换为chain格式 @@ -27,6 +29,7 @@ impl AnyhowErrorToStringChain for anyhow::Error { pub trait AppHandleExt { fn get_config(&self) -> State>; fn get_bili_client(&self) -> State; + fn get_download_manager(&self) -> State; } impl AppHandleExt for tauri::AppHandle { @@ -36,4 +39,7 @@ impl AppHandleExt for tauri::AppHandle { fn get_bili_client(&self) -> State { self.state::() } + fn get_download_manager(&self) -> State { + self.state::() + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 360a599..bde1d7b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod bili_client; mod commands; mod config; +mod downloader; mod errors; mod events; mod extensions; @@ -14,7 +15,11 @@ use config::Config; use parking_lot::RwLock; use tauri::{Manager, Wry}; -use crate::{bili_client::BiliClient, events::LogEvent}; +use crate::{ + bili_client::BiliClient, + downloader::download_manager::DownloadManager, + events::{DownloadEvent, LogEvent}, +}; fn generate_context() -> tauri::Context { tauri::generate_context!() @@ -40,8 +45,14 @@ pub fn run() { get_fav_folders, get_fav_info, get_watch_later_info, + create_download_tasks, + pause_download_tasks, + resume_download_tasks, + delete_download_tasks, + restart_download_tasks, + restore_download_tasks, ]) - .events(tauri_specta::collect_events![LogEvent]); + .events(tauri_specta::collect_events![LogEvent, DownloadEvent]); #[cfg(debug_assertions)] builder @@ -76,6 +87,9 @@ pub fn run() { let bili_client = BiliClient::new(app.handle().clone()); app.manage(bili_client); + let download_manager = DownloadManager::new(app.handle().clone()); + app.manage(download_manager); + logger::init(app.handle())?; Ok(()) diff --git a/src-tauri/src/types/codec_type.rs b/src-tauri/src/types/codec_type.rs new file mode 100644 index 0000000..c9adaee --- /dev/null +++ b/src-tauri/src/types/codec_type.rs @@ -0,0 +1,26 @@ +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)] +#[allow(clippy::upper_case_acronyms)] +pub enum CodecType { + #[default] + Unknown = -1, + Audio = 0, + AVC = 7, + HEVC = 12, + AV1 = 13, +} diff --git a/src-tauri/src/types/create_download_task_params.rs b/src-tauri/src/types/create_download_task_params.rs new file mode 100644 index 0000000..73aa401 --- /dev/null +++ b/src-tauri/src/types/create_download_task_params.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::{bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub enum CreateDownloadTaskParams { + Normal(CreateNormalDownloadTaskParams), + Bangumi(CreateBangumiDownloadTaskParams), + Cheese(CreateCheeseDownloadTaskParams), +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct CreateNormalDownloadTaskParams { + pub info: NormalInfo, + pub aid_cid_pairs: Vec<(i64, Option)>, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct CreateBangumiDownloadTaskParams { + pub ep_ids: Vec, + pub info: BangumiInfo, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct CreateCheeseDownloadTaskParams { + pub ep_ids: Vec, + pub info: CheeseInfo, +} diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs index 4889f43..f190178 100644 --- a/src-tauri/src/types/mod.rs +++ b/src-tauri/src/types/mod.rs @@ -2,6 +2,8 @@ pub mod bangumi_info; pub mod bangumi_media_url; pub mod cheese_info; pub mod cheese_media_url; +pub mod codec_type; +pub mod create_download_task_params; pub mod fav_folders; pub mod fav_info; pub mod get_bangumi_info_params; @@ -15,4 +17,5 @@ pub mod player_info; pub mod qrcode_data; pub mod qrcode_status; pub mod user_info; +pub mod video_quality; pub mod watch_later_info; diff --git a/src-tauri/src/types/video_quality.rs b/src-tauri/src/types/video_quality.rs new file mode 100644 index 0000000..e224a91 --- /dev/null +++ b/src-tauri/src/types/video_quality.rs @@ -0,0 +1,48 @@ +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 VideoQuality { + #[default] + Unknown = -1, + + #[serde(rename = "240P")] + Video240P = 6, + #[serde(rename = "360P")] + Video360P = 16, + #[serde(rename = "480P")] + Video480P = 32, + #[serde(rename = "720P")] + Video720P = 64, + #[serde(rename = "720P60")] + Video720P60 = 74, + #[serde(rename = "1080P")] + Video1080P = 80, + #[serde(rename = "AiRepair")] + VideoAiRepair = 100, + #[serde(rename = "1080P+")] + Video1080PPlus = 112, + #[serde(rename = "1080P60")] + Video1080P60 = 116, + #[serde(rename = "4K")] + Video4K = 120, + #[serde(rename = "HDR")] + VideoHDR = 125, + #[serde(rename = "Dolby")] + VideoDolby = 126, + #[serde(rename = "8K")] + Video8K = 127, +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 15e701f..83dd4e7 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,3 +1,12 @@ +use std::{ + fs::File, + io::{BufReader, Read}, + path::Path, +}; + +use anyhow::{anyhow, Context}; +use byteorder::{BigEndian, ReadBytesExt}; + pub fn filename_filter(s: &str) -> String { s.chars() .map(|c| match c { @@ -17,3 +26,101 @@ pub fn filename_filter(s: &str) -> String { .trim() .to_string() } + +enum BoxSizeField { + SizeExtendToEnd, + LargeSize, + NormalSize(u32), +} + +impl From for BoxSizeField { + fn from(size: u32) -> Self { + match size { + 0 => BoxSizeField::SizeExtendToEnd, + 1 => BoxSizeField::LargeSize, + _ => BoxSizeField::NormalSize(size), + } + } +} + +pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result { + let file = File::open(file_path).context(format!("打开文件`{}`失败", file_path.display()))?; + let real_size = file + .metadata() + .context(format!("获取文件`{}`元数据失败", file_path.display()))? + .len(); + let mut reader = BufReader::new(file); + let mut total_size: u64 = 0; + + let mut has_moov_box = false; + let mut is_first_box = true; + + loop { + // 读取Box尺寸字段 + let box_size_field: BoxSizeField = match reader.read_u32::() { + Ok(s) => s.into(), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // 正常结束 + Err(e) => return Err(anyhow!(e)), + }; + // 读取Box类型字段 + let mut box_type_bytes = [0u8; 4]; + if let Err(e) = reader.read_exact(&mut box_type_bytes) { + // 如果在读取type时就结束了,说明文件在box header中间被截断 + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(false); + } + return Err(anyhow!(e)); + } + // 如果是第一个Box,检查是否是 'ftyp' Box + if is_first_box { + if &box_type_bytes != b"ftyp" { + // 如果第一个box不是 'ftyp',则认为它不是一个标准的MP4文件 + return Ok(false); + } + is_first_box = false; + } + // 检查是否有 'moov' Box + if &box_type_bytes == b"moov" { + has_moov_box = true; + } + + // 获取Box尺寸 + let box_size = match box_size_field { + // Box延伸到文件末尾,直接返回true + BoxSizeField::SizeExtendToEnd => return Ok(true), + BoxSizeField::LargeSize => { + // 对于尺寸非常大的Box(大于4GB),其真实的尺寸是一个64位的整数,紧跟在类型字段后面 + let large_box_size = reader.read_u64::()?; + // 头部总共16字节(box_size 4 + box_type 4 + large_box_size 8) + if large_box_size < 16 { + // 如果连16字节都不够,说明有问题 + return Ok(false); + } + // 跳过Box剩余的部分 + #[allow(clippy::cast_possible_wrap)] + reader.seek_relative((large_box_size - 16) as i64)?; + large_box_size + } + BoxSizeField::NormalSize(box_size) => { + // 头部总共8字节 (size 4 + type 4) + if box_size < 8 { + // 如果连8字节都不够,说明有问题 + return Ok(false); + } + // 跳过Box剩余的部分 + reader.seek_relative(i64::from(box_size - 8))?; + + u64::from(box_size) + } + }; + + total_size += box_size; + + if total_size > real_size { + // 如果总大小超过了实际文件大小,说明有问题 + return Ok(false); + } + } + + Ok(real_size == total_size && has_moov_box) +} diff --git a/src/bindings.ts b/src/bindings.ts index 16707e0..3416ceb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -122,6 +122,29 @@ async getWatchLaterInfo(page: number) : Promise { + await TAURI_INVOKE("create_download_tasks", { params }); +}, +async pauseDownloadTasks(taskIds: string[]) : Promise { + await TAURI_INVOKE("pause_download_tasks", { taskIds }); +}, +async resumeDownloadTasks(taskIds: string[]) : Promise { + await TAURI_INVOKE("resume_download_tasks", { taskIds }); +}, +async deleteDownloadTasks(taskIds: string[]) : Promise { + await TAURI_INVOKE("delete_download_tasks", { taskIds }); +}, +async restartDownloadTasks(taskIds: string[]) : Promise { + await TAURI_INVOKE("restart_download_tasks", { taskIds }); +}, +async restoreDownloadTasks() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("restore_download_tasks") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -129,8 +152,10 @@ async getWatchLaterInfo(page: number) : Promise({ +downloadEvent: "download-event", logEvent: "log-event" }) @@ -156,11 +181,16 @@ export type CheeseMediaUrl = { accept_format: string; code: number; seek_param: export type ClipInfoList = { materialNo: number; start: number; end: number; toastText: string; clipType: string } export type CntInfo = { collect: number; play: number; thumb_up: number; share: number } 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 } +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 Consulting = { consulting_flag: boolean; consulting_url: string } export type ContentList = { bold: boolean; content: string; number: string } export type Cooperation = { link: string } +export type CreateBangumiDownloadTaskParams = { ep_ids: number[]; info: BangumiInfo } +export type CreateCheeseDownloadTaskParams = { ep_ids: number[]; info: CheeseInfo } +export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams } +export type CreateNormalDownloadTaskParams = { info: NormalInfo; aid_cid_pairs: ([number, number | null])[] } export type DashInBangumi = { duration: number; min_buffer_time: number; video: MediaInBangumi[]; audio: MediaInBangumi[] | null } export type DashInCheese = { duration: number; min_buffer_time: number; video: MediaInCheese[]; audio: MediaInCheese[] | null } export type DashInNormal = { duration: number; min_buffer_time: number; video: MediaInNormal[]; audio: MediaInNormal[] | null; dolby: Dolby; flac: Flac | null } @@ -169,6 +199,9 @@ export type Dimension = { width: number; height: number; rotate: number } 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 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 } export type DurlInBangumi = { durl: DurlDetailInBangumi[]; quality: number } @@ -180,6 +213,7 @@ export type EpInCheese = { aid: number; catalogue_index: number; cid: number; co export type EpInNormal = { season_id: number; section_id: number; id: number; aid: number; cid: number; title: string; attribute: number; arc: Arc; page: PageInNormalEp; bvid: string; pages: PageInNormalEp[] } export type EpPage = { next: boolean; num: number; size: number; total: number } export type EpTag = { part_preview_tag: string; pay_tag: string; preview_tag: string } +export type EpisodeType = "Normal" | "Bangumi" | "Cheese" export type Faq = { content: string; link: string; title: string } export type Faq1 = { items: Faq1Item[]; title: string } export type Faq1Item = { answer: string; question: string } @@ -206,6 +240,7 @@ export type LevelInfoInPlayerInfo = { current_level: number; current_min: number export type LevelInfoInUserInfo = { current_level: number; current_min: number; current_exp: number } export type LogEvent = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number } export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" +export type MediaChunk = { start: number; end: number; completed: boolean } export type MediaInBangumi = { start_with_sap: number; bandwidth: number; sar: string; backup_url: string[]; codecs: string; base_url: string; segment_base: SegmentBaseInBangumi; mime_type: string; frame_rate: string; codecid: number; size: number; width: number; id: number; height: number; md5: string } export type MediaInCheese = { start_with_sap: number; bandwidth: number; sar: string; codecs: string; base_url: string; backup_url: string[]; segment_base: SegmentBaseInCheese; frame_rate: string; codecid: number; size: number; mime_type: string; width: number; id: number; height: number; md5: string } export type MediaInFav = { id: number; type: number; title: string; cover: string; intro: string; page: number; duration: number; upper: UpperInMedia; attr: number; cnt_info: CntInfoInMedia; link: string; ctime: number; pubtime: number; fav_time: number; bv_id: string; bvid: string; ugc: Ugc | null; media_list_link: string } @@ -236,6 +271,8 @@ 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 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 } export type Publish = { is_finish: number; is_started: number; pub_time: string; pub_time_show: string; unknow_pub_date: number; weekday: number } export type PurchaseFormatNote = { content_list: ContentList[]; link: string; title: string } @@ -287,6 +324,8 @@ export type UserInfo = { isLogin: boolean; email_verified: number; face: string; export type UserStatusInBangumi = { area_limit: number; ban_area_show: number; follow: number; follow_status: number; login: number; pay: number; pay_pack_paid: number; sponsor: number } export type UserStatusInCheese = { bp: number; expire_at: number; favored: number; favored_count: number; is_expired: boolean; is_first_paid: boolean; payed: number; user_expiry_content: string } export type UserStatusInCheeseUrl = { watch_progress: WatchProgress } +export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K" +export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean } export type ViewPoint = { type: number; from: number; to: number; content: string; img_url: string | null; logo_url: string | null; team_type: string; team_name: string } export type VipInPlayerInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInPlayerInfo; 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; avatar_icon: AvatarIcon } 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 }