mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-05 23:57:23 +08:00
feat: 后端支持视频下载
This commit is contained in:
Generated
+25
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClientWithMiddleware>,
|
||||
media_client: RwLock<ClientWithMiddleware>,
|
||||
content_length_client: RwLock<Client>,
|
||||
}
|
||||
|
||||
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<QrcodeData> {
|
||||
@@ -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<Bytes> {
|
||||
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<u64> {
|
||||
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::<u64>()
|
||||
.context("Content-Length 响应头无法转换为整数")?;
|
||||
|
||||
Ok(content_length)
|
||||
}
|
||||
|
||||
pub async fn get_url_with_content_length(&self, urls: Vec<String>) -> 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,
|
||||
|
||||
@@ -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<Wa
|
||||
.map_err(|err| CommandError::from("获取稍后观看内容失败", err))?;
|
||||
Ok(watch_later_info)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn create_download_tasks(app: AppHandle, params: CreateDownloadTaskParams) {
|
||||
let download_manager = app.get_download_manager();
|
||||
download_manager.create_download_tasks(¶ms);
|
||||
tracing::debug!("下载任务创建成功");
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn pause_download_tasks(app: AppHandle, task_ids: Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Semaphore>,
|
||||
pub media_chunk_sem: Arc<Semaphore>,
|
||||
pub byte_per_sec: Arc<AtomicU64>,
|
||||
pub download_tasks: RwLock<HashMap<String, Arc<DownloadTask>>>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<AtomicU64>) {
|
||||
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<PathBuf> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub cid: i64,
|
||||
pub ep_id: Option<i64>,
|
||||
pub duration: u64,
|
||||
pub pub_ts: i64,
|
||||
pub collection_title: String,
|
||||
pub part_title: Option<String>,
|
||||
pub part_order: Option<i64>,
|
||||
pub episode_title: String,
|
||||
pub episode_order: i64,
|
||||
pub up_name: Option<String>,
|
||||
pub up_uid: Option<i64>,
|
||||
pub up_avatar: Option<String>,
|
||||
pub episode_dir: PathBuf,
|
||||
pub filename: String,
|
||||
pub video_task: VideoTask,
|
||||
pub create_ts: u64,
|
||||
pub completed_ts: Option<u64>,
|
||||
}
|
||||
|
||||
impl DownloadProgress {
|
||||
pub fn from_normal(
|
||||
app: &AppHandle,
|
||||
info: &NormalInfo,
|
||||
aid: i64,
|
||||
cid: Option<i64>,
|
||||
) -> anyhow::Result<Vec<Self>> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<i64>,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<Vec<DownloadProgress>> {
|
||||
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<i64>,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<Vec<DownloadProgress>> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<DownloadTaskState>,
|
||||
pub restart_sender: watch::Sender<()>,
|
||||
pub cancel_sender: watch::Sender<()>,
|
||||
pub delete_sender: watch::Sender<()>,
|
||||
pub task_id: String,
|
||||
pub progress: RwLock<DownloadProgress>,
|
||||
}
|
||||
|
||||
impl DownloadTask {
|
||||
pub fn from_params(app: &AppHandle, params: &CreateDownloadTaskParams) -> Vec<Arc<Self>> {
|
||||
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<Self> {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>, 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<Self>, 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<SemaphorePermit<'a>>) {
|
||||
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<SemaphorePermit<'a>>,
|
||||
state_receiver: &mut watch::Receiver<DownloadTaskState>,
|
||||
) {
|
||||
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<DownloadTask>,
|
||||
start: u64,
|
||||
end: u64,
|
||||
url: String,
|
||||
file: Arc<Mutex<File>>,
|
||||
chunk_index: usize,
|
||||
}
|
||||
|
||||
impl DownloadChunkTask {
|
||||
async fn process(self) -> anyhow::Result<usize> {
|
||||
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<usize> {
|
||||
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<SemaphorePermit<'a>>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub cid: i64,
|
||||
pub ep_id: Option<i64>,
|
||||
pub duration: u64,
|
||||
pub pub_ts: i64,
|
||||
pub collection_title: String,
|
||||
pub episode_title: String,
|
||||
pub episode_order: i64,
|
||||
pub part_title: Option<String>,
|
||||
pub part_order: Option<i64>,
|
||||
pub up_name: Option<String>,
|
||||
pub up_uid: Option<i64>,
|
||||
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<String, String> = 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<String, Value>, 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<String> {
|
||||
let ts_string = chrono::DateTime::from_timestamp(ts, 0)?
|
||||
.with_timezone(&chrono::Local)
|
||||
.format(time_fmt)
|
||||
.to_string();
|
||||
Some(ts_string)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod video_task;
|
||||
@@ -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<MediaChunk>,
|
||||
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<MediaForPrepare> = Vec::new();
|
||||
|
||||
while let Some(Ok(media)) = join_set.join_next().await {
|
||||
if !media.url_with_content_length.is_empty() {
|
||||
medias.push(media);
|
||||
}
|
||||
}
|
||||
|
||||
if medias.is_empty() {
|
||||
return Err(anyhow!("获取视频地址失败"));
|
||||
}
|
||||
|
||||
self.prepare(app, medias);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare_bangumi(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
media_url: &BangumiMediaUrl,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut medias: Vec<MediaForPrepare> = 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<MediaForPrepare> = 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<MediaForPrepare>) {
|
||||
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<MediaForPrepare> = if prefer_quality_found {
|
||||
// 如果用户指定质量存在,则使用用户指定的质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == prefer_quality_id)
|
||||
.collect()
|
||||
} else {
|
||||
// 否则使用最高质量
|
||||
medias
|
||||
.into_iter()
|
||||
.filter(|m| m.id == best_quality_id)
|
||||
.collect()
|
||||
};
|
||||
// 按照 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,
|
||||
}
|
||||
+39
-1
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<RwLock<Config>>;
|
||||
fn get_bili_client(&self) -> State<BiliClient>;
|
||||
fn get_download_manager(&self) -> State<DownloadManager>;
|
||||
}
|
||||
|
||||
impl AppHandleExt for tauri::AppHandle {
|
||||
@@ -36,4 +39,7 @@ impl AppHandleExt for tauri::AppHandle {
|
||||
fn get_bili_client(&self) -> State<BiliClient> {
|
||||
self.state::<BiliClient>()
|
||||
}
|
||||
fn get_download_manager(&self) -> State<DownloadManager> {
|
||||
self.state::<DownloadManager>()
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -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<Wry> {
|
||||
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(())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<i64>)>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct CreateBangumiDownloadTaskParams {
|
||||
pub ep_ids: Vec<i64>,
|
||||
pub info: BangumiInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct CreateCheeseDownloadTaskParams {
|
||||
pub ep_ids: Vec<i64>,
|
||||
pub info: CheeseInfo,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<u32> 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<bool> {
|
||||
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::<BigEndian>() {
|
||||
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::<BigEndian>()?;
|
||||
// 头部总共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)
|
||||
}
|
||||
|
||||
+40
-1
@@ -122,6 +122,29 @@ async getWatchLaterInfo(page: number) : Promise<Result<WatchLaterInfo, CommandEr
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async createDownloadTasks(params: CreateDownloadTaskParams) : Promise<void> {
|
||||
await TAURI_INVOKE("create_download_tasks", { params });
|
||||
},
|
||||
async pauseDownloadTasks(taskIds: string[]) : Promise<void> {
|
||||
await TAURI_INVOKE("pause_download_tasks", { taskIds });
|
||||
},
|
||||
async resumeDownloadTasks(taskIds: string[]) : Promise<void> {
|
||||
await TAURI_INVOKE("resume_download_tasks", { taskIds });
|
||||
},
|
||||
async deleteDownloadTasks(taskIds: string[]) : Promise<void> {
|
||||
await TAURI_INVOKE("delete_download_tasks", { taskIds });
|
||||
},
|
||||
async restartDownloadTasks(taskIds: string[]) : Promise<void> {
|
||||
await TAURI_INVOKE("restart_download_tasks", { taskIds });
|
||||
},
|
||||
async restoreDownloadTasks() : Promise<Result<null, CommandError>> {
|
||||
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<Result<WatchLaterInfo, CommandEr
|
||||
|
||||
|
||||
export const events = __makeEvents__<{
|
||||
downloadEvent: DownloadEvent,
|
||||
logEvent: LogEvent
|
||||
}>({
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user