mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-05 15:39:07 +08:00
feat: 支持前端编辑、提交 Config (#370)
This commit is contained in:
Generated
+19
-9
@@ -500,6 +500,7 @@ dependencies = [
|
|||||||
"md5",
|
"md5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
"prost",
|
"prost",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand",
|
"rand",
|
||||||
@@ -1934,9 +1935,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.11"
|
version = "0.4.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
|
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
@@ -2198,9 +2199,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.1"
|
version = "0.12.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
|
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
@@ -2208,15 +2209,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot_core"
|
name = "parking_lot_core"
|
||||||
version = "0.9.9"
|
version = "0.9.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
|
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall 0.5.13",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-targets 0.48.5",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2621,6 +2622,15 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"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]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4282,7 +4292,7 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9"
|
checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"redox_syscall",
|
"redox_syscall 0.4.1",
|
||||||
"wasite",
|
"wasite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ leaky-bucket = "1.1.2"
|
|||||||
md5 = "0.7.0"
|
md5 = "0.7.0"
|
||||||
memchr = "2.7.4"
|
memchr = "2.7.4"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
|
parking_lot = "0.12.4"
|
||||||
prost = "0.13.5"
|
prost = "0.13.5"
|
||||||
quick-xml = { version = "0.37.5", features = ["async-tokio"] }
|
quick-xml = { version = "0.37.5", features = ["async-tokio"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ leaky-bucket = { workspace = true }
|
|||||||
md5 = { workspace = true }
|
md5 = { workspace = true }
|
||||||
memchr = { workspace = true }
|
memchr = { workspace = true }
|
||||||
once_cell = { workspace = true }
|
once_cell = { workspace = true }
|
||||||
|
parking_lot = { workspace = true }
|
||||||
prost = { workspace = true }
|
prost = { workspace = true }
|
||||||
quick-xml = { workspace = true }
|
quick-xml = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result<Re
|
|||||||
if request.uri().path().starts_with("/api/")
|
if request.uri().path().starts_with("/api/")
|
||||||
&& get_token(&headers).is_none_or(|token| token != VersionedConfig::get().load().auth_token)
|
&& get_token(&headers).is_none_or(|token| token != VersionedConfig::get().load().auth_token)
|
||||||
{
|
{
|
||||||
return Ok(ApiResponse::unauthorized(()).into_response());
|
return Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response());
|
||||||
}
|
}
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ use crate::api::response::{
|
|||||||
};
|
};
|
||||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission};
|
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};
|
use crate::utils::status::{PageStatus, VideoStatus};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
@@ -66,6 +68,8 @@ pub fn api_router() -> Router {
|
|||||||
.route("/api/me/favorites", get(get_created_favorites))
|
.route("/api/me/favorites", get(get_created_favorites))
|
||||||
.route("/api/me/collections", get(get_followed_collections))
|
.route("/api/me/collections", get(get_followed_collections))
|
||||||
.route("/api/me/uppers", get(get_followed_uppers))
|
.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))
|
.route("/image-proxy", get(image_proxy))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +787,39 @@ pub async fn update_video_source(
|
|||||||
Ok(ApiResponse::ok(true))
|
Ok(ApiResponse::ok(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/config",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ApiResponse<Config>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||||
|
Ok(ApiResponse::ok(VersionedConfig::get().load_full()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/config",
|
||||||
|
request_body = Config,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ApiResponse<Config>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn update_config(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
ValidatedJson(config): ValidatedJson<Config>,
|
||||||
|
) -> Result<ApiResponse<Arc<Config>>, 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
|
/// B 站的图片会检查 referer,需要做个转发伪造一下,否则直接返回 403
|
||||||
pub async fn image_proxy(
|
pub async fn image_proxy(
|
||||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::rejection::JsonRejection;
|
use axum::extract::rejection::JsonRejection;
|
||||||
@@ -14,28 +16,51 @@ use crate::api::error::InnerApiError;
|
|||||||
#[derive(ToSchema, Serialize)]
|
#[derive(ToSchema, Serialize)]
|
||||||
pub struct ApiResponse<T: Serialize> {
|
pub struct ApiResponse<T: Serialize> {
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
data: T,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
data: Option<T>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
message: Option<Cow<'static, str>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Serialize> ApiResponse<T> {
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
pub fn ok(data: T) -> Self {
|
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 {
|
pub fn bad_request(message: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { status_code: 400, data }
|
Self {
|
||||||
|
status_code: 400,
|
||||||
|
data: None,
|
||||||
|
message: Some(message.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unauthorized(data: T) -> Self {
|
pub fn unauthorized(message: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { status_code: 401, data }
|
Self {
|
||||||
|
status_code: 401,
|
||||||
|
data: None,
|
||||||
|
message: Some(message.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn not_found(data: T) -> Self {
|
pub fn not_found(message: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { status_code: 404, data }
|
Self {
|
||||||
|
status_code: 404,
|
||||||
|
data: None,
|
||||||
|
message: Some(message.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal_server_error(data: T) -> Self {
|
pub fn internal_server_error(message: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { status_code: 500, data }
|
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 {
|
fn into_response(self) -> axum::response::Response {
|
||||||
if let Some(inner_error) = self.0.downcast_ref::<InnerApiError>() {
|
if let Some(inner_error) = self.0.downcast_ref::<InnerApiError>() {
|
||||||
match inner_error {
|
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(_) => {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::bilibili::error::BiliError;
|
use crate::bilibili::error::BiliError;
|
||||||
use crate::config::VersionedConfig;
|
use crate::config::VersionedConfig;
|
||||||
@@ -8,7 +9,7 @@ pub struct PageAnalyzer {
|
|||||||
info: serde_json::Value,
|
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 {
|
pub enum VideoQuality {
|
||||||
Quality360p = 16,
|
Quality360p = 16,
|
||||||
Quality480p = 32,
|
Quality480p = 32,
|
||||||
@@ -22,7 +23,7 @@ pub enum VideoQuality {
|
|||||||
Quality8k = 127,
|
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 {
|
pub enum AudioQuality {
|
||||||
Quality64k = 30216,
|
Quality64k = 30216,
|
||||||
Quality132k = 30232,
|
Quality132k = 30232,
|
||||||
@@ -54,7 +55,18 @@ impl AudioQuality {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::upper_case_acronyms)]
|
#[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 {
|
pub enum VideoCodecs {
|
||||||
#[strum(serialize = "hev")]
|
#[strum(serialize = "hev")]
|
||||||
HEV,
|
HEV,
|
||||||
@@ -79,7 +91,7 @@ impl TryFrom<u64> for VideoCodecs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 视频流的筛选偏好
|
// 视频流的筛选偏好
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||||
pub struct FilterOption {
|
pub struct FilterOption {
|
||||||
pub video_max_quality: VideoQuality,
|
pub video_max_quality: VideoQuality,
|
||||||
pub video_min_quality: VideoQuality,
|
pub video_min_quality: VideoQuality,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -93,25 +92,25 @@ impl BiliClient {
|
|||||||
if let Some(limiter) = self.limiter.load().as_ref() {
|
if let Some(limiter) = self.limiter.load().as_ref() {
|
||||||
limiter.acquire_one().await;
|
limiter.acquire_one().await;
|
||||||
}
|
}
|
||||||
let credential = VersionedConfig::get().load().credential.load();
|
let credential = &VersionedConfig::get().load().credential;
|
||||||
self.client.request(method, url, Some(credential.as_ref()))
|
self.client.request(method, url, Some(credential))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_refresh(&self, connection: &DatabaseConnection) -> Result<()> {
|
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? {
|
if !credential.need_refresh(&self.client).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let new_credential = credential.refresh(&self.client).await?;
|
let new_credential = credential.refresh(&self.client).await?;
|
||||||
let config = VersionedConfig::get().load();
|
VersionedConfig::get()
|
||||||
config.credential.store(Arc::new(new_credential));
|
.update_credential(new_credential, connection)
|
||||||
config.save_to_database(connection).await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 wbi img,用于生成请求签名
|
/// 获取 wbi img,用于生成请求签名
|
||||||
pub async fn wbi_img(&self) -> Result<WbiImg> {
|
pub async fn wbi_img(&self) -> Result<WbiImg> {
|
||||||
let credential = VersionedConfig::get().load().credential.load();
|
let credential = &VersionedConfig::get().load().credential;
|
||||||
credential.wbi_img(&self.client).await
|
credential.wbi_img(&self.client).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use rsa::pkcs8::DecodePublicKey;
|
|||||||
use rsa::sha2::Sha256;
|
use rsa::sha2::Sha256;
|
||||||
use rsa::{Oaep, RsaPublicKey};
|
use rsa::{Oaep, RsaPublicKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::bilibili::{Client, Validate};
|
use crate::bilibili::{Client, Validate};
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
|||||||
20, 34, 44, 52,
|
20, 34, 44, 52,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct Credential {
|
pub struct Credential {
|
||||||
pub sessdata: String,
|
pub sessdata: String,
|
||||||
pub bili_jct: String,
|
pub bili_jct: String,
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ mod lane;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use float_ord::FloatOrd;
|
use float_ord::FloatOrd;
|
||||||
use lane::Lane;
|
use lane::Lane;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::bilibili::PageInfo;
|
use crate::bilibili::PageInfo;
|
||||||
use crate::bilibili::danmaku::canvas::lane::Collision;
|
use crate::bilibili::danmaku::canvas::lane::Collision;
|
||||||
use crate::bilibili::danmaku::danmu::DanmuType;
|
use crate::bilibili::danmaku::danmu::DanmuType;
|
||||||
use crate::bilibili::danmaku::{Danmu, DrawEffect, Drawable};
|
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 struct DanmakuOption {
|
||||||
pub duration: f64,
|
pub duration: f64,
|
||||||
pub font: String,
|
pub font: String,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ impl<'a> Me<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn my_id() -> String {
|
fn my_id() -> String {
|
||||||
VersionedConfig::get().load().credential.load().dedeuserid.clone()
|
VersionedConfig::get().load().credential.dedeuserid.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ use std::path::PathBuf;
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use arc_swap::ArcSwap;
|
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||||
use crate::config::LegacyConfig;
|
use crate::config::LegacyConfig;
|
||||||
@@ -15,28 +16,23 @@ use crate::utils::model::{load_db_config, save_db_config};
|
|||||||
pub static CONFIG_DIR: LazyLock<PathBuf> =
|
pub static CONFIG_DIR: LazyLock<PathBuf> =
|
||||||
LazyLock::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
|
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 {
|
pub struct Config {
|
||||||
#[serde(default = "default_auth_token")]
|
|
||||||
pub auth_token: String,
|
pub auth_token: String,
|
||||||
#[serde(default = "default_bind_address")]
|
|
||||||
pub bind_address: String,
|
pub bind_address: String,
|
||||||
pub credential: ArcSwap<Credential>,
|
pub credential: Credential,
|
||||||
pub filter_option: FilterOption,
|
pub filter_option: FilterOption,
|
||||||
#[serde(default)]
|
|
||||||
pub danmaku_option: DanmakuOption,
|
pub danmaku_option: DanmakuOption,
|
||||||
pub video_name: String,
|
pub video_name: String,
|
||||||
pub page_name: String,
|
pub page_name: String,
|
||||||
pub interval: u64,
|
pub interval: u64,
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub upper_path: PathBuf,
|
pub upper_path: PathBuf,
|
||||||
#[serde(default)]
|
|
||||||
pub nfo_time_type: NFOTimeType,
|
pub nfo_time_type: NFOTimeType,
|
||||||
#[serde(default)]
|
|
||||||
pub concurrent_limit: ConcurrentLimit,
|
pub concurrent_limit: ConcurrentLimit,
|
||||||
#[serde(default = "default_time_format")]
|
|
||||||
pub time_format: String,
|
pub time_format: String,
|
||||||
#[serde(default)]
|
|
||||||
pub cdn_sorting: bool,
|
pub cdn_sorting: bool,
|
||||||
|
pub version: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -59,7 +55,7 @@ impl Config {
|
|||||||
if self.page_name.is_empty() {
|
if self.page_name.is_empty() {
|
||||||
errors.push("未设置 page_name 模板");
|
errors.push("未设置 page_name 模板");
|
||||||
}
|
}
|
||||||
let credential = self.credential.load();
|
let credential = &self.credential;
|
||||||
if credential.sessdata.is_empty()
|
if credential.sessdata.is_empty()
|
||||||
|| credential.bili_jct.is_empty()
|
|| credential.bili_jct.is_empty()
|
||||||
|| credential.buvid3.is_empty()
|
|| credential.buvid3.is_empty()
|
||||||
@@ -97,7 +93,7 @@ impl Default for Config {
|
|||||||
Self {
|
Self {
|
||||||
auth_token: default_auth_token(),
|
auth_token: default_auth_token(),
|
||||||
bind_address: default_bind_address(),
|
bind_address: default_bind_address(),
|
||||||
credential: ArcSwap::from_pointee(Credential::default()),
|
credential: Credential::default(),
|
||||||
filter_option: FilterOption::default(),
|
filter_option: FilterOption::default(),
|
||||||
danmaku_option: DanmakuOption::default(),
|
danmaku_option: DanmakuOption::default(),
|
||||||
video_name: "{{title}}".to_owned(),
|
video_name: "{{title}}".to_owned(),
|
||||||
@@ -108,6 +104,7 @@ impl Default for Config {
|
|||||||
concurrent_limit: ConcurrentLimit::default(),
|
concurrent_limit: ConcurrentLimit::default(),
|
||||||
time_format: default_time_format(),
|
time_format: default_time_format(),
|
||||||
cdn_sorting: false,
|
cdn_sorting: false,
|
||||||
|
version: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +125,7 @@ impl From<LegacyConfig> for Config {
|
|||||||
concurrent_limit: legacy.concurrent_limit,
|
concurrent_limit: legacy.concurrent_limit,
|
||||||
time_format: legacy.time_format,
|
time_format: legacy.time_format,
|
||||||
cdn_sorting: legacy.cdn_sorting,
|
cdn_sorting: legacy.cdn_sorting,
|
||||||
|
version: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
|
|
||||||
pub static DOWNLOADER_RUNNING: AtomicBool = AtomicBool::new(false);
|
|
||||||
@@ -13,6 +13,7 @@ fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
|
|||||||
let mut handlebars = handlebars::Handlebars::new();
|
let mut handlebars = handlebars::Handlebars::new();
|
||||||
handlebars.register_helper("truncate", Box::new(truncate));
|
handlebars.register_helper("truncate", Box::new(truncate));
|
||||||
handlebars.path_safe_register("video", config.video_name.to_owned())?;
|
handlebars.path_safe_register("video", config.video_name.to_owned())?;
|
||||||
|
handlebars.path_safe_register("page", config.page_name.to_owned())?;
|
||||||
Ok(handlebars)
|
Ok(handlebars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::utils::filenamify::filenamify;
|
use crate::utils::filenamify::filenamify;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ pub struct WatchLaterConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NFO 文件使用的时间类型
|
/// NFO 文件使用的时间类型
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default, ToSchema, Clone)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NFOTimeType {
|
pub enum NFOTimeType {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -22,7 +23,7 @@ pub enum NFOTimeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 并发下载相关的配置
|
/// 并发下载相关的配置
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||||
pub struct ConcurrentLimit {
|
pub struct ConcurrentLimit {
|
||||||
pub video: usize,
|
pub video: usize,
|
||||||
pub page: usize,
|
pub page: usize,
|
||||||
@@ -31,7 +32,7 @@ pub struct ConcurrentLimit {
|
|||||||
pub download: ConcurrentDownloadLimit,
|
pub download: ConcurrentDownloadLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||||
pub struct ConcurrentDownloadLimit {
|
pub struct ConcurrentDownloadLimit {
|
||||||
pub enable: bool,
|
pub enable: bool,
|
||||||
pub concurrency: usize,
|
pub concurrency: usize,
|
||||||
@@ -48,7 +49,7 @@ impl Default for ConcurrentDownloadLimit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||||
pub struct RateLimit {
|
pub struct RateLimit {
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
pub duration: u64,
|
pub duration: u64,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::ArcSwap;
|
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use serde::de::{Deserializer, MapAccess, Visitor};
|
use serde::de::{Deserializer, MapAccess, Visitor};
|
||||||
use serde::ser::SerializeMap;
|
use serde::ser::SerializeMap;
|
||||||
@@ -20,7 +19,7 @@ pub struct LegacyConfig {
|
|||||||
pub auth_token: String,
|
pub auth_token: String,
|
||||||
#[serde(default = "default_bind_address")]
|
#[serde(default = "default_bind_address")]
|
||||||
pub bind_address: String,
|
pub bind_address: String,
|
||||||
pub credential: ArcSwap<Credential>,
|
pub credential: Credential,
|
||||||
pub filter_option: FilterOption,
|
pub filter_option: FilterOption,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub danmaku_option: DanmakuOption,
|
pub danmaku_option: DanmakuOption,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
mod args;
|
mod args;
|
||||||
mod current;
|
mod current;
|
||||||
mod default;
|
mod default;
|
||||||
mod flag;
|
|
||||||
mod handlebar;
|
mod handlebar;
|
||||||
mod item;
|
mod item;
|
||||||
mod legacy;
|
mod legacy;
|
||||||
@@ -10,7 +9,6 @@ mod versioned_config;
|
|||||||
|
|
||||||
pub use crate::config::args::{ARGS, version};
|
pub use crate::config::args::{ARGS, version};
|
||||||
pub use crate::config::current::{CONFIG_DIR, Config};
|
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::handlebar::TEMPLATE;
|
||||||
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit};
|
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit};
|
||||||
pub use crate::config::legacy::LegacyConfig;
|
pub use crate::config::legacy::LegacyConfig;
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ pub struct VersionedCache<T> {
|
|||||||
inner: ArcSwap<T>,
|
inner: ArcSwap<T>,
|
||||||
version: AtomicU64,
|
version: AtomicU64,
|
||||||
builder: fn(&Config) -> Result<T>,
|
builder: fn(&Config) -> Result<T>,
|
||||||
|
mutex: parking_lot::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> VersionedCache<T> {
|
impl<T> VersionedCache<T> {
|
||||||
pub fn new(builder: fn(&Config) -> Result<T>) -> Result<Self> {
|
pub fn new(builder: fn(&Config) -> Result<T>) -> Result<Self> {
|
||||||
let current_config = VersionedConfig::get().load();
|
let current_config = VersionedConfig::get().load();
|
||||||
|
let current_version = current_config.version;
|
||||||
let initial_value = builder(¤t_config)?;
|
let initial_value = builder(¤t_config)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: ArcSwap::from_pointee(initial_value),
|
inner: ArcSwap::from_pointee(initial_value),
|
||||||
version: AtomicU64::new(0),
|
version: AtomicU64::new(current_version),
|
||||||
builder,
|
builder,
|
||||||
|
mutex: parking_lot::Mutex::new(()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,21 +31,23 @@ impl<T> VersionedCache<T> {
|
|||||||
self.inner.load()
|
self.inner.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn load_full(&self) -> Arc<T> {
|
|
||||||
self.reload_if_needed();
|
|
||||||
self.inner.load_full()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reload_if_needed(&self) {
|
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();
|
let current_config = VersionedConfig::get().load();
|
||||||
if let Ok(new_value) = (self.builder)(¤t_config) {
|
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.inner.store(Arc::new(new_value));
|
||||||
self.version.store(current_version, Ordering::Release);
|
self.version.store(current_version, Ordering::Relaxed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use arc_swap::{ArcSwap, Guard};
|
use arc_swap::{ArcSwap, Guard};
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use tokio::sync::OnceCell;
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
use crate::bilibili::Credential;
|
||||||
use crate::config::{CONFIG_DIR, Config, LegacyConfig};
|
use crate::config::{CONFIG_DIR, Config, LegacyConfig};
|
||||||
|
|
||||||
pub static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
|
pub static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
|
||||||
|
|
||||||
pub struct VersionedConfig {
|
pub struct VersionedConfig {
|
||||||
inner: ArcSwap<Config>,
|
inner: ArcSwap<Config>,
|
||||||
version: AtomicU64,
|
update_lock: tokio::sync::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VersionedConfig {
|
impl VersionedConfig {
|
||||||
/// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误
|
/// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误
|
||||||
pub async fn init(connection: &DatabaseConnection) -> Result<()> {
|
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(Ok(config)) => config,
|
||||||
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
|
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
|
||||||
None => {
|
None => {
|
||||||
@@ -43,6 +43,8 @@ impl VersionedConfig {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空
|
||||||
|
config.version = 0;
|
||||||
let versioned_config = VersionedConfig::new(config);
|
let versioned_config = VersionedConfig::new(config);
|
||||||
VERSIONED_CONFIG
|
VERSIONED_CONFIG
|
||||||
.set(versioned_config)
|
.set(versioned_config)
|
||||||
@@ -67,7 +69,7 @@ impl VersionedConfig {
|
|||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: ArcSwap::from_pointee(config),
|
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()
|
self.inner.load_full()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn version(&self) -> u64 {
|
pub async fn update_credential(&self, new_credential: Credential, connection: &DatabaseConnection) -> Result<()> {
|
||||||
self.version.load(Ordering::Acquire)
|
// 确保更新内容与写入数据库的操作是原子性的
|
||||||
|
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)]
|
/// 外部 API 会调用这个方法,如果更新失败直接返回错误
|
||||||
pub fn update(&self, new_config: Config) {
|
pub async fn update(&self, mut new_config: Config, connection: &DatabaseConnection) -> Result<Arc<Config>> {
|
||||||
self.inner.store(Arc::new(new_config));
|
let _lock = self.update_lock.lock().await;
|
||||||
self.version.fetch_add(1, Ordering::AcqRel);
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ mod http_server;
|
|||||||
mod video_downloader;
|
mod video_downloader;
|
||||||
|
|
||||||
pub use http_server::http_server;
|
pub use http_server::http_server;
|
||||||
pub use video_downloader::video_downloader;
|
pub use video_downloader::{DOWNLOADER_TASK_RUNNING, video_downloader};
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ use sea_orm::DatabaseConnection;
|
|||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
use crate::bilibili::{self, BiliClient};
|
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::utils::model::get_enabled_video_sources;
|
||||||
use crate::workflow::process_video_source;
|
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<DatabaseConnection>, bili_client: Arc<BiliClient>) {
|
pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) {
|
||||||
let mut anchor = chrono::Local::now().date_naive();
|
let mut anchor = chrono::Local::now().date_naive();
|
||||||
loop {
|
loop {
|
||||||
info!("开始执行本轮视频下载任务..");
|
info!("开始执行本轮视频下载任务..");
|
||||||
DOWNLOADER_RUNNING.store(true, std::sync::atomic::Ordering::Relaxed);
|
let _lock = DOWNLOADER_TASK_RUNNING.lock().await;
|
||||||
let config = VersionedConfig::get().load_full();
|
let config = VersionedConfig::get().load_full();
|
||||||
'inner: {
|
'inner: {
|
||||||
if let Err(e) = config.check() {
|
if let Err(e) = config.check() {
|
||||||
@@ -53,7 +55,7 @@ pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client:
|
|||||||
}
|
}
|
||||||
info!("本轮任务执行完毕,等待下一轮执行");
|
info!("本轮任务执行完毕,等待下一轮执行");
|
||||||
}
|
}
|
||||||
DOWNLOADER_RUNNING.store(false, std::sync::atomic::Ordering::Relaxed);
|
drop(_lock);
|
||||||
time::sleep(time::Duration::from_secs(config.interval)).await;
|
time::sleep(time::Duration::from_secs(config.interval)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-180
@@ -16,7 +16,8 @@ import type {
|
|||||||
UpsertCollectionRequest,
|
UpsertCollectionRequest,
|
||||||
UpsertSubmissionRequest,
|
UpsertSubmissionRequest,
|
||||||
VideoSourcesDetailsResponse,
|
VideoSourcesDetailsResponse,
|
||||||
UpdateVideoSourceRequest
|
UpdateVideoSourceRequest,
|
||||||
|
Config
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// API 基础配置
|
// API 基础配置
|
||||||
@@ -49,43 +50,21 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除认证 token
|
||||||
|
clearAuthToken() {
|
||||||
|
delete this.defaultHeaders['Authorization'];
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
}
|
||||||
|
|
||||||
// 通用请求方法
|
// 通用请求方法
|
||||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
|
private async request<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`;
|
url: string,
|
||||||
|
method: string = 'GET',
|
||||||
const config: RequestInit = {
|
body?: unknown,
|
||||||
headers: {
|
params?: Record<string, unknown>
|
||||||
...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<T> = 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET 请求
|
|
||||||
private async get<T>(
|
|
||||||
endpoint: string,
|
|
||||||
params?: VideosRequest | Record<string, unknown>
|
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
let queryString = '';
|
// 构建完整的 URL
|
||||||
|
let fullUrl = `${this.baseURL}${url}`;
|
||||||
if (params) {
|
if (params) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
@@ -93,83 +72,86 @@ class ApiClient {
|
|||||||
searchParams.append(key, String(value));
|
searchParams.append(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
|
if (queryString) {
|
||||||
|
fullUrl += `?${queryString}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalEndpoint = queryString ? `${endpoint}?${queryString}` : endpoint;
|
const config: RequestInit = {
|
||||||
return this.request<T>(finalEndpoint, {
|
method,
|
||||||
method: 'GET'
|
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<T>(url: string, params?: Record<string, unknown>): Promise<ApiResponse<T>> {
|
||||||
|
return this.request<T>(url, 'GET', undefined, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST 请求
|
// POST 请求
|
||||||
private async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
private async post<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(url, 'POST', data);
|
||||||
method: 'POST',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT 请求
|
// PUT 请求
|
||||||
private async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
private async put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(url, 'PUT', data);
|
||||||
method: 'PUT',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE 请求
|
|
||||||
private async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
|
||||||
return this.request<T>(endpoint, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 方法
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有视频来源
|
|
||||||
*/
|
|
||||||
async getVideoSources(): Promise<ApiResponse<VideoSourcesResponse>> {
|
async getVideoSources(): Promise<ApiResponse<VideoSourcesResponse>> {
|
||||||
return this.get<VideoSourcesResponse>('/video-sources');
|
return this.get<VideoSourcesResponse>('/video-sources');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取视频列表
|
|
||||||
* @param params 查询参数
|
|
||||||
*/
|
|
||||||
async getVideos(params?: VideosRequest): Promise<ApiResponse<VideosResponse>> {
|
async getVideos(params?: VideosRequest): Promise<ApiResponse<VideosResponse>> {
|
||||||
return this.get<VideosResponse>('/videos', params);
|
return this.get<VideosResponse>('/videos', params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个视频详情
|
|
||||||
* @param id 视频 ID
|
|
||||||
*/
|
|
||||||
async getVideo(id: number): Promise<ApiResponse<VideoResponse>> {
|
async getVideo(id: number): Promise<ApiResponse<VideoResponse>> {
|
||||||
return this.get<VideoResponse>(`/videos/${id}`);
|
return this.get<VideoResponse>(`/videos/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置视频下载状态
|
|
||||||
* @param id 视频 ID
|
|
||||||
*/
|
|
||||||
async resetVideo(id: number): Promise<ApiResponse<ResetVideoResponse>> {
|
async resetVideo(id: number): Promise<ApiResponse<ResetVideoResponse>> {
|
||||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset`);
|
return this.post<ResetVideoResponse>(`/videos/${id}/reset`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置所有视频下载状态
|
|
||||||
*/
|
|
||||||
async resetAllVideos(): Promise<ApiResponse<ResetAllVideosResponse>> {
|
async resetAllVideos(): Promise<ApiResponse<ResetAllVideosResponse>> {
|
||||||
return this.post<ResetAllVideosResponse>('/videos/reset-all');
|
return this.post<ResetAllVideosResponse>('/videos/reset-all');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置视频状态位
|
|
||||||
* @param id 视频 ID
|
|
||||||
* @param request 重置请求参数
|
|
||||||
*/
|
|
||||||
async updateVideoStatus(
|
async updateVideoStatus(
|
||||||
id: number,
|
id: number,
|
||||||
request: UpdateVideoStatusRequest
|
request: UpdateVideoStatusRequest
|
||||||
@@ -177,17 +159,10 @@ class ApiClient {
|
|||||||
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取我的收藏夹
|
|
||||||
*/
|
|
||||||
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
||||||
return this.get<FavoritesResponse>('/me/favorites');
|
return this.get<FavoritesResponse>('/me/favorites');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取关注的合集
|
|
||||||
* @param page 页码
|
|
||||||
*/
|
|
||||||
async getFollowedCollections(
|
async getFollowedCollections(
|
||||||
pageNum?: number,
|
pageNum?: number,
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -196,13 +171,9 @@ class ApiClient {
|
|||||||
page_num: pageNum,
|
page_num: pageNum,
|
||||||
page_size: pageSize
|
page_size: pageSize
|
||||||
};
|
};
|
||||||
return this.get<CollectionsResponse>('/me/collections', params);
|
return this.get<CollectionsResponse>('/me/collections', params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取关注的UP主
|
|
||||||
* @param page 页码
|
|
||||||
*/
|
|
||||||
async getFollowedUppers(
|
async getFollowedUppers(
|
||||||
pageNum?: number,
|
pageNum?: number,
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -211,46 +182,25 @@ class ApiClient {
|
|||||||
page_num: pageNum,
|
page_num: pageNum,
|
||||||
page_size: pageSize
|
page_size: pageSize
|
||||||
};
|
};
|
||||||
return this.get<UppersResponse>('/me/uppers', params);
|
return this.get<UppersResponse>('/me/uppers', params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅收藏夹
|
|
||||||
* @param request 订阅请求参数
|
|
||||||
*/
|
|
||||||
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
|
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/favorites', request);
|
return this.post<boolean>('/video-sources/favorites', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅合集
|
|
||||||
* @param request 订阅请求参数
|
|
||||||
*/
|
|
||||||
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
|
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/collections', request);
|
return this.post<boolean>('/video-sources/collections', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅UP主投稿
|
|
||||||
* @param request 订阅请求参数
|
|
||||||
*/
|
|
||||||
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/submissions', request);
|
return this.post<boolean>('/video-sources/submissions', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有视频源的详细信息
|
|
||||||
*/
|
|
||||||
async getVideoSourcesDetails(): Promise<ApiResponse<VideoSourcesDetailsResponse>> {
|
async getVideoSourcesDetails(): Promise<ApiResponse<VideoSourcesDetailsResponse>> {
|
||||||
return this.get<VideoSourcesDetailsResponse>('/video-sources/details');
|
return this.get<VideoSourcesDetailsResponse>('/video-sources/details');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新视频源
|
|
||||||
* @param type 视频源类型
|
|
||||||
* @param id 视频源 ID
|
|
||||||
* @param request 更新请求
|
|
||||||
*/
|
|
||||||
async updateVideoSource(
|
async updateVideoSource(
|
||||||
type: string,
|
type: string,
|
||||||
id: number,
|
id: number,
|
||||||
@@ -258,92 +208,43 @@ class ApiClient {
|
|||||||
): Promise<ApiResponse<boolean>> {
|
): Promise<ApiResponse<boolean>> {
|
||||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConfig(): Promise<ApiResponse<Config>> {
|
||||||
|
return this.get<Config>('/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(config: Config): Promise<ApiResponse<Config>> {
|
||||||
|
return this.put<Config>('/config', config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建默认的 API 客户端实例
|
// 创建默认的 API 客户端实例
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|
||||||
// 导出 API 方法的便捷函数
|
// 导出 API 方法的便捷函数
|
||||||
export const api = {
|
const api = {
|
||||||
/**
|
|
||||||
* 获取所有视频来源
|
|
||||||
*/
|
|
||||||
getVideoSources: () => apiClient.getVideoSources(),
|
getVideoSources: () => apiClient.getVideoSources(),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取视频列表
|
|
||||||
*/
|
|
||||||
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个视频详情
|
|
||||||
*/
|
|
||||||
getVideo: (id: number) => apiClient.getVideo(id),
|
getVideo: (id: number) => apiClient.getVideo(id),
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置视频下载状态
|
|
||||||
*/
|
|
||||||
resetVideo: (id: number) => apiClient.resetVideo(id),
|
resetVideo: (id: number) => apiClient.resetVideo(id),
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置所有视频下载状态
|
|
||||||
*/
|
|
||||||
resetAllVideos: () => apiClient.resetAllVideos(),
|
resetAllVideos: () => apiClient.resetAllVideos(),
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置视频状态位
|
|
||||||
*/
|
|
||||||
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
||||||
apiClient.updateVideoStatus(id, request),
|
apiClient.updateVideoStatus(id, request),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取我的收藏夹
|
|
||||||
*/
|
|
||||||
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取关注的合集
|
|
||||||
*/
|
|
||||||
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
||||||
apiClient.getFollowedCollections(pageNum, pageSize),
|
apiClient.getFollowedCollections(pageNum, pageSize),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取关注的UP主
|
|
||||||
*/
|
|
||||||
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
|
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
|
||||||
apiClient.getFollowedUppers(pageNum, pageSize),
|
apiClient.getFollowedUppers(pageNum, pageSize),
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅收藏夹
|
|
||||||
*/
|
|
||||||
upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request),
|
upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request),
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅合集
|
|
||||||
*/
|
|
||||||
upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request),
|
upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request),
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅UP主投稿
|
|
||||||
*/
|
|
||||||
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
|
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有视频源的详细信息
|
|
||||||
*/
|
|
||||||
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新视频源
|
|
||||||
*/
|
|
||||||
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
||||||
apiClient.updateVideoSource(type, id, request),
|
apiClient.updateVideoSource(type, id, request),
|
||||||
|
getConfig: () => apiClient.getConfig(),
|
||||||
/**
|
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||||
* 设置认证 token
|
setAuthToken: (token: string) => apiClient.setAuthToken(token),
|
||||||
*/
|
clearAuthToken: () => apiClient.clearAuthToken()
|
||||||
setAuthToken: (token: string) => apiClient.setAuthToken(token)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 默认导出
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export interface UpsertFavoriteRequest {
|
|||||||
export interface UpsertCollectionRequest {
|
export interface UpsertCollectionRequest {
|
||||||
sid: number;
|
sid: number;
|
||||||
mid: number;
|
mid: number;
|
||||||
|
collection_type?: number;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,3 +183,61 @@ export interface UpdateVideoSourceRequest {
|
|||||||
path: string;
|
path: string;
|
||||||
enabled: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,10 +67,6 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">关注的合集</h1>
|
|
||||||
<p class="text-muted-foreground mt-1">管理您在B站关注的合集订阅</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground text-sm">
|
<div class="text-muted-foreground text-sm">
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
共 {totalCount} 个合集
|
共 {totalCount} 个合集
|
||||||
|
|||||||
@@ -52,10 +52,6 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">我的收藏夹</h1>
|
|
||||||
<p class="text-muted-foreground mt-1">管理您在B站创建的收藏夹订阅</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground text-sm">
|
<div class="text-muted-foreground text-sm">
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
共 {favorites.length} 个收藏夹
|
共 {favorites.length} 个收藏夹
|
||||||
|
|||||||
@@ -65,10 +65,6 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">关注的UP主</h1>
|
|
||||||
<p class="text-muted-foreground mt-1">管理您在B站关注的UP主投稿订阅</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground text-sm">
|
<div class="text-muted-foreground text-sm">
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
共 {totalCount} 个UP主
|
共 {totalCount} 个UP主
|
||||||
|
|||||||
@@ -3,28 +3,72 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/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 api from '$lib/api';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { appStateStore, ToQuery } from '$lib/stores/filter';
|
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 saving = false;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
async function saveApiToken() {
|
async function loadConfig() {
|
||||||
if (!apiToken.trim()) {
|
loading = true;
|
||||||
toast.error('请输入有效的API Token');
|
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;
|
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;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
api.setAuthToken(apiToken.trim());
|
let resp = await api.updateConfig(formData);
|
||||||
toast.success('API Token 已保存');
|
formData = resp.data;
|
||||||
|
config = { ...formData };
|
||||||
|
toast.success('配置已保存');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存API Token失败:', error);
|
console.error('保存配置失败:', error);
|
||||||
toast.error('保存失败,请重试');
|
toast.error('保存配置失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -40,10 +84,14 @@
|
|||||||
},
|
},
|
||||||
{ label: '设置', isActive: true }
|
{ label: '设置', isActive: true }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const savedToken = localStorage.getItem('authToken');
|
const savedToken = localStorage.getItem('authToken');
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
apiToken = savedToken;
|
frontendToken = savedToken;
|
||||||
|
api.setAuthToken(savedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,31 +99,570 @@
|
|||||||
<title>设置 - Bili Sync</title>
|
<title>设置 - Bili Sync</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="max-w-4xl">
|
<div class="space-y-6">
|
||||||
<div class="space-y-8">
|
<!-- 前端认证状态栏 -->
|
||||||
<!-- API Token 配置 -->
|
<div class="bg-card rounded-lg border p-4">
|
||||||
<div class="border-border border-b pb-6">
|
<div class="flex items-center justify-between">
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="space-y-1">
|
||||||
<div class="lg:col-span-1">
|
<h2 class="font-semibold">前端认证状态</h2>
|
||||||
<Label class="text-base font-semibold">API Token</Label>
|
<p class="text-muted-foreground text-sm">
|
||||||
<p class="text-muted-foreground mt-1 text-sm">用于身份验证的API令牌</p>
|
{formData ? '已认证 - 可以正常加载数据' : '未认证 - 请输入 Token 进行鉴权'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 lg:col-span-2">
|
{#if !formData}
|
||||||
<div class="space-y-2">
|
<div class="flex gap-3">
|
||||||
<Input
|
<Input
|
||||||
id="api-token"
|
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入API Token"
|
placeholder="输入认证Token"
|
||||||
bind:value={apiToken}
|
bind:value={frontendToken}
|
||||||
class="max-w-lg"
|
class="w-64"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted-foreground text-xs">请确保令牌的安全性,不要与他人分享</p>
|
<Button onclick={authenticateFrontend} disabled={!frontendToken.trim()}>认证</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={saveApiToken} disabled={saving} size="sm">
|
{:else}
|
||||||
{saving ? '保存中...' : '保存'}
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||||
|
<span class="text-sm text-green-600">已认证</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
formData = null;
|
||||||
|
config = null;
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
api.clearAuthToken();
|
||||||
|
frontendToken = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出认证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用配置 -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-16">
|
||||||
|
<div class="space-y-2 text-center">
|
||||||
|
<div
|
||||||
|
class="border-primary mx-auto h-6 w-6 animate-spin rounded-full border-2 border-t-transparent"
|
||||||
|
></div>
|
||||||
|
<p class="text-muted-foreground">加载配置中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if formData}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Tabs.Root value="basic" class="w-full">
|
||||||
|
<Tabs.List class="grid w-full grid-cols-5">
|
||||||
|
<Tabs.Trigger value="basic">基本设置</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="auth">B站认证</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="filter">视频质量</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="danmaku">弹幕渲染</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="advanced">高级设置</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<Tabs.Content value="basic" class="mt-6 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bind-address">绑定地址</Label>
|
||||||
|
<Input
|
||||||
|
id="bind-address"
|
||||||
|
placeholder="127.0.0.1:9999"
|
||||||
|
bind:value={formData.bind_address}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="interval">同步间隔(秒)</Label>
|
||||||
|
<Input id="interval" type="number" min="60" bind:value={formData.interval} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="video-name">视频名称模板</Label>
|
||||||
|
<Input id="video-name" bind:value={formData.video_name} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="page-name">分页名称模板</Label>
|
||||||
|
<Input id="page-name" bind:value={formData.page_name} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="upper-path">UP主头像保存路径</Label>
|
||||||
|
<Input
|
||||||
|
id="upper-path"
|
||||||
|
placeholder="/path/to/upper/faces"
|
||||||
|
bind:value={formData.upper_path}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="time-format">时间格式</Label>
|
||||||
|
<Input
|
||||||
|
id="time-format"
|
||||||
|
placeholder="%Y-%m-%d %H:%M:%S"
|
||||||
|
bind:value={formData.time_format}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="backend-auth-token">后端API认证Token</Label>
|
||||||
|
<Input
|
||||||
|
id="backend-auth-token"
|
||||||
|
type="password"
|
||||||
|
placeholder="设置后端API认证Token"
|
||||||
|
bind:value={formData.auth_token}
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
修改此Token后,前端需要使用新Token重新认证才能访问API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="cdn-sorting" bind:checked={formData.cdn_sorting} />
|
||||||
|
<Label for="cdn-sorting">启用CDN排序</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- B站认证 -->
|
||||||
|
<Tabs.Content value="auth" class="mt-6 space-y-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="sessdata">SESSDATA</Label>
|
||||||
|
<Input
|
||||||
|
id="sessdata"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入SESSDATA"
|
||||||
|
bind:value={formData.credential.sessdata}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bili-jct">bili_jct</Label>
|
||||||
|
<Input
|
||||||
|
id="bili-jct"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入bili_jct"
|
||||||
|
bind:value={formData.credential.bili_jct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="buvid3">buvid3</Label>
|
||||||
|
<Input
|
||||||
|
id="buvid3"
|
||||||
|
placeholder="请输入buvid3"
|
||||||
|
bind:value={formData.credential.buvid3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="dedeuserid">dedeuserid</Label>
|
||||||
|
<Input
|
||||||
|
id="dedeuserid"
|
||||||
|
placeholder="请输入dedeuserid"
|
||||||
|
bind:value={formData.credential.dedeuserid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="ac-time-value">ac_time_value</Label>
|
||||||
|
<Input
|
||||||
|
id="ac-time-value"
|
||||||
|
placeholder="请输入ac_time_value"
|
||||||
|
bind:value={formData.credential.ac_time_value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- 过滤规则 -->
|
||||||
|
<Tabs.Content value="filter" class="mt-6 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="video-max-quality">最高视频质量</Label>
|
||||||
|
<select
|
||||||
|
id="video-max-quality"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={formData.filter_option.video_max_quality}
|
||||||
|
>
|
||||||
|
<option value="Quality360p">360p</option>
|
||||||
|
<option value="Quality480p">480p</option>
|
||||||
|
<option value="Quality720p">720p</option>
|
||||||
|
<option value="Quality1080p">1080p</option>
|
||||||
|
<option value="Quality1080pPLUS">1080p+</option>
|
||||||
|
<option value="Quality1080p60">1080p60</option>
|
||||||
|
<option value="Quality4k">4K</option>
|
||||||
|
<option value="QualityHdr">HDR</option>
|
||||||
|
<option value="QualityDolby">杜比视界</option>
|
||||||
|
<option value="Quality8k">8K</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="video-min-quality">最低视频质量</Label>
|
||||||
|
<select
|
||||||
|
id="video-min-quality"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={formData.filter_option.video_min_quality}
|
||||||
|
>
|
||||||
|
<option value="Quality360p">360p</option>
|
||||||
|
<option value="Quality480p">480p</option>
|
||||||
|
<option value="Quality720p">720p</option>
|
||||||
|
<option value="Quality1080p">1080p</option>
|
||||||
|
<option value="Quality1080pPLUS">1080p+</option>
|
||||||
|
<option value="Quality1080p60">1080p60</option>
|
||||||
|
<option value="Quality4k">4K</option>
|
||||||
|
<option value="QualityHdr">HDR</option>
|
||||||
|
<option value="QualityDolby">杜比视界</option>
|
||||||
|
<option value="Quality8k">8K</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="audio-max-quality">最高音频质量</Label>
|
||||||
|
<select
|
||||||
|
id="audio-max-quality"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={formData.filter_option.audio_max_quality}
|
||||||
|
>
|
||||||
|
<option value="Quality64k">64k</option>
|
||||||
|
<option value="Quality132k">132k</option>
|
||||||
|
<option value="Quality192k">192k</option>
|
||||||
|
<option value="QualityDolby">杜比全景声</option>
|
||||||
|
<option value="QualityHiRES">Hi-RES</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="audio-min-quality">最低音频质量</Label>
|
||||||
|
<select
|
||||||
|
id="audio-min-quality"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={formData.filter_option.audio_min_quality}
|
||||||
|
>
|
||||||
|
<option value="Quality64k">64k</option>
|
||||||
|
<option value="Quality132k">132k</option>
|
||||||
|
<option value="Quality192k">192k</option>
|
||||||
|
<option value="QualityDolby">杜比全景声</option>
|
||||||
|
<option value="QualityHiRES">Hi-RES</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Label>视频编码格式偏好(按优先级排序)</Label>
|
||||||
|
<p class="text-muted-foreground text-sm">排在前面的编码格式优先级更高</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each formData.filter_option.codecs as codec, index (index)}
|
||||||
|
<div class="flex items-center space-x-2 rounded-lg border p-3">
|
||||||
|
<Badge variant="secondary">{index + 1}</Badge>
|
||||||
|
<span class="flex-1 font-medium">{codec}</span>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={index === 0}
|
||||||
|
onclick={() => {
|
||||||
|
if (formData && index > 0) {
|
||||||
|
const newCodecs = [...formData.filter_option.codecs];
|
||||||
|
[newCodecs[index - 1], newCodecs[index]] = [
|
||||||
|
newCodecs[index],
|
||||||
|
newCodecs[index - 1]
|
||||||
|
];
|
||||||
|
formData.filter_option.codecs = newCodecs;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={index === formData.filter_option.codecs.length - 1}
|
||||||
|
onclick={() => {
|
||||||
|
if (formData && index < formData.filter_option.codecs.length - 1) {
|
||||||
|
const newCodecs = [...formData.filter_option.codecs];
|
||||||
|
[newCodecs[index], newCodecs[index + 1]] = [
|
||||||
|
newCodecs[index + 1],
|
||||||
|
newCodecs[index]
|
||||||
|
];
|
||||||
|
formData.filter_option.codecs = newCodecs;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onclick={() => {
|
||||||
|
if (formData) {
|
||||||
|
formData.filter_option.codecs = formData.filter_option.codecs.filter(
|
||||||
|
(_, i) => i !== index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if formData.filter_option.codecs.length < 3}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>添加编码格式</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each ['AV1', 'HEV', 'AVC'] as codec (codec)}
|
||||||
|
{#if !formData.filter_option.codecs.includes(codec)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
if (formData) {
|
||||||
|
formData.filter_option.codecs = [
|
||||||
|
...formData.filter_option.codecs,
|
||||||
|
codec
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {codec}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="no-dolby-video" bind:checked={formData.filter_option.no_dolby_video} />
|
||||||
|
<Label for="no-dolby-video">排除杜比视界视频</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="no-dolby-audio" bind:checked={formData.filter_option.no_dolby_audio} />
|
||||||
|
<Label for="no-dolby-audio">排除杜比全景声音频</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="no-hdr" bind:checked={formData.filter_option.no_hdr} />
|
||||||
|
<Label for="no-hdr">排除HDR视频</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="no-hires" bind:checked={formData.filter_option.no_hires} />
|
||||||
|
<Label for="no-hires">排除Hi-RES音频</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- 弹幕设置 -->
|
||||||
|
<Tabs.Content value="danmaku" class="mt-6 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-duration">弹幕持续时间(秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-duration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={formData.danmaku_option.duration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-font">字体</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-font"
|
||||||
|
placeholder="黑体"
|
||||||
|
bind:value={formData.danmaku_option.font}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-font-size">字体大小</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-font-size"
|
||||||
|
type="number"
|
||||||
|
min="8"
|
||||||
|
max="72"
|
||||||
|
bind:value={formData.danmaku_option.font_size}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-width-ratio">宽度比例</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-width-ratio"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={formData.danmaku_option.width_ratio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-horizontal-gap">水平间距</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-horizontal-gap"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
bind:value={formData.danmaku_option.horizontal_gap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-lane-size">轨道大小</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-lane-size"
|
||||||
|
type="number"
|
||||||
|
min="8"
|
||||||
|
max="128"
|
||||||
|
bind:value={formData.danmaku_option.lane_size}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-float-percentage">滚动弹幕高度百分比</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-float-percentage"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={formData.danmaku_option.float_percentage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-bottom-percentage">底部弹幕高度百分比</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-bottom-percentage"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={formData.danmaku_option.bottom_percentage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-opacity">透明度 (0-255)</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-opacity"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
bind:value={formData.danmaku_option.opacity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-outline">描边宽度</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-outline"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={formData.danmaku_option.outline}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="danmaku-time-offset">时间偏移(秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="danmaku-time-offset"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={formData.danmaku_option.time_offset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch id="danmaku-bold" bind:checked={formData.danmaku_option.bold} />
|
||||||
|
<Label for="danmaku-bold">粗体显示</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- 高级设置 -->
|
||||||
|
<Tabs.Content value="advanced" class="mt-6 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="video-concurrent">视频并发数</Label>
|
||||||
|
<Input
|
||||||
|
id="video-concurrent"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
bind:value={formData.concurrent_limit.video}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="page-concurrent">分页并发数</Label>
|
||||||
|
<Input
|
||||||
|
id="page-concurrent"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
bind:value={formData.concurrent_limit.page}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="nfo-time-type">NFO时间类型</Label>
|
||||||
|
<select
|
||||||
|
id="nfo-time-type"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={formData.nfo_time_type}
|
||||||
|
>
|
||||||
|
<option value="FavTime">收藏时间</option>
|
||||||
|
<option value="PubTime">发布时间</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- Token管理 -->
|
||||||
|
<Tabs.Content value="token" class="mt-6 space-y-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="backend-auth-token">后端认证Token</Label>
|
||||||
|
<Input
|
||||||
|
id="backend-auth-token"
|
||||||
|
type="password"
|
||||||
|
placeholder="设置后端API认证Token"
|
||||||
|
bind:value={formData.auth_token}
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
用于保护后端API的认证令牌,修改后需要重新进行前端认证
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-6">
|
||||||
|
<Button onclick={saveConfig} disabled={saving} size="lg">
|
||||||
|
{saving ? '保存中...' : '保存配置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center py-16">
|
||||||
|
<div class="space-y-4 text-center">
|
||||||
|
<p class="text-muted-foreground">请先进行前端认证以加载配置</p>
|
||||||
|
<Button onclick={() => frontendToken && authenticateFrontend()} disabled={!frontendToken}>
|
||||||
|
重新加载配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user