From e50318870e47c3dabe4a2d0e1bf715d71a08a728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Wed, 18 Jun 2025 16:50:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E3=80=81=E6=8F=90=E4=BA=A4=20Config=20(#370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 28 +- Cargo.toml | 1 + crates/bili_sync/Cargo.toml | 1 + crates/bili_sync/src/api/auth.rs | 2 +- crates/bili_sync/src/api/handler.rs | 37 + crates/bili_sync/src/api/wrapper.rs | 51 +- crates/bili_sync/src/bilibili/analyzer.rs | 20 +- crates/bili_sync/src/bilibili/client.rs | 15 +- crates/bili_sync/src/bilibili/credential.rs | 3 +- .../src/bilibili/danmaku/canvas/mod.rs | 3 +- crates/bili_sync/src/bilibili/me.rs | 2 +- crates/bili_sync/src/config/current.rs | 22 +- crates/bili_sync/src/config/flag.rs | 3 - crates/bili_sync/src/config/handlebar.rs | 1 + crates/bili_sync/src/config/item.rs | 9 +- crates/bili_sync/src/config/legacy.rs | 3 +- crates/bili_sync/src/config/mod.rs | 2 - .../bili_sync/src/config/versioned_cache.rs | 35 +- .../bili_sync/src/config/versioned_config.rs | 49 +- crates/bili_sync/src/task/mod.rs | 2 +- crates/bili_sync/src/task/video_downloader.rs | 8 +- web/src/lib/api.ts | 259 +++---- web/src/lib/types.ts | 59 ++ web/src/routes/me/collections/+page.svelte | 4 - web/src/routes/me/favorites/+page.svelte | 4 - web/src/routes/me/uppers/+page.svelte | 4 - web/src/routes/settings/+page.svelte | 647 +++++++++++++++++- 27 files changed, 963 insertions(+), 311 deletions(-) delete mode 100644 crates/bili_sync/src/config/flag.rs diff --git a/Cargo.lock b/Cargo.lock index 382adb2..c016487 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,6 +500,7 @@ dependencies = [ "md5", "memchr", "once_cell", + "parking_lot", "prost", "quick-xml", "rand", @@ -1934,9 +1935,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -2198,9 +2199,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -2208,15 +2209,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.13", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -2621,6 +2622,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +dependencies = [ + "bitflags 2.5.0", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -4282,7 +4292,7 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" dependencies = [ - "redox_syscall", + "redox_syscall 0.4.1", "wasite", ] diff --git a/Cargo.toml b/Cargo.toml index bb279cf..d3b4759 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ leaky-bucket = "1.1.2" md5 = "0.7.0" memchr = "2.7.4" once_cell = "1.21.3" +parking_lot = "0.12.4" prost = "0.13.5" quick-xml = { version = "0.37.5", features = ["async-tokio"] } rand = "0.8.5" diff --git a/crates/bili_sync/Cargo.toml b/crates/bili_sync/Cargo.toml index 1cdfe27..7c8b12c 100644 --- a/crates/bili_sync/Cargo.toml +++ b/crates/bili_sync/Cargo.toml @@ -30,6 +30,7 @@ leaky-bucket = { workspace = true } md5 = { workspace = true } memchr = { workspace = true } once_cell = { workspace = true } +parking_lot = { workspace = true } prost = { workspace = true } quick-xml = { workspace = true } rand = { workspace = true } diff --git a/crates/bili_sync/src/api/auth.rs b/crates/bili_sync/src/api/auth.rs index b7bb827..0f9ef09 100644 --- a/crates/bili_sync/src/api/auth.rs +++ b/crates/bili_sync/src/api/auth.rs @@ -13,7 +13,7 @@ pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result::unauthorized("auth token does not match").into_response()); } Ok(next.run(request).await) } diff --git a/crates/bili_sync/src/api/handler.rs b/crates/bili_sync/src/api/handler.rs index b49a60f..0fc2df6 100644 --- a/crates/bili_sync/src/api/handler.rs +++ b/crates/bili_sync/src/api/handler.rs @@ -34,6 +34,8 @@ use crate::api::response::{ }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission}; +use crate::config::{Config, VersionedConfig}; +use crate::task::DOWNLOADER_TASK_RUNNING; use crate::utils::status::{PageStatus, VideoStatus}; #[derive(OpenApi)] @@ -66,6 +68,8 @@ pub fn api_router() -> Router { .route("/api/me/favorites", get(get_created_favorites)) .route("/api/me/collections", get(get_followed_collections)) .route("/api/me/uppers", get(get_followed_uppers)) + .route("/api/config", get(get_config)) + .route("/api/config", put(update_config)) .route("/image-proxy", get(image_proxy)) } @@ -783,6 +787,39 @@ pub async fn update_video_source( Ok(ApiResponse::ok(true)) } +#[utoipa::path( + get, + path = "/api/config", + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn get_config() -> Result>, ApiError> { + Ok(ApiResponse::ok(VersionedConfig::get().load_full())) +} + +#[utoipa::path( + put, + path = "/api/config", + request_body = Config, + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn update_config( + Extension(db): Extension>, + ValidatedJson(config): ValidatedJson, +) -> Result>, ApiError> { + let Ok(_lock) = DOWNLOADER_TASK_RUNNING.try_lock() else { + // 简单避免一下可能的不一致现象 + return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into()); + }; + config.check()?; + let new_config = VersionedConfig::get().update(config, db.as_ref()).await?; + drop(_lock); + Ok(ApiResponse::ok(new_config)) +} + /// B 站的图片会检查 referer,需要做个转发伪造一下,否则直接返回 403 pub async fn image_proxy( Extension(bili_client): Extension>, diff --git a/crates/bili_sync/src/api/wrapper.rs b/crates/bili_sync/src/api/wrapper.rs index c64a146..74628ba 100644 --- a/crates/bili_sync/src/api/wrapper.rs +++ b/crates/bili_sync/src/api/wrapper.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use anyhow::Error; use axum::Json; use axum::extract::rejection::JsonRejection; @@ -14,28 +16,51 @@ use crate::api::error::InnerApiError; #[derive(ToSchema, Serialize)] pub struct ApiResponse { status_code: u16, - data: T, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option>, } impl ApiResponse { pub fn ok(data: T) -> Self { - Self { status_code: 200, data } + Self { + status_code: 200, + data: Some(data), + message: None, + } } - pub fn bad_request(data: T) -> Self { - Self { status_code: 400, data } + pub fn bad_request(message: impl Into>) -> Self { + Self { + status_code: 400, + data: None, + message: Some(message.into()), + } } - pub fn unauthorized(data: T) -> Self { - Self { status_code: 401, data } + pub fn unauthorized(message: impl Into>) -> Self { + Self { + status_code: 401, + data: None, + message: Some(message.into()), + } } - pub fn not_found(data: T) -> Self { - Self { status_code: 404, data } + pub fn not_found(message: impl Into>) -> Self { + Self { + status_code: 404, + data: None, + message: Some(message.into()), + } } - pub fn internal_server_error(data: T) -> Self { - Self { status_code: 500, data } + pub fn internal_server_error(message: impl Into>) -> Self { + Self { + status_code: 500, + data: None, + message: Some(message.into()), + } } } @@ -64,13 +89,13 @@ impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { if let Some(inner_error) = self.0.downcast_ref::() { match inner_error { - InnerApiError::NotFound(_) => return ApiResponse::not_found(self.0.to_string()).into_response(), + InnerApiError::NotFound(_) => return ApiResponse::<()>::not_found(self.0.to_string()).into_response(), InnerApiError::BadRequest(_) => { - return ApiResponse::bad_request(self.0.to_string()).into_response(); + return ApiResponse::<()>::bad_request(self.0.to_string()).into_response(); } } } - ApiResponse::internal_server_error(self.0.to_string()).into_response() + ApiResponse::<()>::internal_server_error(self.0.to_string()).into_response() } } diff --git a/crates/bili_sync/src/bilibili/analyzer.rs b/crates/bili_sync/src/bilibili/analyzer.rs index 9976fbe..79f24be 100644 --- a/crates/bili_sync/src/bilibili/analyzer.rs +++ b/crates/bili_sync/src/bilibili/analyzer.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::bilibili::error::BiliError; use crate::config::VersionedConfig; @@ -8,7 +9,7 @@ pub struct PageAnalyzer { info: serde_json::Value, } -#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema, Clone)] pub enum VideoQuality { Quality360p = 16, Quality480p = 32, @@ -22,7 +23,7 @@ pub enum VideoQuality { Quality8k = 127, } -#[derive(Debug, Clone, Copy, strum::FromRepr, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, strum::FromRepr, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum AudioQuality { Quality64k = 30216, Quality132k = 30232, @@ -54,7 +55,18 @@ impl AudioQuality { } #[allow(clippy::upper_case_acronyms)] -#[derive(Debug, strum::EnumString, strum::Display, strum::AsRefStr, PartialEq, PartialOrd, Serialize, Deserialize)] +#[derive( + Debug, + strum::EnumString, + strum::Display, + strum::AsRefStr, + PartialEq, + PartialOrd, + Serialize, + Deserialize, + ToSchema, + Clone, +)] pub enum VideoCodecs { #[strum(serialize = "hev")] HEV, @@ -79,7 +91,7 @@ impl TryFrom for VideoCodecs { } // 视频流的筛选偏好 -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema, Clone)] pub struct FilterOption { pub video_max_quality: VideoQuality, pub video_min_quality: VideoQuality, diff --git a/crates/bili_sync/src/bilibili/client.rs b/crates/bili_sync/src/bilibili/client.rs index 4eae4e3..c204284 100644 --- a/crates/bili_sync/src/bilibili/client.rs +++ b/crates/bili_sync/src/bilibili/client.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use std::time::Duration; use anyhow::Result; @@ -93,25 +92,25 @@ impl BiliClient { if let Some(limiter) = self.limiter.load().as_ref() { limiter.acquire_one().await; } - let credential = VersionedConfig::get().load().credential.load(); - self.client.request(method, url, Some(credential.as_ref())) + let credential = &VersionedConfig::get().load().credential; + self.client.request(method, url, Some(credential)) } pub async fn check_refresh(&self, connection: &DatabaseConnection) -> Result<()> { - let credential = VersionedConfig::get().load().credential.load(); + let credential = &VersionedConfig::get().load().credential; if !credential.need_refresh(&self.client).await? { return Ok(()); } let new_credential = credential.refresh(&self.client).await?; - let config = VersionedConfig::get().load(); - config.credential.store(Arc::new(new_credential)); - config.save_to_database(connection).await?; + VersionedConfig::get() + .update_credential(new_credential, connection) + .await?; Ok(()) } /// 获取 wbi img,用于生成请求签名 pub async fn wbi_img(&self) -> Result { - let credential = VersionedConfig::get().load().credential.load(); + let credential = &VersionedConfig::get().load().credential; credential.wbi_img(&self.client).await } } diff --git a/crates/bili_sync/src/bilibili/credential.rs b/crates/bili_sync/src/bilibili/credential.rs index 36d1f33..a363e05 100644 --- a/crates/bili_sync/src/bilibili/credential.rs +++ b/crates/bili_sync/src/bilibili/credential.rs @@ -10,6 +10,7 @@ use rsa::pkcs8::DecodePublicKey; use rsa::sha2::Sha256; use rsa::{Oaep, RsaPublicKey}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::bilibili::{Client, Validate}; @@ -19,7 +20,7 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [ 20, 34, 44, 52, ]; -#[derive(Default, Debug, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Credential { pub sessdata: String, pub bili_jct: String, diff --git a/crates/bili_sync/src/bilibili/danmaku/canvas/mod.rs b/crates/bili_sync/src/bilibili/danmaku/canvas/mod.rs index 9a73425..99f8743 100644 --- a/crates/bili_sync/src/bilibili/danmaku/canvas/mod.rs +++ b/crates/bili_sync/src/bilibili/danmaku/canvas/mod.rs @@ -4,13 +4,14 @@ mod lane; use anyhow::Result; use float_ord::FloatOrd; use lane::Lane; +use utoipa::ToSchema; use crate::bilibili::PageInfo; use crate::bilibili::danmaku::canvas::lane::Collision; use crate::bilibili::danmaku::danmu::DanmuType; use crate::bilibili::danmaku::{Danmu, DrawEffect, Drawable}; -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, ToSchema)] pub struct DanmakuOption { pub duration: f64, pub font: String, diff --git a/crates/bili_sync/src/bilibili/me.rs b/crates/bili_sync/src/bilibili/me.rs index fd3431c..8b9e87d 100644 --- a/crates/bili_sync/src/bilibili/me.rs +++ b/crates/bili_sync/src/bilibili/me.rs @@ -73,7 +73,7 @@ impl<'a> Me<'a> { } fn my_id() -> String { - VersionedConfig::get().load().credential.load().dedeuserid.clone() + VersionedConfig::get().load().credential.dedeuserid.clone() } } diff --git a/crates/bili_sync/src/config/current.rs b/crates/bili_sync/src/config/current.rs index 206fd63..0710118 100644 --- a/crates/bili_sync/src/config/current.rs +++ b/crates/bili_sync/src/config/current.rs @@ -2,9 +2,10 @@ use std::path::PathBuf; use std::sync::LazyLock; use anyhow::{Result, bail}; -use arc_swap::ArcSwap; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::Validate; use crate::bilibili::{Credential, DanmakuOption, FilterOption}; use crate::config::LegacyConfig; @@ -15,28 +16,23 @@ use crate::utils::model::{load_db_config, save_db_config}; pub static CONFIG_DIR: LazyLock = LazyLock::new(|| dirs::config_dir().expect("No config path found").join("bili-sync")); -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema, Validate, Clone)] pub struct Config { - #[serde(default = "default_auth_token")] pub auth_token: String, - #[serde(default = "default_bind_address")] pub bind_address: String, - pub credential: ArcSwap, + pub credential: Credential, pub filter_option: FilterOption, - #[serde(default)] pub danmaku_option: DanmakuOption, pub video_name: String, pub page_name: String, pub interval: u64, + #[schema(value_type = String)] pub upper_path: PathBuf, - #[serde(default)] pub nfo_time_type: NFOTimeType, - #[serde(default)] pub concurrent_limit: ConcurrentLimit, - #[serde(default = "default_time_format")] pub time_format: String, - #[serde(default)] pub cdn_sorting: bool, + pub version: u64, } impl Config { @@ -59,7 +55,7 @@ impl Config { if self.page_name.is_empty() { errors.push("未设置 page_name 模板"); } - let credential = self.credential.load(); + let credential = &self.credential; if credential.sessdata.is_empty() || credential.bili_jct.is_empty() || credential.buvid3.is_empty() @@ -97,7 +93,7 @@ impl Default for Config { Self { auth_token: default_auth_token(), bind_address: default_bind_address(), - credential: ArcSwap::from_pointee(Credential::default()), + credential: Credential::default(), filter_option: FilterOption::default(), danmaku_option: DanmakuOption::default(), video_name: "{{title}}".to_owned(), @@ -108,6 +104,7 @@ impl Default for Config { concurrent_limit: ConcurrentLimit::default(), time_format: default_time_format(), cdn_sorting: false, + version: 0, } } } @@ -128,6 +125,7 @@ impl From for Config { concurrent_limit: legacy.concurrent_limit, time_format: legacy.time_format, cdn_sorting: legacy.cdn_sorting, + version: 0, } } } diff --git a/crates/bili_sync/src/config/flag.rs b/crates/bili_sync/src/config/flag.rs deleted file mode 100644 index 7b987e5..0000000 --- a/crates/bili_sync/src/config/flag.rs +++ /dev/null @@ -1,3 +0,0 @@ -use std::sync::atomic::AtomicBool; - -pub static DOWNLOADER_RUNNING: AtomicBool = AtomicBool::new(false); diff --git a/crates/bili_sync/src/config/handlebar.rs b/crates/bili_sync/src/config/handlebar.rs index f9caafd..e091ac2 100644 --- a/crates/bili_sync/src/config/handlebar.rs +++ b/crates/bili_sync/src/config/handlebar.rs @@ -13,6 +13,7 @@ fn create_template(config: &Config) -> Result> { let mut handlebars = handlebars::Handlebars::new(); handlebars.register_helper("truncate", Box::new(truncate)); handlebars.path_safe_register("video", config.video_name.to_owned())?; + handlebars.path_safe_register("page", config.page_name.to_owned())?; Ok(handlebars) } diff --git a/crates/bili_sync/src/config/item.rs b/crates/bili_sync/src/config/item.rs index b0fa6fa..c7095ec 100644 --- a/crates/bili_sync/src/config/item.rs +++ b/crates/bili_sync/src/config/item.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::Result; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::utils::filenamify::filenamify; @@ -13,7 +14,7 @@ pub struct WatchLaterConfig { } /// NFO 文件使用的时间类型 -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, ToSchema, Clone)] #[serde(rename_all = "lowercase")] pub enum NFOTimeType { #[default] @@ -22,7 +23,7 @@ pub enum NFOTimeType { } /// 并发下载相关的配置 -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema, Clone)] pub struct ConcurrentLimit { pub video: usize, pub page: usize, @@ -31,7 +32,7 @@ pub struct ConcurrentLimit { pub download: ConcurrentDownloadLimit, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema, Clone)] pub struct ConcurrentDownloadLimit { pub enable: bool, pub concurrency: usize, @@ -48,7 +49,7 @@ impl Default for ConcurrentDownloadLimit { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema, Clone)] pub struct RateLimit { pub limit: usize, pub duration: u64, diff --git a/crates/bili_sync/src/config/legacy.rs b/crates/bili_sync/src/config/legacy.rs index 329e193..fdfd390 100644 --- a/crates/bili_sync/src/config/legacy.rs +++ b/crates/bili_sync/src/config/legacy.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::Result; -use arc_swap::ArcSwap; use sea_orm::DatabaseConnection; use serde::de::{Deserializer, MapAccess, Visitor}; use serde::ser::SerializeMap; @@ -20,7 +19,7 @@ pub struct LegacyConfig { pub auth_token: String, #[serde(default = "default_bind_address")] pub bind_address: String, - pub credential: ArcSwap, + pub credential: Credential, pub filter_option: FilterOption, #[serde(default)] pub danmaku_option: DanmakuOption, diff --git a/crates/bili_sync/src/config/mod.rs b/crates/bili_sync/src/config/mod.rs index e0cf5c9..40da1de 100644 --- a/crates/bili_sync/src/config/mod.rs +++ b/crates/bili_sync/src/config/mod.rs @@ -1,7 +1,6 @@ mod args; mod current; mod default; -mod flag; mod handlebar; mod item; mod legacy; @@ -10,7 +9,6 @@ mod versioned_config; pub use crate::config::args::{ARGS, version}; pub use crate::config::current::{CONFIG_DIR, Config}; -pub use crate::config::flag::DOWNLOADER_RUNNING; pub use crate::config::handlebar::TEMPLATE; pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit}; pub use crate::config::legacy::LegacyConfig; diff --git a/crates/bili_sync/src/config/versioned_cache.rs b/crates/bili_sync/src/config/versioned_cache.rs index ca9ca99..98ff22e 100644 --- a/crates/bili_sync/src/config/versioned_cache.rs +++ b/crates/bili_sync/src/config/versioned_cache.rs @@ -10,16 +10,19 @@ pub struct VersionedCache { inner: ArcSwap, version: AtomicU64, builder: fn(&Config) -> Result, + mutex: parking_lot::Mutex<()>, } impl VersionedCache { pub fn new(builder: fn(&Config) -> Result) -> Result { let current_config = VersionedConfig::get().load(); + let current_version = current_config.version; let initial_value = builder(¤t_config)?; Ok(Self { inner: ArcSwap::from_pointee(initial_value), - version: AtomicU64::new(0), + version: AtomicU64::new(current_version), builder, + mutex: parking_lot::Mutex::new(()), }) } @@ -28,21 +31,23 @@ impl VersionedCache { self.inner.load() } - #[allow(dead_code)] - pub fn load_full(&self) -> Arc { - self.reload_if_needed(); - self.inner.load_full() - } - fn reload_if_needed(&self) { - let current_version = VersionedConfig::get().version(); - let cached_version = self.version.load(Ordering::Acquire); - - if current_version != cached_version { - let current_config = VersionedConfig::get().load(); - if let Ok(new_value) = (self.builder)(¤t_config) { - self.inner.store(Arc::new(new_value)); - self.version.store(current_version, Ordering::Release); + let current_config = VersionedConfig::get().load(); + let current_version = current_config.version; + let version = self.version.load(Ordering::Relaxed); + if version < current_version { + let _lock = self.mutex.lock(); + if self.version.load(Ordering::Relaxed) >= current_version { + return; + } + match (self.builder)(¤t_config) { + Err(e) => { + error!("Failed to rebuild versioned cache: {:?}", e); + } + Ok(new_value) => { + self.inner.store(Arc::new(new_value)); + self.version.store(current_version, Ordering::Relaxed); + } } } } diff --git a/crates/bili_sync/src/config/versioned_config.rs b/crates/bili_sync/src/config/versioned_config.rs index 143784d..3305a60 100644 --- a/crates/bili_sync/src/config/versioned_config.rs +++ b/crates/bili_sync/src/config/versioned_config.rs @@ -1,24 +1,24 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{Result, anyhow, bail}; use arc_swap::{ArcSwap, Guard}; use sea_orm::DatabaseConnection; use tokio::sync::OnceCell; +use crate::bilibili::Credential; use crate::config::{CONFIG_DIR, Config, LegacyConfig}; pub static VERSIONED_CONFIG: OnceCell = OnceCell::const_new(); pub struct VersionedConfig { inner: ArcSwap, - version: AtomicU64, + update_lock: tokio::sync::Mutex<()>, } impl VersionedConfig { /// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误 pub async fn init(connection: &DatabaseConnection) -> Result<()> { - let config = match Config::load_from_database(connection).await? { + let mut config = match Config::load_from_database(connection).await? { Some(Ok(config)) => config, Some(Err(e)) => bail!("解析数据库配置失败: {}", e), None => { @@ -43,6 +43,8 @@ impl VersionedConfig { config } }; + // version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空 + config.version = 0; let versioned_config = VersionedConfig::new(config); VERSIONED_CONFIG .set(versioned_config) @@ -67,7 +69,7 @@ impl VersionedConfig { pub fn new(config: Config) -> Self { Self { inner: ArcSwap::from_pointee(config), - version: AtomicU64::new(1), + update_lock: tokio::sync::Mutex::new(()), } } @@ -79,13 +81,40 @@ impl VersionedConfig { self.inner.load_full() } - pub fn version(&self) -> u64 { - self.version.load(Ordering::Acquire) + pub async fn update_credential(&self, new_credential: Credential, connection: &DatabaseConnection) -> Result<()> { + // 确保更新内容与写入数据库的操作是原子性的 + let _lock = self.update_lock.lock().await; + loop { + let old_config = self.inner.load(); + let mut new_config = old_config.as_ref().clone(); + new_config.credential = new_credential.clone(); + new_config.version += 1; + if Arc::ptr_eq( + &old_config, + &self.inner.compare_and_swap(&old_config, Arc::new(new_config)), + ) { + break; + } + } + self.inner.load().save_to_database(connection).await } - #[allow(dead_code)] - pub fn update(&self, new_config: Config) { - self.inner.store(Arc::new(new_config)); - self.version.fetch_add(1, Ordering::AcqRel); + /// 外部 API 会调用这个方法,如果更新失败直接返回错误 + pub async fn update(&self, mut new_config: Config, connection: &DatabaseConnection) -> Result> { + let _lock = self.update_lock.lock().await; + let old_config = self.inner.load(); + if old_config.version != new_config.version { + bail!("配置版本不匹配,请刷新页面修改后重新提交"); + } + new_config.version += 1; + let new_config = Arc::new(new_config); + if !Arc::ptr_eq( + &old_config, + &self.inner.compare_and_swap(&old_config, new_config.clone()), + ) { + bail!("配置版本不匹配,请刷新页面修改后重新提交"); + } + new_config.save_to_database(connection).await?; + Ok(new_config) } } diff --git a/crates/bili_sync/src/task/mod.rs b/crates/bili_sync/src/task/mod.rs index db2d677..fb3a3d6 100644 --- a/crates/bili_sync/src/task/mod.rs +++ b/crates/bili_sync/src/task/mod.rs @@ -2,4 +2,4 @@ mod http_server; mod video_downloader; pub use http_server::http_server; -pub use video_downloader::video_downloader; +pub use video_downloader::{DOWNLOADER_TASK_RUNNING, video_downloader}; diff --git a/crates/bili_sync/src/task/video_downloader.rs b/crates/bili_sync/src/task/video_downloader.rs index 0e848de..4383b6b 100644 --- a/crates/bili_sync/src/task/video_downloader.rs +++ b/crates/bili_sync/src/task/video_downloader.rs @@ -4,16 +4,18 @@ use sea_orm::DatabaseConnection; use tokio::time; use crate::bilibili::{self, BiliClient}; -use crate::config::{DOWNLOADER_RUNNING, VersionedConfig}; +use crate::config::VersionedConfig; use crate::utils::model::get_enabled_video_sources; use crate::workflow::process_video_source; +pub static DOWNLOADER_TASK_RUNNING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// 启动周期下载视频的任务 pub async fn video_downloader(connection: Arc, bili_client: Arc) { let mut anchor = chrono::Local::now().date_naive(); loop { info!("开始执行本轮视频下载任务.."); - DOWNLOADER_RUNNING.store(true, std::sync::atomic::Ordering::Relaxed); + let _lock = DOWNLOADER_TASK_RUNNING.lock().await; let config = VersionedConfig::get().load_full(); 'inner: { if let Err(e) = config.check() { @@ -53,7 +55,7 @@ pub async fn video_downloader(connection: Arc, bili_client: } info!("本轮任务执行完毕,等待下一轮执行"); } - DOWNLOADER_RUNNING.store(false, std::sync::atomic::Ordering::Relaxed); + drop(_lock); time::sleep(time::Duration::from_secs(config.interval)).await; } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index afdb90b..eb49263 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -16,7 +16,8 @@ import type { UpsertCollectionRequest, UpsertSubmissionRequest, VideoSourcesDetailsResponse, - UpdateVideoSourceRequest + UpdateVideoSourceRequest, + Config } from './types'; // API 基础配置 @@ -49,43 +50,21 @@ class ApiClient { } } - // 通用请求方法 - private async request(endpoint: string, options: RequestInit = {}): Promise> { - const url = `${this.baseURL}${endpoint}`; - - const config: RequestInit = { - headers: { - ...this.defaultHeaders, - ...options.headers - }, - ...options - }; - - try { - const response = await fetch(url, config); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: ApiResponse = await response.json(); - return data; - } catch (error) { - const apiError: ApiError = { - message: error instanceof Error ? error.message : 'Unknown error occurred', - status: error instanceof TypeError ? undefined : (error as { status?: number }).status - }; - throw apiError; - } + // 清除认证 token + clearAuthToken() { + delete this.defaultHeaders['Authorization']; + localStorage.removeItem('authToken'); } - // GET 请求 - private async get( - endpoint: string, - params?: VideosRequest | Record + // 通用请求方法 + private async request( + url: string, + method: string = 'GET', + body?: unknown, + params?: Record ): Promise> { - let queryString = ''; - + // 构建完整的 URL + let fullUrl = `${this.baseURL}${url}`; if (params) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { @@ -93,83 +72,86 @@ class ApiClient { searchParams.append(key, String(value)); } }); - queryString = searchParams.toString(); + const queryString = searchParams.toString(); + if (queryString) { + fullUrl += `?${queryString}`; + } } - const finalEndpoint = queryString ? `${endpoint}?${queryString}` : endpoint; - return this.request(finalEndpoint, { - method: 'GET' - }); + const config: RequestInit = { + method, + headers: this.defaultHeaders + }; + + if (body && method !== 'GET') { + config.body = JSON.stringify(body); + } + + try { + const response = await fetch(fullUrl, config); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.message || errorJson.error || '请求失败'; + } catch { + errorMessage = errorText || `HTTP ${response.status}: ${response.statusText}`; + } + throw { + message: errorMessage, + status: response.status + } as ApiError; + } + + return await response.json(); + } catch (error) { + if (error && typeof error === 'object' && 'status' in error) { + throw error; + } + throw { + message: error instanceof Error ? error.message : '网络请求失败', + status: 0 + } as ApiError; + } + } + + // GET 请求 + private async get(url: string, params?: Record): Promise> { + return this.request(url, 'GET', undefined, params); } // POST 请求 - private async post(endpoint: string, data?: unknown): Promise> { - return this.request(endpoint, { - method: 'POST', - body: data ? JSON.stringify(data) : undefined - }); + private async post(url: string, data?: unknown): Promise> { + return this.request(url, 'POST', data); } // PUT 请求 - private async put(endpoint: string, data?: unknown): Promise> { - return this.request(endpoint, { - method: 'PUT', - body: data ? JSON.stringify(data) : undefined - }); + private async put(url: string, data?: unknown): Promise> { + return this.request(url, 'PUT', data); } - // DELETE 请求 - private async delete(endpoint: string): Promise> { - return this.request(endpoint, { - method: 'DELETE' - }); - } - - // API 方法 - - /** - * 获取所有视频来源 - */ async getVideoSources(): Promise> { return this.get('/video-sources'); } - /** - * 获取视频列表 - * @param params 查询参数 - */ async getVideos(params?: VideosRequest): Promise> { - return this.get('/videos', params); + return this.get('/videos', params as Record); } - /** - * 获取单个视频详情 - * @param id 视频 ID - */ async getVideo(id: number): Promise> { return this.get(`/videos/${id}`); } - /** - * 重置视频下载状态 - * @param id 视频 ID - */ async resetVideo(id: number): Promise> { return this.post(`/videos/${id}/reset`); } - /** - * 重置所有视频下载状态 - */ async resetAllVideos(): Promise> { return this.post('/videos/reset-all'); } - /** - * 重置视频状态位 - * @param id 视频 ID - * @param request 重置请求参数 - */ async updateVideoStatus( id: number, request: UpdateVideoStatusRequest @@ -177,17 +159,10 @@ class ApiClient { return this.post(`/videos/${id}/update-status`, request); } - /** - * 获取我的收藏夹 - */ async getCreatedFavorites(): Promise> { return this.get('/me/favorites'); } - /** - * 获取关注的合集 - * @param page 页码 - */ async getFollowedCollections( pageNum?: number, pageSize?: number @@ -196,13 +171,9 @@ class ApiClient { page_num: pageNum, page_size: pageSize }; - return this.get('/me/collections', params); + return this.get('/me/collections', params as Record); } - /** - * 获取关注的UP主 - * @param page 页码 - */ async getFollowedUppers( pageNum?: number, pageSize?: number @@ -211,46 +182,25 @@ class ApiClient { page_num: pageNum, page_size: pageSize }; - return this.get('/me/uppers', params); + return this.get('/me/uppers', params as Record); } - /** - * 订阅收藏夹 - * @param request 订阅请求参数 - */ async upsertFavorite(request: UpsertFavoriteRequest): Promise> { return this.post('/video-sources/favorites', request); } - /** - * 订阅合集 - * @param request 订阅请求参数 - */ async upsertCollection(request: UpsertCollectionRequest): Promise> { return this.post('/video-sources/collections', request); } - /** - * 订阅UP主投稿 - * @param request 订阅请求参数 - */ async upsertSubmission(request: UpsertSubmissionRequest): Promise> { return this.post('/video-sources/submissions', request); } - /** - * 获取所有视频源的详细信息 - */ async getVideoSourcesDetails(): Promise> { return this.get('/video-sources/details'); } - /** - * 更新视频源 - * @param type 视频源类型 - * @param id 视频源 ID - * @param request 更新请求 - */ async updateVideoSource( type: string, id: number, @@ -258,92 +208,43 @@ class ApiClient { ): Promise> { return this.put(`/video-sources/${type}/${id}`, request); } + + async getConfig(): Promise> { + return this.get('/config'); + } + + async updateConfig(config: Config): Promise> { + return this.put('/config', config); + } } // 创建默认的 API 客户端实例 export const apiClient = new ApiClient(); // 导出 API 方法的便捷函数 -export const api = { - /** - * 获取所有视频来源 - */ +const api = { getVideoSources: () => apiClient.getVideoSources(), - - /** - * 获取视频列表 - */ getVideos: (params?: VideosRequest) => apiClient.getVideos(params), - - /** - * 获取单个视频详情 - */ getVideo: (id: number) => apiClient.getVideo(id), - - /** - * 重置视频下载状态 - */ resetVideo: (id: number) => apiClient.resetVideo(id), - - /** - * 重置所有视频下载状态 - */ resetAllVideos: () => apiClient.resetAllVideos(), - - /** - * 重置视频状态位 - */ updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) => apiClient.updateVideoStatus(id, request), - - /** - * 获取我的收藏夹 - */ getCreatedFavorites: () => apiClient.getCreatedFavorites(), - - /** - * 获取关注的合集 - */ getFollowedCollections: (pageNum?: number, pageSize?: number) => apiClient.getFollowedCollections(pageNum, pageSize), - - /** - * 获取关注的UP主 - */ getFollowedUppers: (pageNum?: number, pageSize?: number) => apiClient.getFollowedUppers(pageNum, pageSize), - - /** - * 订阅收藏夹 - */ upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request), - - /** - * 订阅合集 - */ upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request), - - /** - * 订阅UP主投稿 - */ upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request), - - /** - * 获取所有视频源的详细信息 - */ getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(), - - /** - * 更新视频源 - */ updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) => apiClient.updateVideoSource(type, id, request), - - /** - * 设置认证 token - */ - setAuthToken: (token: string) => apiClient.setAuthToken(token) + getConfig: () => apiClient.getConfig(), + updateConfig: (config: Config) => apiClient.updateConfig(config), + setAuthToken: (token: string) => apiClient.setAuthToken(token), + clearAuthToken: () => apiClient.clearAuthToken() }; -// 默认导出 export default api; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 65534eb..2459a7e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -153,6 +153,7 @@ export interface UpsertFavoriteRequest { export interface UpsertCollectionRequest { sid: number; mid: number; + collection_type?: number; path: string; } @@ -182,3 +183,61 @@ export interface UpdateVideoSourceRequest { path: string; enabled: boolean; } + +// 配置相关类型 +export interface Credential { + sessdata: string; + bili_jct: string; + buvid3: string; + dedeuserid: string; + ac_time_value: string; +} + +export interface FilterOption { + video_max_quality: string; + video_min_quality: string; + audio_max_quality: string; + audio_min_quality: string; + codecs: string[]; + no_dolby_video: boolean; + no_dolby_audio: boolean; + no_hdr: boolean; + no_hires: boolean; +} + +export interface DanmakuOption { + duration: number; + font: string; + font_size: number; + width_ratio: number; + horizontal_gap: number; + lane_size: number; + float_percentage: number; + bottom_percentage: number; + opacity: number; + bold: boolean; + outline: number; + time_offset: number; +} + +export interface ConcurrentLimit { + video: number; + page: number; +} + +export interface Config { + auth_token: string; + bind_address: string; + credential: Credential; + filter_option: FilterOption; + danmaku_option: DanmakuOption; + video_name: string; + page_name: string; + interval: number; + upper_path: string; + nfo_time_type: string; + concurrent_limit: ConcurrentLimit; + time_format: string; + cdn_sorting: boolean; + version: number; +} diff --git a/web/src/routes/me/collections/+page.svelte b/web/src/routes/me/collections/+page.svelte index cf1ec02..c5737a3 100644 --- a/web/src/routes/me/collections/+page.svelte +++ b/web/src/routes/me/collections/+page.svelte @@ -67,10 +67,6 @@
-
-

关注的合集

-

管理您在B站关注的合集订阅

-
{#if !loading} 共 {totalCount} 个合集 diff --git a/web/src/routes/me/favorites/+page.svelte b/web/src/routes/me/favorites/+page.svelte index e321b16..7c0f617 100644 --- a/web/src/routes/me/favorites/+page.svelte +++ b/web/src/routes/me/favorites/+page.svelte @@ -52,10 +52,6 @@
-
-

我的收藏夹

-

管理您在B站创建的收藏夹订阅

-
{#if !loading} 共 {favorites.length} 个收藏夹 diff --git a/web/src/routes/me/uppers/+page.svelte b/web/src/routes/me/uppers/+page.svelte index 58c6d38..1df1e99 100644 --- a/web/src/routes/me/uppers/+page.svelte +++ b/web/src/routes/me/uppers/+page.svelte @@ -65,10 +65,6 @@
-
-

关注的UP主

-

管理您在B站关注的UP主投稿订阅

-
{#if !loading} 共 {totalCount} 个UP主 diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index f1f6602..08f41ca 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -3,28 +3,72 @@ import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; + import { Switch } from '$lib/components/ui/switch/index.js'; + import * as Tabs from '$lib/components/ui/tabs/index.js'; + import { Separator } from '$lib/components/ui/separator/index.js'; + import { Badge } from '$lib/components/ui/badge/index.js'; import api from '$lib/api'; import { toast } from 'svelte-sonner'; import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { goto } from '$app/navigation'; import { appStateStore, ToQuery } from '$lib/stores/filter'; + import type { Config, ApiError } from '$lib/types'; - let apiToken = ''; + let frontendToken = ''; // 前端认证token + let config: Config | null = null; + let formData: Config | null = null; let saving = false; + let loading = false; - async function saveApiToken() { - if (!apiToken.trim()) { - toast.error('请输入有效的API Token'); + async function loadConfig() { + loading = true; + try { + const response = await api.getConfig(); + config = response.data; + formData = { ...config }; + } catch (error) { + console.error('加载配置失败:', error); + toast.error('加载配置失败', { + description: (error as ApiError).message + }); + } finally { + loading = false; + } + } + + async function authenticateFrontend() { + if (!frontendToken.trim()) { + toast.error('请输入前端认证Token'); return; } + try { + api.setAuthToken(frontendToken.trim()); + localStorage.setItem('authToken', frontendToken.trim()); + toast.success('前端认证成功'); + loadConfig(); // 认证成功后加载配置 + } catch (error) { + console.error('前端认证失败:', error); + toast.error('认证失败,请检查Token是否正确'); + } + } + + async function saveConfig() { + if (!formData) { + toast.error('配置未加载'); + return; + } saving = true; try { - api.setAuthToken(apiToken.trim()); - toast.success('API Token 已保存'); + let resp = await api.updateConfig(formData); + formData = resp.data; + config = { ...formData }; + toast.success('配置已保存'); } catch (error) { - console.error('保存API Token失败:', error); - toast.error('保存失败,请重试'); + console.error('保存配置失败:', error); + toast.error('保存配置失败', { + description: (error as ApiError).message + }); } finally { saving = false; } @@ -40,10 +84,14 @@ }, { label: '设置', isActive: true } ]); + const savedToken = localStorage.getItem('authToken'); if (savedToken) { - apiToken = savedToken; + frontendToken = savedToken; + api.setAuthToken(savedToken); } + + loadConfig(); }); @@ -51,31 +99,570 @@ 设置 - Bili Sync -
-
- -
-
-
- -

用于身份验证的API令牌

+
+ +
+
+
+

前端认证状态

+

+ {formData ? '已认证 - 可以正常加载数据' : '未认证 - 请输入 Token 进行鉴权'} +

+
+ {#if !formData} +
+ +
-
-
- -

请确保令牌的安全性,不要与他人分享

+ {:else} +
+
+
+ 已认证
-
-
+ {/if}
+ + + {#if loading} +
+
+
+

加载配置中...

+
+
+ {:else if formData} +
+ + + 基本设置 + B站认证 + 视频质量 + 弹幕渲染 + 高级设置 + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +

+ 修改此Token后,前端需要使用新Token重新认证才能访问API +

+
+
+ + + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ +

排在前面的编码格式优先级更高

+
+ {#each formData.filter_option.codecs as codec, index (index)} +
+ {index + 1} + {codec} +
+ + + +
+
+ {/each} + + {#if formData.filter_option.codecs.length < 3} +
+ +
+ {#each ['AV1', 'HEV', 'AVC'] as codec (codec)} + {#if !formData.filter_option.codecs.includes(codec)} + + {/if} + {/each} +
+
+ {/if} +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +

+ 用于保护后端API的认证令牌,修改后需要重新进行前端认证 +

+
+
+
+
+ +
+ +
+
+ {:else} +
+
+

请先进行前端认证以加载配置

+ +
+
+ {/if}