feat: 后端支持视频下载

This commit is contained in:
lanyeeee
2025-07-22 06:09:36 +08:00
parent 9f4a2b6099
commit a1649e6a07
24 changed files with 2469 additions and 13 deletions
+25
View File
@@ -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"
+7
View File
@@ -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
+115 -2
View File
@@ -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,
+59 -6
View File
@@ -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(&params);
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(())
}
+91
View File
@@ -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 }
}
}
+646
View File
@@ -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 &params.aid_cid_pairs {
let progress = match DownloadProgress::from_normal(app, &params.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 &params.ep_ids {
let progress = match DownloadProgress::from_bangumi(app, &params.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 &params.ep_ids {
let progress = match DownloadProgress::from_cheese(app, &params.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,
}
+10
View File
@@ -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,
}
+110
View File
@@ -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)
}
+9
View File
@@ -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,
}
+8
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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,
},
}
+7 -1
View File
@@ -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
View File
@@ -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(())
+26
View File
@@ -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,
}
+3
View File
@@ -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;
+48
View File
@@ -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,
}
+107
View File
@@ -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
View File
@@ -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 }