diff --git a/Cargo.lock b/Cargo.lock index efeff82..0418da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,7 @@ dependencies = [ "croner", "dashmap", "dirs", + "dunce", "enum_dispatch", "float-ord", "futures", @@ -1087,6 +1088,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index 16cc800..a5bd5f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ croner = "3.0.1" dashmap = "6.1.0" derivative = "2.2.0" dirs = "6.0.0" +dunce = "1.0.5" enum_dispatch = "0.3.13" float-ord = "0.3.2" futures = "0.3.31" diff --git a/README.md b/README.md index f823f2b..edf5874 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ ## 简介 > [!NOTE] -> [点击此处](https://bili-sync.allwens.work/)查看文档 +> [查看文档](https://bili-sync.amto.cc/) | [加入 Telegram 交流群](https://t.me/+nuYrt8q6uEo4MWI1) bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust & Tokio 驱动。 ## 效果演示 ### 管理页 -![管理页](/assets/webui.webp) +![管理页](./assets/webui.webp) ### 媒体库概览 ![媒体库概览](./assets/overview.webp) ### 媒体库详情 diff --git a/assets/webui.webp b/assets/webui.webp index d523f79..4f32d0b 100644 Binary files a/assets/webui.webp and b/assets/webui.webp differ diff --git a/crates/bili_sync/Cargo.toml b/crates/bili_sync/Cargo.toml index 31482bb..9caa4a6 100644 --- a/crates/bili_sync/Cargo.toml +++ b/crates/bili_sync/Cargo.toml @@ -24,6 +24,7 @@ cookie = { workspace = true } croner = { workspace = true } dashmap = { workspace = true } dirs = { workspace = true } +dunce = { workspace = true } enum_dispatch = { workspace = true } float-ord = { workspace = true } futures = { workspace = true } diff --git a/crates/bili_sync/src/api/helper.rs b/crates/bili_sync/src/api/helper.rs index 48f7d1b..864e8ff 100644 --- a/crates/bili_sync/src/api/helper.rs +++ b/crates/bili_sync/src/api/helper.rs @@ -1,9 +1,11 @@ use std::borrow::Borrow; +use bili_sync_entity::video; +use bili_sync_migration::SimpleExpr; use itertools::Itertools; -use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction}; +use sea_orm::{ColumnTrait, Condition, ConnectionTrait, DatabaseTransaction}; -use crate::api::request::StatusFilter; +use crate::api::request::{StatusFilter, ValidationFilter}; use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo}; use crate::utils::status::VideoStatus; @@ -18,6 +20,20 @@ impl StatusFilter { } } +impl ValidationFilter { + pub fn to_video_query(&self) -> SimpleExpr { + match self { + ValidationFilter::Invalid => video::Column::Valid.eq(false), + ValidationFilter::Skipped => video::Column::Valid + .eq(true) + .and(video::Column::ShouldDownload.eq(false)), + ValidationFilter::Normal => video::Column::Valid + .eq(true) + .and(video::Column::ShouldDownload.eq(true)), + } + } +} + pub trait VideoRecord { fn as_id_status_tuple(&self) -> (i32, u32); } @@ -116,10 +132,7 @@ async fn execute_page_update_batch( txn: &DatabaseTransaction, pages: impl Iterator, ) -> Result<(), sea_orm::DbErr> { - let values = pages - .map(|p| format!("({}, {})", p.0, p.1)) - .collect::>() - .join(", "); + let values = pages.map(|p| format!("({}, {})", p.0, p.1)).join(", "); if values.is_empty() { return Ok(()); } diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index 3617ca1..d6b5e18 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -12,6 +12,14 @@ pub enum StatusFilter { Waiting, } +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ValidationFilter { + Skipped, + Invalid, + Normal, +} + #[derive(Deserialize)] pub struct VideosRequest { pub collection: Option, @@ -20,6 +28,7 @@ pub struct VideosRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, pub page: Option, pub page_size: Option, } @@ -38,6 +47,7 @@ pub struct ResetFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, #[serde(default)] pub force: bool, } @@ -75,6 +85,7 @@ pub struct UpdateFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, #[serde(default)] #[validate(nested)] pub video_updates: Vec, @@ -132,3 +143,8 @@ pub struct DefaultPathRequest { pub struct PollQrcodeRequest { pub qrcode_key: String, } + +#[derive(Debug, Deserialize)] +pub struct FullSyncVideoSourceRequest { + pub delete_local: bool, +} diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index eba227d..b77b83e 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -73,9 +73,14 @@ pub struct VideoInfo { pub bvid: String, pub name: String, pub upper_name: String, + pub valid: bool, pub should_download: bool, #[serde(serialize_with = "serde_video_download_status")] pub download_status: u32, + pub collection_id: Option, + pub favorite_id: Option, + pub submission_id: Option, + pub watch_later_id: Option, } #[derive(Serialize, DerivePartialModel, FromQueryResult)] @@ -224,3 +229,9 @@ pub struct UpdateVideoSourceResponse { pub type GenerateQrcodeResponse = Qrcode; pub type PollQrcodeResponse = PollStatus; + +#[derive(Serialize)] +pub struct FullSyncVideoSourceResponse { + pub removed_count: usize, + pub warnings: Option>, +} diff --git a/crates/bili_sync/src/api/routes/video_sources/mod.rs b/crates/bili_sync/src/api/routes/video_sources/mod.rs index 0151483..94bcf0c 100644 --- a/crates/bili_sync/src/api/routes/video_sources/mod.rs +++ b/crates/bili_sync/src/api/routes/video_sources/mod.rs @@ -1,12 +1,16 @@ +use std::collections::HashSet; use std::sync::Arc; -use anyhow::Result; -use axum::Router; +use anyhow::{Context, Result}; use axum::extract::{Extension, Path, Query}; use axum::routing::{get, post, put}; +use axum::{Json, Router}; use bili_sync_entity::rule::Rule; use bili_sync_entity::*; use bili_sync_migration::Expr; +use futures::stream::FuturesUnordered; +use futures::{StreamExt, TryStreamExt}; +use itertools::Itertools; use sea_orm::ActiveValue::Set; use sea_orm::entity::prelude::*; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait}; @@ -15,11 +19,12 @@ use serde_json::json; use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum}; use crate::api::error::InnerApiError; use crate::api::request::{ - DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, - UpdateVideoSourceRequest, + DefaultPathRequest, FullSyncVideoSourceRequest, InsertCollectionRequest, InsertFavoriteRequest, + InsertSubmissionRequest, UpdateVideoSourceRequest, }; use crate::api::response::{ - UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse, + FullSyncVideoSourceResponse, UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, + VideoSourcesDetailsResponse, VideoSourcesResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission}; @@ -39,6 +44,7 @@ pub(super) fn router() -> Router { put(update_video_source).delete(remove_video_source), ) .route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source)) + .route("/video-sources/{type}/{id}/full-sync", post(full_sync_video_source)) .route("/video-sources/favorites", post(insert_favorite)) .route("/video-sources/collections", post(insert_collection)) .route("/video-sources/submissions", post(insert_submission)) @@ -373,11 +379,7 @@ pub async fn evaluate_video_source( SET should_download = tempdata.should_download \ FROM tempdata \ WHERE video.id = tempdata.id", - chunk - .iter() - .map(|item| format!("({}, {})", item.0, item.1)) - .collect::>() - .join(", ") + chunk.iter().map(|item| format!("({}, {})", item.0, item.1)).join(", ") ); txn.execute_unprepared(&sql).await?; } @@ -385,6 +387,86 @@ pub async fn evaluate_video_source( Ok(ApiResponse::ok(true)) } +pub async fn full_sync_video_source( + Path((source_type, id)): Path<(String, i32)>, + Extension(db): Extension, + Extension(bili_client): Extension>, + Json(request): Json, +) -> Result, ApiError> { + let video_source: Option = match source_type.as_str() { + "collections" => collection::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "watch_later" => watch_later::Entity::find_by_id(id).one(&db).await?.map(Into::into), + _ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()), + }; + let Some(video_source) = video_source else { + return Err(InnerApiError::NotFound(id).into()); + }; + let credential = &VersionedConfig::get().read().credential; + let filter_expr = video_source.filter_expr(); + let (_, video_streams) = video_source.refresh(&bili_client, credential, &db).await?; + let all_videos = video_streams + .try_collect::>() + .await + .context("failed to read all videos from video stream")?; + let all_bvids = all_videos.into_iter().map(|v| v.bvid_owned()).collect::>(); + let videos_to_remove = video::Entity::find() + .filter(video::Column::Bvid.is_not_in(all_bvids).and(filter_expr)) + .select_only() + .columns([video::Column::Id, video::Column::Path]) + .into_tuple::<(i32, String)>() + .all(&db) + .await?; + if videos_to_remove.is_empty() { + return Ok(ApiResponse::ok(FullSyncVideoSourceResponse { + removed_count: 0, + warnings: None, + })); + } + let remove_count = videos_to_remove.len(); + let (video_ids, video_paths): (Vec, Vec) = videos_to_remove.into_iter().unzip(); + let txn = db.begin().await?; + page::Entity::delete_many() + .filter(page::Column::VideoId.is_in(video_ids.iter().copied())) + .exec(&txn) + .await?; + video::Entity::delete_many() + .filter(video::Column::Id.is_in(video_ids)) + .exec(&txn) + .await?; + txn.commit().await?; + let warnings = if request.delete_local { + let tasks = video_paths + .into_iter() + .filter_map(|path| { + if path.is_empty() { + None + } else { + Some(async move { + tokio::fs::remove_dir_all(&path) + .await + .with_context(|| format!("failed to remove {path}"))?; + Result::<_, anyhow::Error>::Ok(()) + }) + } + }) + .collect::>(); + Some( + tasks + .filter_map(|res| futures::future::ready(res.err().map(|e| format!("{:#}", e)))) + .collect::>() + .await, + ) + } else { + None + }; + Ok(ApiResponse::ok(FullSyncVideoSourceResponse { + removed_count: remove_count, + warnings, + })) +} + /// 新增收藏夹订阅 pub async fn insert_favorite( Extension(db): Extension, diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index 0c1f9d3..5602be6 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -65,6 +65,9 @@ pub async fn get_videos( if let Some(status_filter) = params.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = params.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let total_count = query.clone().count(&db).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { (page, page_size) @@ -174,6 +177,7 @@ pub async fn clear_and_reset_video_status( let mut video_info = video_info.into_active_model(); video_info.single_page = Set(None); video_info.download_status = Set(0); + video_info.valid = Set(true); let video_info = video_info.update(&txn).await?; page::Entity::delete_many() .filter(page::Column::VideoId.eq(id)) @@ -181,11 +185,15 @@ pub async fn clear_and_reset_video_status( .await?; txn.commit().await?; let video_info = video_info.try_into_model()?; - let warning = tokio::fs::remove_dir_all(&video_info.path) - .await - .context(format!("删除本地路径「{}」失败", video_info.path)) - .err() - .map(|e| format!("{:#}", e)); + let warning = if video_info.path.is_empty() { + None + } else { + tokio::fs::remove_dir_all(&video_info.path) + .await + .context(format!("删除本地路径「{}」失败", video_info.path)) + .err() + .map(|e| format!("{:#}", e)) + }; Ok(ApiResponse::ok(ClearAndResetVideoStatusResponse { warning, video: VideoInfo { @@ -193,8 +201,13 @@ pub async fn clear_and_reset_video_status( bvid: video_info.bvid, name: video_info.name, upper_name: video_info.upper_name, + valid: video_info.valid, should_download: video_info.should_download, download_status: video_info.download_status, + collection_id: video_info.collection_id, + favorite_id: video_info.favorite_id, + submission_id: video_info.submission_id, + watch_later_id: video_info.watch_later_id, }, })) } @@ -224,6 +237,9 @@ pub async fn reset_filtered_video_status( if let Some(status_filter) = request.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = request.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let all_videos = query.into_partial_model::().all(&db).await?; let all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) @@ -360,6 +376,9 @@ pub async fn update_filtered_video_status( if let Some(status_filter) = request.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = request.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let mut all_videos = query.into_partial_model::().all(&db).await?; let mut all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) diff --git a/crates/bili_sync/src/bilibili/analyzer.rs b/crates/bili_sync/src/bilibili/analyzer.rs index 9d4d8a7..cbbb0e5 100644 --- a/crates/bili_sync/src/bilibili/analyzer.rs +++ b/crates/bili_sync/src/bilibili/analyzer.rs @@ -263,10 +263,13 @@ impl PageAnalyzer { } } if !filter_option.no_hires - && let Some(flac) = self.info.pointer_mut("/dash/flac/audio") + && let Some(flac) = self + .info + .pointer_mut("/dash/flac/audio") + .and_then(|f| f.as_object_mut()) { let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else { - bail!("invalid flac stream, flac content: {}", flac); + bail!("invalid flac stream, flac content: {:?}", flac); }; let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?; if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality { diff --git a/crates/bili_sync/src/bilibili/collection.rs b/crates/bili_sync/src/bilibili/collection.rs index 909812c..13788d5 100644 --- a/crates/bili_sync/src/bilibili/collection.rs +++ b/crates/bili_sync/src/bilibili/collection.rs @@ -196,6 +196,9 @@ impl<'a> Collection<'a> { })?; let archives = &mut videos["data"]["archives"]; if archives.as_array().is_none_or(|v| v.is_empty()) { + if page == 1 { + break; + } Err(anyhow!( "no videos found in collection {:?} page {}", self.collection, diff --git a/crates/bili_sync/src/bilibili/dynamic.rs b/crates/bili_sync/src/bilibili/dynamic.rs index 893d801..b1b6d9f 100644 --- a/crates/bili_sync/src/bilibili/dynamic.rs +++ b/crates/bili_sync/src/bilibili/dynamic.rs @@ -52,7 +52,15 @@ impl<'a> Dynamic<'a> { .get_dynamics(offset.take()) .await .with_context(|| "failed to get dynamics")?; - let items = res["data"]["items"].as_array_mut().context("items not exist")?; + let items = match res["data"]["items"].as_array_mut() { + Some(items) if !items.is_empty() => items, + _ => { + if offset.is_none() { + break; + } + Err(anyhow!("no dynamics found in offset {:?}", offset))? + } + }; for item in items.iter_mut() { if item["type"].as_str().is_none_or(|t| t != "DYNAMIC_TYPE_AV") { continue; diff --git a/crates/bili_sync/src/bilibili/favorite_list.rs b/crates/bili_sync/src/bilibili/favorite_list.rs index de61054..1e253cf 100644 --- a/crates/bili_sync/src/bilibili/favorite_list.rs +++ b/crates/bili_sync/src/bilibili/favorite_list.rs @@ -85,6 +85,9 @@ impl<'a> FavoriteList<'a> { .with_context(|| format!("failed to get videos of favorite {} page {}", self.fid, page))?; let medias = &mut videos["data"]["medias"]; if medias.as_array().is_none_or(|v| v.is_empty()) { + if page == 1 { + break; + } Err(anyhow!("no medias found in favorite {} page {}", self.fid, page))?; } let videos_info: Vec = serde_json::from_value(medias.take()) diff --git a/crates/bili_sync/src/bilibili/submission.rs b/crates/bili_sync/src/bilibili/submission.rs index 79d5cb5..5740650 100644 --- a/crates/bili_sync/src/bilibili/submission.rs +++ b/crates/bili_sync/src/bilibili/submission.rs @@ -82,6 +82,9 @@ impl<'a> Submission<'a> { .with_context(|| format!("failed to get videos of upper {} page {}", self.upper_id, page))?; let vlist = &mut videos["data"]["list"]["vlist"]; if vlist.as_array().is_none_or(|v| v.is_empty()) { + if page == 1 { + break; + } Err(anyhow!("no medias found in upper {} page {}", self.upper_id, page))?; } let videos_info: Vec = serde_json::from_value(vlist.take()) diff --git a/crates/bili_sync/src/bilibili/watch_later.rs b/crates/bili_sync/src/bilibili/watch_later.rs index 670aaa7..f866d90 100644 --- a/crates/bili_sync/src/bilibili/watch_later.rs +++ b/crates/bili_sync/src/bilibili/watch_later.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result}; use async_stream::try_stream; use futures::Stream; use serde_json::Value; @@ -38,7 +38,7 @@ impl<'a> WatchLater<'a> { .with_context(|| "Failed to get watch later list")?; let list = &mut videos["data"]["list"]; if list.as_array().is_none_or(|v| v.is_empty()) { - Err(anyhow!("No videos found in watch later list"))?; + return; } let videos_info: Vec = serde_json::from_value(list.take()).with_context(|| "Failed to parse watch later list")?; diff --git a/crates/bili_sync/src/config/current.rs b/crates/bili_sync/src/config/current.rs index 42eff95..a3e83db 100644 --- a/crates/bili_sync/src/config/current.rs +++ b/crates/bili_sync/src/config/current.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, LazyLock}; use anyhow::{Result, bail}; use croner::parser::CronParser; +use itertools::Itertools; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -51,6 +52,8 @@ pub struct Config { pub concurrent_limit: ConcurrentLimit, pub time_format: String, pub cdn_sorting: bool, + #[serde(default)] + pub try_upower_anyway: bool, pub version: u64, } @@ -105,13 +108,7 @@ impl Config { } }; if !errors.is_empty() { - bail!( - errors - .into_iter() - .map(|e| format!("- {}", e)) - .collect::>() - .join("\n") - ); + bail!(errors.into_iter().map(|e| format!("- {}", e)).join("\n")); } Ok(()) } @@ -139,6 +136,7 @@ impl Default for Config { concurrent_limit: ConcurrentLimit::default(), time_format: default_time_format(), cdn_sorting: false, + try_upower_anyway: false, version: 0, } } diff --git a/crates/bili_sync/src/notifier/info.rs b/crates/bili_sync/src/notifier/info.rs new file mode 100644 index 0000000..6833230 --- /dev/null +++ b/crates/bili_sync/src/notifier/info.rs @@ -0,0 +1,67 @@ +use bili_sync_entity::video; + +use crate::utils::status::{STATUS_OK, VideoStatus}; + +pub enum DownloadNotifyInfo { + List { + source: String, + img_url: Option, + titles: Vec, + }, + Summary { + source: String, + img_url: Option, + count: usize, + }, +} + +impl DownloadNotifyInfo { + pub fn new(source: String) -> Self { + Self::List { + source, + img_url: None, + titles: Vec::with_capacity(10), + } + } + + pub fn record(&mut self, models: &[video::ActiveModel]) { + let success_models = models + .iter() + .filter(|m| { + let sub_task_status: [u32; 5] = VideoStatus::from(*m.download_status.as_ref()).into(); + sub_task_status.into_iter().all(|s| s == STATUS_OK) + }) + .collect::>(); + match self { + Self::List { + source, + img_url, + titles, + } => { + let count = success_models.len() + titles.len(); + if count > 10 { + *self = Self::Summary { + source: std::mem::take(source), + img_url: std::mem::take(img_url), + count, + }; + } else { + if img_url.is_none() { + *img_url = success_models.first().map(|m| m.cover.as_ref().clone()); + } + titles.extend(success_models.into_iter().map(|m| m.name.as_ref().clone())); + } + } + Self::Summary { count, .. } => *count += success_models.len(), + } + } + + pub fn should_notify(&self) -> bool { + if let Self::List { titles, .. } = self + && titles.is_empty() + { + return false; + } + true + } +} diff --git a/crates/bili_sync/src/notifier/message.rs b/crates/bili_sync/src/notifier/message.rs new file mode 100644 index 0000000..8609bc1 --- /dev/null +++ b/crates/bili_sync/src/notifier/message.rs @@ -0,0 +1,59 @@ +use std::borrow::Cow; + +use itertools::Itertools; +use serde::Serialize; + +use crate::notifier::DownloadNotifyInfo; + +#[derive(Serialize)] +pub struct Message<'a> { + pub message: Cow<'a, str>, + pub image_url: Option, +} + +impl<'a> From<&'a str> for Message<'a> { + fn from(message: &'a str) -> Self { + Self { + message: Cow::Borrowed(message), + image_url: None, + } + } +} + +impl From for Message<'_> { + fn from(message: String) -> Self { + Self { + message: message.into(), + image_url: None, + } + } +} + +impl From for Message<'_> { + fn from(info: DownloadNotifyInfo) -> Self { + match info { + DownloadNotifyInfo::List { + source, + img_url, + titles, + } => Self { + message: format!( + "{}的 {} 条新视频已入库:\n{}", + source, + titles.len(), + titles + .into_iter() + .enumerate() + .map(|(i, title)| format!("{}. {title}", i + 1)) + .join("\n") + ) + .into(), + image_url: img_url, + }, + DownloadNotifyInfo::Summary { source, img_url, count } => Self { + message: format!("{}的 {} 条新视频已入库,快去看看吧!", source, count).into(), + image_url: img_url, + }, + } + } +} diff --git a/crates/bili_sync/src/notifier/mod.rs b/crates/bili_sync/src/notifier/mod.rs index 411246d..da2759f 100644 --- a/crates/bili_sync/src/notifier/mod.rs +++ b/crates/bili_sync/src/notifier/mod.rs @@ -1,5 +1,10 @@ +mod info; +mod message; + use anyhow::Result; use futures::future; +pub use info::DownloadNotifyInfo; +pub use message::Message; use reqwest::header; use serde::{Deserialize, Serialize}; @@ -33,23 +38,38 @@ pub fn webhook_template_content(template: &Option) -> &str { } pub trait NotifierAllExt { - async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()>; + async fn notify_all<'a>(&self, client: &reqwest::Client, message: impl Into>) -> Result<()>; } impl NotifierAllExt for Vec { - async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()> { - future::join_all(self.iter().map(|notifier| notifier.notify(client, message))).await; + async fn notify_all<'a>(&self, client: &reqwest::Client, message: impl Into>) -> Result<()> { + let message = message.into(); + future::join_all(self.iter().map(|notifier| notifier.notify_internal(client, &message))).await; Ok(()) } } impl Notifier { - pub async fn notify(&self, client: &reqwest::Client, message: &str) -> Result<()> { + pub async fn notify<'a>(&self, client: &reqwest::Client, message: impl Into>) -> Result<()> { + self.notify_internal(client, &message.into()).await + } + + async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> { match self { Notifier::Telegram { bot_token, chat_id } => { - let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token); - let params = [("chat_id", chat_id.as_str()), ("text", message)]; - client.post(&url).form(¶ms).send().await?; + if let Some(img_url) = &message.image_url { + let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token); + let params = [ + ("chat_id", chat_id.as_str()), + ("photo", img_url.as_str()), + ("caption", message.message.as_ref()), + ]; + client.post(&url).form(¶ms).send().await?; + } else { + let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token); + let params = [("chat_id", chat_id.as_str()), ("text", message.message.as_ref())]; + client.post(&url).form(¶ms).send().await?; + } } Notifier::Webhook { url, @@ -57,15 +77,10 @@ impl Notifier { ignore_cache, } => { let key = webhook_template_key(url); - let data = serde_json::json!( - { - "message": message, - } - ); let handlebar = TEMPLATE.read(); let payload = match ignore_cache { - Some(_) => handlebar.render_template(webhook_template_content(template), &data)?, - None => handlebar.render(&key, &data)?, + Some(_) => handlebar.render_template(webhook_template_content(template), &message)?, + None => handlebar.render(&key, &message)?, }; client .post(url) diff --git a/crates/bili_sync/src/utils/convert.rs b/crates/bili_sync/src/utils/convert.rs index c1d0a40..9803faa 100644 --- a/crates/bili_sync/src/utils/convert.rs +++ b/crates/bili_sync/src/utils/convert.rs @@ -10,6 +10,7 @@ impl VideoInfo { let default = bili_sync_entity::video::ActiveModel { id: NotSet, created_at: NotSet, + should_download: NotSet, // 此处不使用 ActiveModel::default() 是为了让其它字段有默认值 ..bili_sync_entity::video::Model::default().into_active_model() }; @@ -49,7 +50,7 @@ impl VideoInfo { pubtime: Set(pubtime.naive_utc()), favtime: Set(fav_time.naive_utc()), download_status: Set(0), - valid: Set(attr == 0), + valid: Set(attr == 0 || attr == 4), upper_id: Set(upper.mid), upper_name: Set(upper.name), upper_face: Set(upper.face), @@ -119,7 +120,12 @@ impl VideoInfo { /// 填充视频详情时调用,该方法会将视频详情附加到原有的 Model 上 /// 特殊地,如果在检测视频更新时记录了 favtime,那么 favtime 会维持原样,否则会使用 pubtime 填充 - pub fn into_detail_model(self, base_model: bili_sync_entity::video::Model) -> bili_sync_entity::video::ActiveModel { + /// 如果开启 try_upower_anyway,标记视频状态时不再检测是否充电,一律进入后面的下载环节 + pub fn into_detail_model( + self, + base_model: bili_sync_entity::video::Model, + try_upower_anyway: bool, + ) -> bili_sync_entity::video::ActiveModel { match self { VideoInfo::Detail { title, @@ -153,7 +159,9 @@ impl VideoInfo { // 2. 都为 false,表示视频是非充电视频 // redirect_url 仅在视频为番剧、影视、纪录片等特殊视频时才会有值,如果为空说明是普通视频 // 仅在三种条件都满足时,才认为视频是可下载的 - valid: Set(state == 0 && (is_upower_exclusive == is_upower_play) && redirect_url.is_none()), + valid: Set(state == 0 + && (try_upower_anyway || (is_upower_exclusive == is_upower_play)) + && redirect_url.is_none()), upper_id: Set(upper.mid), upper_name: Set(upper.name), upper_face: Set(upper.face), @@ -174,6 +182,17 @@ impl VideoInfo { VideoInfo::Detail { .. } => unreachable!(), } } + + pub fn bvid_owned(self) -> String { + match self { + VideoInfo::Collection { bvid, .. } + | VideoInfo::Favorite { bvid, .. } + | VideoInfo::WatchLater { bvid, .. } + | VideoInfo::Submission { bvid, .. } + | VideoInfo::Dynamic { bvid, .. } + | VideoInfo::Detail { bvid, .. } => bvid, + } + } } impl PageInfo { diff --git a/crates/bili_sync/src/utils/notify.rs b/crates/bili_sync/src/utils/notify.rs index 128ef91..05cd770 100644 --- a/crates/bili_sync/src/utils/notify.rs +++ b/crates/bili_sync/src/utils/notify.rs @@ -1,6 +1,16 @@ use crate::bilibili::BiliClient; use crate::config::Config; -use crate::notifier::NotifierAllExt; +use crate::notifier::{Message, NotifierAllExt}; + +pub fn notify(config: &Config, bili_client: &BiliClient, msg: impl Into>) { + if let Some(notifiers) = &config.notifiers + && !notifiers.is_empty() + { + let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone()); + let msg = msg.into(); + tokio::spawn(async move { notifiers.notify_all(&inner_client, msg).await }); + } +} pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String) { error!("{msg}"); @@ -8,6 +18,6 @@ pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String) && !notifiers.is_empty() { let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone()); - tokio::spawn(async move { notifiers.notify_all(&inner_client, msg.as_str()).await }); + tokio::spawn(async move { notifiers.notify_all(&inner_client, msg).await }); } } diff --git a/crates/bili_sync/src/workflow.rs b/crates/bili_sync/src/workflow.rs index f6bb619..672be9a 100644 --- a/crates/bili_sync/src/workflow.rs +++ b/crates/bili_sync/src/workflow.rs @@ -17,6 +17,7 @@ use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Vi use crate::config::{ARGS, Config, PathSafeTemplate}; use crate::downloader::Downloader; use crate::error::ExecutionStatus; +use crate::notifier::DownloadNotifyInfo; use crate::utils::download_context::DownloadContext; use crate::utils::format_arg::{page_format_args, video_format_args}; use crate::utils::model::{ @@ -24,6 +25,7 @@ use crate::utils::model::{ update_videos_model, }; use crate::utils::nfo::{NFO, ToNFO}; +use crate::utils::notify::notify; use crate::utils::rule::FieldEvaluatable; use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus}; @@ -49,7 +51,11 @@ pub async fn process_video_source( warn!("已开启仅扫描模式,跳过视频下载.."); } else { // 从数据库中查找所有未下载的视频与分页,下载并处理 - download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?; + let download_notify_info = + download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?; + if download_notify_info.should_notify() { + notify(config, bili_client, download_notify_info); + } } Ok(()) } @@ -150,7 +156,7 @@ pub async fn fetch_video_details( .map(|p| p.into_active_model(video_model.id)) .collect::>(); // 更新 video model 的各项有关属性 - let mut video_active_model = view_info.into_detail_model(video_model); + let mut video_active_model = view_info.into_detail_model(video_model, config.try_upower_anyway); video_source.set_relation_id(&mut video_active_model); video_active_model.single_page = Set(Some(pages.len() == 1)); video_active_model.tags = Set(Some(tags.into())); @@ -176,7 +182,7 @@ pub async fn download_unprocessed_videos( connection: &DatabaseConnection, template: &handlebars::Handlebars<'_>, config: &Config, -) -> Result<()> { +) -> Result { video_source.log_download_video_start(); let semaphore = Semaphore::new(config.concurrent_limit.video); let downloader = Downloader::new(bili_client.client.clone()); @@ -207,14 +213,16 @@ pub async fn download_unprocessed_videos( .filter_map(|res| futures::future::ready(res.ok())) // 将成功返回的 Model 按十个一组合并 .chunks(10); + let mut download_notify_info = DownloadNotifyInfo::new(video_source.display_name().into()); while let Some(models) = stream.next().await { + download_notify_info.record(&models); update_videos_model(models, connection).await?; } if let Some(e) = risk_control_related_error { bail!(e); } video_source.log_download_video_end(); - Ok(()) + Ok(download_notify_info) } pub async fn download_video_pages( @@ -236,6 +244,8 @@ pub async fn download_video_pages( .path_safe_render("video", &video_format_args(&video_model, &cx.config.time_format))?, ) }; + fs::create_dir_all(&base_path).await?; + let base_path = dunce::canonicalize(base_path).context("canonicalize video path failed")?; let upper_id = video_model.upper_id.to_string(); let base_upper_path = cx .config @@ -416,6 +426,7 @@ pub async fn download_page( )?, ) }; + let base_path = dunce::canonicalize(base_path).context("canonicalize base path failed")?; let (poster_path, video_path, nfo_path, danmaku_path, fanart_path, subtitle_path) = if is_single_page { ( base_path.join(format!("{}-poster.jpg", &base_name)), diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6fa62a0..ff3c41e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -21,7 +21,7 @@ export default defineConfig({ nav: [ { text: "主页", link: "/" }, { - text: "v2.10.3", + text: "v2.10.4", items: [ { text: "程序更新", diff --git a/docs/public/CNAME b/docs/public/CNAME index fc50adb..af6a5a6 100644 --- a/docs/public/CNAME +++ b/docs/public/CNAME @@ -1 +1 @@ -bili-sync.allwens.work \ No newline at end of file +bili-sync.amto.cc diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0c1a074..19f76d4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.93.1" components = ["clippy"] diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e4a69ec..7e02098 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,6 +6,8 @@ import type { Config, DashBoardResponse, FavoritesResponse, + FullSyncVideoSourceRequest, + FullSyncVideoSourceResponse, QrcodeGenerateResponse as GenerateQrcodeResponse, InsertCollectionRequest, InsertFavoriteRequest, @@ -253,6 +255,14 @@ class ApiClient { return this.post(`/video-sources/${type}/${id}/evaluate`, null); } + async fullSyncVideoSource( + type: string, + id: number, + data: FullSyncVideoSourceRequest + ): Promise> { + return this.post(`/video-sources/${type}/${id}/full-sync`, data); + } + async getDefaultPath(type: string, name: string): Promise> { return this.get(`/video-sources/${type}/default-path`, { name }); } @@ -327,6 +337,8 @@ const api = { removeVideoSource: (type: string, id: number) => apiClient.removeVideoSource(type, id), evaluateVideoSourceRules: (type: string, id: number) => apiClient.evaluateVideoSourceRules(type, id), + fullSyncVideoSource: (type: string, id: number, data: { delete_local: boolean }) => + apiClient.fullSyncVideoSource(type, id, data), getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name), testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier), getConfig: () => apiClient.getConfig(), diff --git a/web/src/lib/components/subscription-card.svelte b/web/src/lib/components/subscription-card.svelte index 1c94584..b7f5493 100644 --- a/web/src/lib/components/subscription-card.svelte +++ b/web/src/lib/components/subscription-card.svelte @@ -148,7 +148,7 @@ ? 'opacity-60' : ''}" > - +
+ import { + CircleCheckBigIcon, + TriangleAlertIcon, + SkipForwardIcon, + ChevronDownIcon, + TrashIcon + } from '@lucide/svelte/icons'; + import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; + import { Button } from '$lib/components/ui/button/index.js'; + import { type ValidationFilterValue } from '$lib/stores/filter'; + + interface Props { + value: ValidationFilterValue; + onSelect?: (value: ValidationFilterValue) => void; + onRemove?: () => void; + } + + let { value = $bindable('normal'), onSelect, onRemove }: Props = $props(); + + let open = $state(false); + let triggerRef = $state(null!); + + function closeAndFocusTrigger() { + open = false; + } + + const validationOptions = [ + { + value: 'normal' as const, + label: '有效', + icon: CircleCheckBigIcon + }, + { + value: 'skipped' as const, + label: '跳过', + icon: SkipForwardIcon + }, + { + value: 'invalid' as const, + label: '失效', + icon: TriangleAlertIcon + } + ]; + + function handleSelect(selectedValue: ValidationFilterValue) { + value = selectedValue; + onSelect?.(selectedValue); + closeAndFocusTrigger(); + } + + const currentOption = $derived(validationOptions.find((opt) => opt.value === value)); + + +
+ + {currentOption ? currentOption.label : '未应用'} + + + + + {#snippet child({ props })} + + {/snippet} + + + + 有效性 + {#each validationOptions as option (option.value)} + handleSelect(option.value)}> + + + {option.label} + + {#if value === option.value} + + {/if} + + {/each} + + { + closeAndFocusTrigger(); + onRemove?.(); + }} + > + + 移除筛选 + + + + +
diff --git a/web/src/lib/components/video-card.svelte b/web/src/lib/components/video-card.svelte index 54a28a4..e7760f0 100644 --- a/web/src/lib/components/video-card.svelte +++ b/web/src/lib/components/video-card.svelte @@ -13,13 +13,17 @@ BrushCleaningIcon, UserIcon, SquareArrowOutUpRightIcon, - EllipsisIcon + EllipsisIcon, + HeartIcon, + FolderIcon, + ClockIcon } from '@lucide/svelte/icons'; import { goto } from '$app/navigation'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; // 将 bvid 设置为可选属性,但保留 VideoInfo 的其它所有属性 export let video: Omit & { bvid?: string }; + export let source: { type: string; name: string } | null = null; // 视频源信息 export let showActions: boolean = true; // 控制是否显示操作按钮 export let mode: 'default' | 'detail' | 'page' = 'default'; // 卡片模式 export let customTitle: string = ''; // 自定义标题 @@ -57,11 +61,16 @@ function getOverallStatus( downloadStatus: number[], - shouldDownload: boolean + shouldDownload: boolean, + valid: boolean ): { text: string; style: string; } { + if (!valid) { + // 视频属性表明已失效,或由于各种条件判断(充电视频等)判定为无效的情况 + return { text: '失效', style: 'bg-gray-100 text-gray-700' }; + } if (!shouldDownload) { // 被过滤规则排除,显示为“跳过” return { text: '跳过', style: 'bg-gray-100 text-gray-700' }; @@ -90,7 +99,7 @@ return defaultTaskNames[index] || `任务${index + 1}`; } - $: overallStatus = getOverallStatus(video.download_status, video.should_download); + $: overallStatus = getOverallStatus(video.download_status, video.should_download, video.valid); $: completed = video.download_status.filter((status) => status === 7).length; $: total = video.download_status.length; @@ -127,7 +136,7 @@ - +
+ + {#if source.type === 'favorite'} + + {:else if source.type === 'collection'} + + {:else if source.type === 'submission'} + + {:else if source.type === 'watch_later'} + + {/if} + + {source.name} + + +
+ {/if}
({ query: '', currentPage: 0, videoSource: null, - statusFilter: null + statusFilter: null, + validationFilter: 'normal' }); export const ToQuery = (state: AppState): string => { - const { query, videoSource, currentPage, statusFilter } = state; + const { query, videoSource, currentPage, statusFilter, validationFilter } = state; const params = new URLSearchParams(); if (currentPage > 0) { params.set('page', String(currentPage)); @@ -34,6 +37,9 @@ export const ToQuery = (state: AppState): string => { if (statusFilter) { params.set('status_filter', statusFilter); } + if (validationFilter) { + params.set('validation_filter', validationFilter); + } const queryString = params.toString(); return queryString ? `videos?${queryString}` : 'videos'; }; @@ -48,6 +54,7 @@ export const ToFilterParams = ( submission?: number; watch_later?: number; status_filter?: Exclude; + validation_filter?: Exclude; } => { const params: { query?: string; @@ -56,6 +63,7 @@ export const ToFilterParams = ( submission?: number; watch_later?: number; status_filter?: Exclude; + validation_filter?: Exclude; } = {}; if (state.query.trim()) { @@ -69,12 +77,20 @@ export const ToFilterParams = ( if (state.statusFilter) { params.status_filter = state.statusFilter; } + if (state.validationFilter) { + params.validation_filter = state.validationFilter; + } return params; }; // 检查是否有活动的筛选条件 export const hasActiveFilters = (state: AppState): boolean => { - return !!(state.query.trim() || state.videoSource || state.statusFilter); + return !!( + state.query.trim() || + state.videoSource || + state.statusFilter || + state.validationFilter + ); }; export const setQuery = (query: string) => { @@ -98,6 +114,13 @@ export const setStatusFilter = (statusFilter: StatusFilterValue | null) => { })); }; +export const setValidationFilter = (validationFilter: ValidationFilterValue | null) => { + appStateStore.update((state) => ({ + ...state, + validationFilter + })); +}; + export const resetCurrentPage = () => { appStateStore.update((state) => ({ ...state, @@ -109,12 +132,14 @@ export const setAll = ( query: string, currentPage: number, videoSource: { type: string; id: string } | null, - statusFilter: StatusFilterValue | null + statusFilter: StatusFilterValue | null, + validationFilter: ValidationFilterValue | null = 'normal' ) => { appStateStore.set({ query, currentPage, videoSource, - statusFilter + statusFilter, + validationFilter }); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b2db172..991f775 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,7 +9,8 @@ export interface VideosRequest { submission?: number; watch_later?: number; query?: string; - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; page?: number; page_size?: number; } @@ -31,8 +32,13 @@ export interface VideoInfo { bvid: string; name: string; upper_name: string; + valid: boolean; should_download: boolean; download_status: [number, number, number, number, number]; + collection_id?: number; + favorite_id?: number; + submission_id?: number; + watch_later_id?: number; } export interface VideosResponse { @@ -83,7 +89,16 @@ export interface UpdateFilteredVideoStatusResponse { export interface ApiError { message: string; - status?: number; + status: number; +} + +export interface FullSyncVideoSourceRequest { + delete_local: boolean; +} + +export interface FullSyncVideoSourceResponse { + removed_count: number; + warnings?: string[]; } export interface StatusUpdate { @@ -107,8 +122,8 @@ export interface UpdateFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; - // 仅更新下载失败 - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; video_updates?: StatusUpdate[]; page_updates?: StatusUpdate[]; } @@ -123,8 +138,8 @@ export interface ResetFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; - // 仅重置下载失败 - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; force: boolean; } @@ -319,6 +334,7 @@ export interface Config { concurrent_limit: ConcurrentLimit; time_format: string; cdn_sorting: boolean; + try_upower_anyway: boolean; version: number; } diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 6adf284..cd9c1ac 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -26,13 +26,11 @@ interface ClientEvent { type LogsCallback = (data: string) => void; type TasksCallback = (data: TaskStatus) => void; type SysInfoCallback = (data: SysInfo) => void; -type ErrorCallback = (error: Event) => void; export class WebSocketManager { private static instance: WebSocketManager; private socket: WebSocket | null = null; private connected = false; - private connecting = false; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; @@ -41,7 +39,6 @@ export class WebSocketManager { private logsSubscribers: Set = new Set(); private tasksSubscribers: Set = new Set(); private sysInfoSubscribers: Set = new Set(); - private errorSubscribers: Set = new Set(); private subscribedEvents: Set = new Set(); private connectionPromise: Promise | null = null; @@ -61,7 +58,6 @@ export class WebSocketManager { if (this.connectionPromise) return this.connectionPromise; this.connectionPromise = new Promise((resolve, reject) => { - this.connecting = true; const token = api.getAuthToken() || ''; try { @@ -73,7 +69,6 @@ export class WebSocketManager { ); this.socket.onopen = () => { this.connected = true; - this.connecting = false; this.reconnectAttempts = 0; this.connectionPromise = null; this.resubscribeEvents(); @@ -84,20 +79,17 @@ export class WebSocketManager { this.socket.onclose = () => { this.connected = false; - this.connecting = false; this.connectionPromise = null; this.scheduleReconnect(); }; this.socket.onerror = (error) => { console.error('WebSocket error:', error); - this.connecting = false; this.connectionPromise = null; reject(error); toast.error('WebSocket 连接发生错误,请检查网络或稍后重试'); }; } catch (error) { - this.connecting = false; this.connectionPromise = null; reject(error); console.error('Failed to create WebSocket:', error); @@ -273,7 +265,6 @@ export class WebSocketManager { } this.connected = false; - this.connecting = false; this.connectionPromise = null; this.subscribedEvents.clear(); } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 56220cc..4e8deb4 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -29,11 +29,13 @@ DownloadIcon } from '@lucide/svelte/icons'; - let dashboardData: DashBoardResponse | null = null; - let sysInfo: SysInfo | null = null; - let taskStatus: TaskStatus | null = null; - let loading = false; - let triggering = false; + let dashboardData = $state(null); + let sysInfo = $state(null); + let taskStatus = $state(null); + let loading = $state(false); + let triggering = $state(false); + let memoryHistory = $state>([]); + let cpuHistory = $state>([]); let unsubscribeSysInfo: (() => void) | null = null; let unsubscribeTasks: (() => void) | null = null; @@ -90,29 +92,6 @@ } } - onMount(() => { - setBreadcrumb([{ label: '仪表盘' }]); - - unsubscribeSysInfo = api.subscribeToSysInfo((data) => { - sysInfo = data; - }); - unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => { - taskStatus = data; - }); - loadDashboard(); - return () => { - if (unsubscribeSysInfo) { - unsubscribeSysInfo(); - unsubscribeSysInfo = null; - } - if (unsubscribeTasks) { - unsubscribeTasks(); - unsubscribeTasks = null; - } - }; - }); - - // 图表配置 const videoChartConfig = { videos: { label: '视频数量', @@ -142,32 +121,51 @@ } } satisfies Chart.ChartConfig; - let memoryHistory: Array<{ time: number; used: number; process: number }> = []; - let cpuHistory: Array<{ time: number; used: number; process: number }> = []; - - $: if (sysInfo) { + function pushSysInfo(data: SysInfo) { memoryHistory = [ ...memoryHistory.slice(-14), { - time: sysInfo.timestamp, - used: sysInfo.used_memory, - process: sysInfo.process_memory + time: data.timestamp, + used: data.used_memory, + process: data.process_memory } ]; cpuHistory = [ ...cpuHistory.slice(-14), { - time: sysInfo.timestamp, - used: sysInfo.used_cpu, - process: sysInfo.process_cpu + time: data.timestamp, + used: data.used_cpu, + process: data.process_cpu } ]; } - // 计算磁盘使用率 - $: diskUsagePercent = sysInfo - ? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100 - : 0; + const diskUsagePercent = $derived( + sysInfo ? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100 : 0 + ); + + onMount(() => { + setBreadcrumb([{ label: '仪表盘' }]); + + unsubscribeSysInfo = api.subscribeToSysInfo((data) => { + sysInfo = data; + pushSysInfo(data); + }); + unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => { + taskStatus = data; + }); + loadDashboard(); + return () => { + if (unsubscribeSysInfo) { + unsubscribeSysInfo(); + unsubscribeSysInfo = null; + } + if (unsubscribeTasks) { + unsubscribeTasks(); + unsubscribeTasks = null; + } + }; + }); diff --git a/web/src/routes/logs/+page.svelte b/web/src/routes/logs/+page.svelte index d38ec29..a60c500 100644 --- a/web/src/routes/logs/+page.svelte +++ b/web/src/routes/logs/+page.svelte @@ -1,22 +1,24 @@