Compare commits

...

24 Commits

Author SHA1 Message Date
amtoaer
2b046362d7 chore: 发布 bili-sync 2.7.0 2025-09-25 00:51:59 +08:00
ᴀᴍᴛᴏᴀᴇʀ
61c9e7de88 chore: 前端小修改,ua 随机范围添加 windows (#470) 2025-09-25 00:50:17 +08:00
ᴀᴍᴛᴏᴀᴇʀ
3d25c6b321 chore: 跑一遍 auto-correct (#468) 2025-09-24 18:50:47 +08:00
ᴀᴍᴛᴏᴀᴇʀ
d35858790b chore: clippy 应该拒绝 warning (#466) 2025-09-24 17:58:04 +08:00
ᴀᴍᴛᴏᴀᴇʀ
b441f04cdf chore: 修复新的 clippy warnings (#467) 2025-09-24 17:36:20 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4db7e6763a feat: 支持重新评估历史视频,前端显示视频的规则评估状态 (#465) 2025-09-24 17:08:04 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bbbb7d0c5b feat: 利用 etag 节省内容传输,显式写明生命周期 (#464) 2025-09-24 02:03:06 +08:00
ᴀᴍᴛᴏᴀᴇʀ
210c94398a feat: 实现视频的筛选规则 (#457) 2025-09-24 00:42:27 +08:00
ᴀᴍᴛᴏᴀᴇʀ
6c7d295fe6 fix: 修复字幕风控的报错 (#463) 2025-09-23 08:27:14 +08:00
ᴀᴍᴛᴏᴀᴇʀ
71519af2f3 chore: 移除不必要的 image-proxy (#451) 2025-08-28 18:51:23 +08:00
Thomas Yang
8ed2fbae24 feat: 请求中header的User-Agent使用随机值 (#447) 2025-08-27 10:27:23 +08:00
amtoaer
fd90bc8b73 chore: 下载失败时不再打印一大串 URL 2025-08-08 20:23:40 +08:00
amtoaer
66bd3d6a41 chore: ffmpeg 执行失败时添加一条说明 2025-08-07 15:11:29 +08:00
amtoaer
5ef23a678f chore: 发布 bili-sync 2.6.3 2025-08-07 12:41:48 +08:00
ᴀᴍᴛᴏᴀᴇʀ
66079f3adc feat: sqlite 开启 Wal,移除不必要的 Arc,妥善释放数据库 (#421) 2025-08-06 17:20:06 +08:00
ᴀᴍᴛᴏᴀᴇʀ
4f780faf64 fix: 增加 busy_timeout、最小化事务块、增加每批处理 page 量 (#420) 2025-08-06 14:08:07 +08:00
ᴀᴍᴛᴏᴀᴇʀ
dbcb1fa78b fix: 为填充视频详情添加并发限制,避免数据库竞争 (#419) 2025-08-06 10:37:06 +08:00
amtoaer
386dac7735 chore: 格式化后端代码 2025-08-05 23:11:55 +08:00
Xinyu Bao
5537c621be Add error messages for the case in which the database initialization fails (#415) 2025-08-05 23:11:11 +08:00
amtoaer
c7978e20da chore: 发布 bili-sync 2.6.2 2025-07-23 22:48:30 +08:00
ᴀᴍᴛᴏᴀᴇʀ
6e4af47bda fix: 修复 collection_type 反序列化错误 (#403) 2025-07-23 22:46:39 +08:00
ᴀᴍᴛᴏᴀᴇʀ
791e4997a0 docs: 修复配置项描述 (#396) 2025-07-13 16:59:06 +08:00
amtoaer
05ab83fc93 chore: 发布 bili-sync 2.6.1 2025-07-13 00:29:52 +08:00
ᴀᴍᴛᴏᴀᴇʀ
18ed9e09b1 fix: 修复 chromium 系浏览器创建 WebSocket 失败的问题 (#395) 2025-07-13 00:28:24 +08:00
82 changed files with 1981 additions and 560 deletions

View File

@@ -37,7 +37,7 @@ jobs:
run: cargo +nightly fmt --check
- name: cargo clippy
run: cargo clippy
run: cargo clippy -- -D warnings
- name: cargo test
run: cargo test

56
Cargo.lock generated
View File

@@ -475,7 +475,7 @@ dependencies = [
[[package]]
name = "bili_sync"
version = "2.6.0"
version = "2.7.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -524,21 +524,25 @@ dependencies = [
"tower",
"tracing",
"tracing-subscriber",
"ua_generator",
"uuid",
"validator",
]
[[package]]
name = "bili_sync_entity"
version = "2.6.0"
version = "2.7.0"
dependencies = [
"derivative",
"regex",
"sea-orm",
"serde",
"serde_json",
]
[[package]]
name = "bili_sync_migration"
version = "2.6.0"
version = "2.7.0"
dependencies = [
"async-std",
"sea-orm-migration",
@@ -1033,6 +1037,17 @@ dependencies = [
"serde",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@@ -3123,6 +3138,7 @@ version = "0.23.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -4314,6 +4330,20 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ua_generator"
version = "0.5.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a68ea0a55d5ad9e86e85f767180daff9f24a030490ac66e8490861e8484d7ed"
dependencies = [
"dotenvy",
"fastrand",
"serde",
"serde_json",
"toml",
"ureq",
]
[[package]]
name = "ucd-trie"
version = "0.1.6"
@@ -4353,6 +4383,26 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"brotli-decompressor",
"encoding_rs",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.2",
]
[[package]]
name = "url"
version = "2.5.4"

View File

@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.6.0"
version = "2.7.0"
authors = ["amtoaer <amtoaer@gmail.com>"]
license = "MIT"
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
@@ -29,6 +29,7 @@ clap = { version = "4.5.41", features = ["env", "string"] }
cookie = "0.18.1"
cow-utils = "0.1.3"
dashmap = "6.1.0"
derivative = "2.2.0"
dirs = "6.0.0"
enum_dispatch = "0.3.13"
float-ord = "0.3.2"
@@ -75,6 +76,7 @@ toml = "0.9.1"
tower = "0.5.2"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["chrono", "json"] }
ua_generator = "0.5.22"
uuid = { version = "1.17.0", features = ["v4"] }
validator = { version = "0.20.0", features = ["derive"] }

View File

@@ -54,6 +54,7 @@ toml = { workspace = true }
tower = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
ua_generator = { workspace = true }
uuid = { workspace = true }
validator = { workspace = true }

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use std::pin::Pin;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use chrono::Utc;
use futures::Stream;
@@ -16,7 +17,7 @@ use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, Vi
impl VideoSource for collection::Model {
fn display_name(&self) -> Cow<'static, str> {
format!("{}{}", CollectionType::from(self.r#type), self.name).into()
format!("{}{}", CollectionType::from_expected(self.r#type), self.name).into()
}
fn filter_expr(&self) -> SimpleExpr {
@@ -55,14 +56,19 @@ impl VideoSource for collection::Model {
latest_row_at: &chrono::DateTime<Utc>,
) -> Option<VideoInfo> {
// 由于 collection 的视频无固定时间顺序should_take 无法提前中断拉取,因此 should_filter 环节需要进行额外过滤
if let Ok(video_info) = video_info {
if video_info.release_datetime() > latest_row_at {
return Some(video_info);
}
if let Ok(video_info) = video_info
&& video_info.release_datetime() > latest_row_at
{
return Some(video_info);
}
None
}
fn rule(&self) -> &Option<Rule> {
&self.rule
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,
@@ -76,14 +82,14 @@ impl VideoSource for collection::Model {
CollectionItem {
sid: self.s_id.to_string(),
mid: self.m_id.to_string(),
collection_type: CollectionType::from(self.r#type),
collection_type: CollectionType::from_expected(self.r#type),
},
);
let collection_info = collection.get_info().await?;
ensure!(
collection_info.sid == self.s_id
&& collection_info.mid == self.m_id
&& collection_info.collection_type == CollectionType::from(self.r#type),
&& collection_info.collection_type == CollectionType::from_expected(self.r#type),
"collection info mismatch: {:?} != {:?}",
collection_info,
collection.collection

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use std::pin::Pin;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::ActiveValue::Set;
@@ -42,6 +43,10 @@ impl VideoSource for favorite::Model {
})
}
fn rule(&self) -> &Option<Rule> {
&self.rule
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,

View File

@@ -19,6 +19,7 @@ use sea_orm::sea_query::SimpleExpr;
#[rustfmt::skip]
use bili_sync_entity::collection::Model as Collection;
use bili_sync_entity::favorite::Model as Favorite;
use bili_sync_entity::rule::Rule;
use bili_sync_entity::submission::Model as Submission;
use bili_sync_entity::watch_later::Model as WatchLater;
@@ -68,6 +69,8 @@ pub trait VideoSource {
video_info.ok()
}
fn rule(&self) -> &Option<Rule>;
fn log_refresh_video_start(&self) {
info!("开始扫描{}..", self.display_name());
}

View File

@@ -2,6 +2,7 @@ use std::path::Path;
use std::pin::Pin;
use anyhow::{Context, Result, ensure};
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::ActiveValue::Set;
@@ -41,6 +42,10 @@ impl VideoSource for submission::Model {
})
}
fn rule(&self) -> &Option<Rule> {
&self.rule
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,

View File

@@ -2,6 +2,7 @@ use std::path::Path;
use std::pin::Pin;
use anyhow::Result;
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use futures::Stream;
use sea_orm::ActiveValue::Set;
@@ -41,6 +42,10 @@ impl VideoSource for watch_later::Model {
})
}
fn rule(&self) -> &Option<Rule> {
&self.rule
}
async fn refresh<'a>(
self,
bili_client: &'a BiliClient,

View File

@@ -1,3 +1,4 @@
use bili_sync_entity::rule::Rule;
use serde::Deserialize;
use validator::Validate;
@@ -81,14 +82,10 @@ pub struct InsertSubmissionRequest {
pub path: String,
}
#[derive(Deserialize)]
pub struct ImageProxyParams {
pub url: String,
}
#[derive(Deserialize, Validate)]
pub struct UpdateVideoSourceRequest {
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
pub enabled: bool,
pub rule: Option<Rule>,
}

View File

@@ -1,3 +1,4 @@
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use sea_orm::{DerivePartialModel, FromQueryResult};
use serde::Serialize;
@@ -58,6 +59,7 @@ pub struct VideoInfo {
pub bvid: String,
pub name: String,
pub upper_name: String,
pub should_download: bool,
#[serde(serialize_with = "serde_video_download_status")]
pub download_status: u32,
}
@@ -169,9 +171,19 @@ pub struct SysInfo {
}
#[derive(Serialize, FromQueryResult)]
#[serde(rename_all = "camelCase")]
pub struct VideoSourceDetail {
pub id: i32,
pub name: String,
pub path: String,
pub rule: Option<Rule>,
#[serde(default)]
pub rule_display: Option<String>,
pub enabled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateVideoSourceResponse {
pub rule_display: Option<String>,
}

View File

@@ -22,7 +22,7 @@ pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
/// 更新全局配置
pub async fn update_config(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
ValidatedJson(config): ValidatedJson<Config>,
) -> Result<ApiResponse<Arc<Config>>, ApiError> {
let Some(_lock) = TASK_STATUS_NOTIFIER.detect_running() else {
@@ -30,7 +30,7 @@ pub async fn update_config(
return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into());
};
config.check()?;
let new_config = VersionedConfig::get().update(config, db.as_ref()).await?;
let new_config = VersionedConfig::get().update(config, &db).await?;
drop(_lock);
Ok(ApiResponse::ok(new_config))
}

View File

@@ -1,5 +1,3 @@
use std::sync::Arc;
use axum::routing::get;
use axum::{Extension, Router};
use bili_sync_entity::*;
@@ -14,21 +12,21 @@ pub(super) fn router() -> Router {
}
async fn get_dashboard(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
) -> Result<ApiResponse<DashBoardResponse>, ApiError> {
let (enabled_favorites, enabled_collections, enabled_submissions, enabled_watch_later, videos_by_day) = tokio::try_join!(
favorite::Entity::find()
.filter(favorite::Column::Enabled.eq(true))
.count(db.as_ref()),
.count(&db),
collection::Entity::find()
.filter(collection::Column::Enabled.eq(true))
.count(db.as_ref()),
.count(&db),
submission::Entity::find()
.filter(submission::Column::Enabled.eq(true))
.count(db.as_ref()),
.count(&db),
watch_later::Entity::find()
.filter(watch_later::Column::Enabled.eq(true))
.count(db.as_ref()),
.count(&db),
DayCountPair::find_by_statement(Statement::from_string(
db.get_database_backend(),
// 用 SeaORM 太复杂了,直接写个裸 SQL
@@ -55,13 +53,13 @@ ORDER BY
dates.day;
"
))
.all(db.as_ref()),
.all(&db),
)?;
return Ok(ApiResponse::ok(DashBoardResponse {
Ok(ApiResponse::ok(DashBoardResponse {
enabled_favorites,
enabled_collections,
enabled_submissions,
enable_watch_later: enabled_watch_later > 0,
videos_by_day,
}));
}))
}

View File

@@ -25,14 +25,14 @@ pub(super) fn router() -> Router {
/// 获取当前用户创建的收藏夹
pub async fn get_created_favorites(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
) -> Result<ApiResponse<FavoritesResponse>, ApiError> {
let me = Me::new(bili_client.as_ref());
let bili_favorites = me.get_created_favorites().await?;
let favorites = if let Some(bili_favorites) = bili_favorites {
// b 站收藏夹相关接口使用的所谓 “fid” 其实是该处的 id即 fid + mid 后两位
// b 站收藏夹相关接口使用的所谓“fid”其实是该处的 id即 fid + mid 后两位
let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect();
let subscribed_fids: Vec<i64> = favorite::Entity::find()
@@ -40,7 +40,7 @@ pub async fn get_created_favorites(
.column(favorite::Column::FId)
.filter(favorite::Column::FId.is_in(bili_fids))
.into_tuple()
.all(db.as_ref())
.all(&db)
.await?;
let subscribed_set: HashSet<i64> = subscribed_fids.into_iter().collect();
@@ -64,7 +64,7 @@ pub async fn get_created_favorites(
/// 获取当前用户收藏的合集
pub async fn get_followed_collections(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<FollowedCollectionsRequest>,
) -> Result<ApiResponse<CollectionsResponse>, ApiError> {
@@ -80,7 +80,7 @@ pub async fn get_followed_collections(
.column(collection::Column::SId)
.filter(collection::Column::SId.is_in(bili_sids))
.into_tuple()
.all(db.as_ref())
.all(&db)
.await?;
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
@@ -106,7 +106,7 @@ pub async fn get_followed_collections(
/// 获取当前用户关注的 UP 主
pub async fn get_followed_uppers(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<FollowedUppersRequest>,
) -> Result<ApiResponse<UppersResponse>, ApiError> {
@@ -121,7 +121,7 @@ pub async fn get_followed_uppers(
.column(submission::Column::UpperId)
.filter(submission::Column::UpperId.is_in(bili_uid))
.into_tuple()
.all(db.as_ref())
.all(&db)
.await?;
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();

View File

@@ -1,20 +1,13 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Extension, Query, Request};
use axum::extract::Request;
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Router, middleware};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use reqwest::{Method, StatusCode, header};
use reqwest::StatusCode;
use super::request::ImageProxyParams;
use crate::api::wrapper::ApiResponse;
use crate::bilibili::BiliClient;
use crate::config::VersionedConfig;
mod config;
@@ -27,7 +20,7 @@ mod ws;
pub use ws::{LogHelper, MAX_HISTORY_LOGS};
pub fn router() -> Router {
Router::new().route("/image-proxy", get(image_proxy)).nest(
Router::new().nest(
"/api",
config::router()
.merge(me::router())
@@ -39,61 +32,27 @@ pub fn router() -> Router {
)
}
/// 中间件:验证请求头中的 Authorization 是否与配置中的 auth_token 匹配
pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
/// 中间件:使用 auth token 对请求进行身份验证
pub async fn auth(mut headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
let config = VersionedConfig::get().load();
let token = config.auth_token.as_str();
if headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.is_some_and(|s| s == token)
|| headers
.get("Sec-WebSocket-Protocol")
.and_then(|v| v.to_str().ok())
.and_then(|s| BASE64_URL_SAFE_NO_PAD.decode(s).ok())
.is_some_and(|s| s == token.as_bytes())
{
return Ok(next.run(request).await);
}
if let Some(protocol) = headers.remove("Sec-WebSocket-Protocol")
&& protocol
.to_str()
.ok()
.and_then(|s| BASE64_URL_SAFE_NO_PAD.decode(s).ok())
.is_some_and(|s| s == token.as_bytes())
{
let mut resp = next.run(request).await;
resp.headers_mut().insert("Sec-WebSocket-Protocol", protocol);
return Ok(resp);
}
Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response())
}
/// B 站的图片会检查 referer需要做个转发伪造一下否则直接返回 403
pub async fn image_proxy(
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<ImageProxyParams>,
) -> Response {
let resp = bili_client.client.request(Method::GET, &params.url, None).send().await;
let whitelist = [
header::CONTENT_TYPE,
header::CONTENT_LENGTH,
header::CACHE_CONTROL,
header::EXPIRES,
header::LAST_MODIFIED,
header::ETAG,
header::CONTENT_DISPOSITION,
header::CONTENT_ENCODING,
header::ACCEPT_RANGES,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
]
.into_iter()
.collect::<HashSet<_>>();
let builder = Response::builder();
let response = match resp {
Err(e) => builder.status(StatusCode::BAD_GATEWAY).body(Body::new(e.to_string())),
Ok(res) => {
let mut response = builder.status(res.status());
for (k, v) in res.headers() {
if whitelist.contains(k) {
response = response.header(k, v);
}
}
let streams = res.bytes_stream();
response.body(Body::from_stream(streams))
}
};
//safety: all previously configured headers are taken from a valid response, ensuring the response is safe to use
response.unwrap()
}

View File

@@ -4,25 +4,31 @@ use anyhow::Result;
use axum::Router;
use axum::extract::{Extension, Path};
use axum::routing::{get, post, put};
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use bili_sync_migration::Expr;
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect};
use sea_orm::entity::prelude::*;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, TransactionTrait};
use crate::adapter::_ActiveModel;
use crate::api::error::InnerApiError;
use crate::api::request::{
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
};
use crate::api::response::{VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse};
use crate::api::response::{
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
};
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
use crate::utils::rule::FieldEvaluatable;
pub(super) fn router() -> Router {
Router::new()
.route("/video-sources", get(get_video_sources))
.route("/video-sources/details", get(get_video_sources_details))
.route("/video-sources/{type}/{id}", put(update_video_source))
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
.route("/video-sources/favorites", post(insert_favorite))
.route("/video-sources/collections", post(insert_collection))
.route("/video-sources/submissions", post(insert_submission))
@@ -30,31 +36,31 @@ pub(super) fn router() -> Router {
/// 列出所有视频来源
pub async fn get_video_sources(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
let (collection, favorite, submission, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([collection::Column::Id, collection::Column::Name])
.into_model::<VideoSource>()
.all(db.as_ref()),
.all(&db),
favorite::Entity::find()
.select_only()
.columns([favorite::Column::Id, favorite::Column::Name])
.into_model::<VideoSource>()
.all(db.as_ref()),
.all(&db),
submission::Entity::find()
.select_only()
.column(submission::Column::Id)
.column_as(submission::Column::UpperName, "name")
.into_model::<VideoSource>()
.all(db.as_ref()),
.all(&db),
watch_later::Entity::find()
.select_only()
.column(watch_later::Column::Id)
.column_as(Expr::value("稍后再看"), "name")
.into_model::<VideoSource>()
.all(db.as_ref())
.all(&db)
)?;
// watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项
if watch_later.is_empty() {
@@ -73,52 +79,71 @@ pub async fn get_video_sources(
/// 获取视频来源详情
pub async fn get_video_sources_details(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
let (collections, favorites, submissions, mut watch_later) = tokio::try_join!(
let (mut collections, mut favorites, mut submissions, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([
collection::Column::Id,
collection::Column::Name,
collection::Column::Path,
collection::Column::Rule,
collection::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
.all(&db),
favorite::Entity::find()
.select_only()
.columns([
favorite::Column::Id,
favorite::Column::Name,
favorite::Column::Path,
favorite::Column::Rule,
favorite::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
.all(&db),
submission::Entity::find()
.select_only()
.column(submission::Column::Id)
.column_as(submission::Column::UpperName, "name")
.columns([submission::Column::Path, submission::Column::Enabled])
.columns([
submission::Column::Id,
submission::Column::Path,
submission::Column::Enabled,
submission::Column::Rule
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
.all(&db),
watch_later::Entity::find()
.select_only()
.column(watch_later::Column::Id)
.column_as(Expr::value("稍后再看"), "name")
.columns([watch_later::Column::Path, watch_later::Column::Enabled])
.columns([
watch_later::Column::Id,
watch_later::Column::Path,
watch_later::Column::Enabled,
watch_later::Column::Rule
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref())
.all(&db)
)?;
if watch_later.is_empty() {
watch_later.push(VideoSourceDetail {
id: 1,
name: "稍后再看".to_string(),
path: String::new(),
rule: None,
rule_display: None,
enabled: false,
})
}
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
sources.iter_mut().for_each(|item| {
if let Some(rule) = &item.rule {
item.rule_display = Some(rule.to_string());
}
});
}
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
collections,
favorites,
@@ -130,29 +155,33 @@ pub async fn get_video_sources_details(
/// 更新视频来源
pub async fn update_video_source(
Path((source_type, id)): Path<(String, i32)>,
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
) -> Result<ApiResponse<UpdateVideoSourceResponse>, ApiError> {
let rule_display = request.rule.as_ref().map(|rule| rule.to_string());
let active_model = match source_type.as_str() {
"collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
let mut active_model: collection::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
active_model.rule = Set(request.rule);
_ActiveModel::Collection(active_model)
}),
"favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
let mut active_model: favorite::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
active_model.rule = Set(request.rule);
_ActiveModel::Favorite(active_model)
}),
"submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
let mut active_model: submission::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
active_model.rule = Set(request.rule);
_ActiveModel::Submission(active_model)
}),
"watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? {
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
// 稍后再看需要做特殊处理get 时如果稍后再看不存在返回的是 id 为 1 的假记录
// 因此此处可能是更新也可能是插入,做个额外的处理
Some(model) => {
@@ -160,6 +189,7 @@ pub async fn update_video_source(
let mut active_model: watch_later::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
active_model.rule = Set(request.rule);
Some(_ActiveModel::WatchLater(active_model))
}
None => {
@@ -170,6 +200,7 @@ pub async fn update_video_source(
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
path: Set(request.path),
enabled: Set(request.enabled),
rule: Set(request.rule),
..Default::default()
}))
}
@@ -180,13 +211,90 @@ pub async fn update_video_source(
let Some(active_model) = active_model else {
return Err(InnerApiError::NotFound(id).into());
};
active_model.save(db.as_ref()).await?;
active_model.save(&db).await?;
Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display }))
}
pub async fn evaluate_video_source(
Path((source_type, id)): Path<(String, i32)>,
Extension(db): Extension<DatabaseConnection>,
) -> Result<ApiResponse<bool>, ApiError> {
// 找出对应 source 的规则与 video 筛选条件
let (rule, filter_condition) = match source_type.as_str() {
"collections" => (
collection::Entity::find_by_id(id)
.select_only()
.column(collection::Column::Rule)
.into_tuple::<Option<Rule>>()
.one(&db)
.await?
.and_then(|r| r),
video::Column::CollectionId.eq(id),
),
"favorites" => (
favorite::Entity::find_by_id(id)
.select_only()
.column(favorite::Column::Rule)
.into_tuple::<Option<Rule>>()
.one(&db)
.await?
.and_then(|r| r),
video::Column::FavoriteId.eq(id),
),
"submissions" => (
submission::Entity::find_by_id(id)
.select_only()
.column(submission::Column::Rule)
.into_tuple::<Option<Rule>>()
.one(&db)
.await?
.and_then(|r| r),
video::Column::SubmissionId.eq(id),
),
"watch_later" => (
watch_later::Entity::find_by_id(id)
.select_only()
.column(watch_later::Column::Rule)
.into_tuple::<Option<Rule>>()
.one(&db)
.await?
.and_then(|r| r),
video::Column::WatchLaterId.eq(id),
),
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
};
let videos: Vec<(video::Model, Vec<page::Model>)> = video::Entity::find()
.filter(filter_condition)
.find_with_related(page::Entity)
.all(&db)
.await?;
let video_should_download_pairs = videos
.into_iter()
.map(|(video, pages)| (video.id, rule.evaluate_model(&video, &pages)))
.collect::<Vec<(i32, bool)>>();
let txn = db.begin().await?;
for chunk in video_should_download_pairs.chunks(500) {
let sql = format!(
"WITH tempdata(id, should_download) AS (VALUES {}) \
UPDATE video \
SET should_download = tempdata.should_download \
FROM tempdata \
WHERE video.id = tempdata.id",
chunk
.iter()
.map(|item| format!("({}, {})", item.0, item.1))
.collect::<Vec<_>>()
.join(", ")
);
txn.execute_unprepared(&sql).await?;
}
txn.commit().await?;
Ok(ApiResponse::ok(true))
}
/// 新增收藏夹订阅
pub async fn insert_favorite(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
@@ -196,17 +304,17 @@ pub async fn insert_favorite(
f_id: Set(favorite_info.id),
name: Set(favorite_info.title.clone()),
path: Set(request.path),
enabled: Set(true),
enabled: Set(false),
..Default::default()
})
.exec(db.as_ref())
.exec(&db)
.await?;
Ok(ApiResponse::ok(true))
}
/// 新增合集/列表订阅
pub async fn insert_collection(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
@@ -225,10 +333,10 @@ pub async fn insert_collection(
r#type: Set(collection_info.collection_type.into()),
name: Set(collection_info.name.clone()),
path: Set(request.path),
enabled: Set(true),
enabled: Set(false),
..Default::default()
})
.exec(db.as_ref())
.exec(&db)
.await?;
Ok(ApiResponse::ok(true))
@@ -236,7 +344,7 @@ pub async fn insert_collection(
/// 新增投稿订阅
pub async fn insert_submission(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
@@ -246,10 +354,10 @@ pub async fn insert_submission(
upper_id: Set(upper.mid.parse()?),
upper_name: Set(upper.name),
path: Set(request.path),
enabled: Set(true),
enabled: Set(false),
..Default::default()
})
.exec(db.as_ref())
.exec(&db)
.await?;
Ok(ApiResponse::ok(true))
}

View File

@@ -1,5 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use axum::extract::{Extension, Path, Query};
@@ -31,7 +30,7 @@ pub(super) fn router() -> Router {
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
pub async fn get_videos(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Query(params): Query<VideosRequest>,
) -> Result<ApiResponse<VideosResponse>, ApiError> {
let mut query = video::Entity::find();
@@ -48,7 +47,7 @@ pub async fn get_videos(
if let Some(query_word) = params.query {
query = query.filter(video::Column::Name.contains(query_word));
}
let total_count = query.clone().count(db.as_ref()).await?;
let total_count = query.clone().count(&db).await?;
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) {
(page, page_size)
} else {
@@ -58,7 +57,7 @@ pub async fn get_videos(
videos: query
.order_by_desc(video::Column::Id)
.into_partial_model::<VideoInfo>()
.paginate(db.as_ref(), page_size)
.paginate(&db, page_size)
.fetch_page(page)
.await?,
total_count,
@@ -67,17 +66,15 @@ pub async fn get_videos(
pub async fn get_video(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
) -> Result<ApiResponse<VideoResponse>, ApiError> {
let (video_info, pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
.all(&db)
)?;
let Some(video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());
@@ -90,18 +87,16 @@ pub async fn get_video(
pub async fn reset_video(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Json(request): Json<ResetRequest>,
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
let (video_info, pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
.all(&db)
)?;
let Some(mut video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());
@@ -150,13 +145,13 @@ pub async fn reset_video(
}
pub async fn reset_all_videos(
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
Json(request): Json<ResetRequest>,
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
// 先查询所有视频和页面数据
let (all_videos, all_pages) = tokio::try_join!(
video::Entity::find().into_partial_model::<VideoInfo>().all(db.as_ref()),
page::Entity::find().into_partial_model::<PageInfo>().all(db.as_ref())
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
)?;
let resetted_pages_info = all_pages
.into_iter()
@@ -210,18 +205,16 @@ pub async fn reset_all_videos(
pub async fn update_video_status(
Path(id): Path<i32>,
Extension(db): Extension<Arc<DatabaseConnection>>,
Extension(db): Extension<DatabaseConnection>,
ValidatedJson(request): ValidatedJson<UpdateVideoStatusRequest>,
) -> Result<ApiResponse<UpdateVideoStatusResponse>, ApiError> {
let (video_info, mut pages_info) = tokio::try_join!(
video::Entity::find_by_id(id)
.into_partial_model::<VideoInfo>()
.one(db.as_ref()),
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
page::Entity::find()
.filter(page::Column::VideoId.eq(id))
.order_by_asc(page::Column::Cid)
.into_partial_model::<PageInfo>()
.all(db.as_ref())
.all(&db)
)?;
let Some(mut video_info) = video_info else {
return Err(InnerApiError::NotFound(id).into());

View File

@@ -118,7 +118,7 @@ impl WebSocketHandler {
let rx = log_writer_clone.sender.subscribe();
let log_stream = futures::stream::iter(history_logs.into_iter())
.chain(BroadcastStream::new(rx).filter_map(async |msg| msg.ok()))
.map(|msg| ServerEvent::Logs(msg));
.map(ServerEvent::Logs);
pin!(log_stream);
while let Some(event) = log_stream.next().await {
if let Err(e) = tx_clone.send(event).await {
@@ -133,8 +133,8 @@ impl WebSocketHandler {
if task_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
let tx_clone = tx.clone();
task_handle = Some(tokio::spawn(async move {
let mut stream = WatchStream::new(TASK_STATUS_NOTIFIER.subscribe())
.map(|status| ServerEvent::Tasks(status));
let mut stream =
WatchStream::new(TASK_STATUS_NOTIFIER.subscribe()).map(ServerEvent::Tasks);
while let Some(event) = stream.next().await {
if let Err(e) = tx_clone.send(event).await {
error!("Failed to send task status: {:?}", e);
@@ -179,7 +179,7 @@ impl WebSocketHandler {
// 添加订阅者
async fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: tokio::sync::mpsc::Sender<ServerEvent>) {
self.sysinfo_subscribers.insert(uuid, sender);
if self.sysinfo_subscribers.len() > 0
if !self.sysinfo_subscribers.is_empty()
&& self
.sysinfo_handles
.read()
@@ -235,10 +235,10 @@ impl WebSocketHandler {
async fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
self.sysinfo_subscribers.remove(&uuid);
if self.sysinfo_subscribers.is_empty() {
if let Some(handle) = self.sysinfo_handles.write().take() {
handle.abort();
}
if self.sysinfo_subscribers.is_empty()
&& let Some(handle) = self.sysinfo_handles.write().take()
{
handle.abort();
}
}
}

View File

@@ -263,39 +263,37 @@ impl PageAnalyzer {
});
}
}
if !filter_option.no_hires {
if let Some(flac) = self.info.pointer_mut("/dash/flac/audio") {
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
bail!("invalid flac stream");
};
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
if !filter_option.no_hires
&& let Some(flac) = self.info.pointer_mut("/dash/flac/audio")
{
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
bail!("invalid flac stream");
};
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
}
if !filter_option.no_dolby_audio {
if let Some(dolby_audio) = self
if !filter_option.no_dolby_audio
&& let Some(dolby_audio) = self
.info
.pointer_mut("/dash/dolby/audio/0")
.and_then(|a| a.as_object_mut())
{
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
bail!("invalid dolby audio stream");
};
let quality =
AudioQuality::from_repr(quality as usize).context("invalid dolby audio stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
{
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
bail!("invalid dolby audio stream");
};
let quality = AudioQuality::from_repr(quality as usize).context("invalid dolby audio stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
streams.push(Stream::DashAudio {
url: url.to_string(),
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
quality,
});
}
}
Ok(streams)

View File

@@ -4,6 +4,7 @@ use anyhow::Result;
use leaky_bucket::RateLimiter;
use reqwest::{Method, header};
use sea_orm::DatabaseConnection;
use ua_generator::ua;
use crate::bilibili::Credential;
use crate::bilibili::credential::WbiImg;
@@ -19,9 +20,7 @@ impl Client {
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
),
header::HeaderValue::from_static(ua::spoof_chrome_ua()),
);
headers.insert(
header::REFERER,

View File

@@ -10,13 +10,23 @@ use serde_json::Value;
use crate::bilibili::credential::encoded_query;
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
#[derive(PartialEq, Eq, Hash, Clone, Debug, Deserialize, Default, Copy)]
#[derive(PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
pub enum CollectionType {
Series,
#[default]
Season,
}
impl<'de> serde::Deserialize<'de> for CollectionType {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = i32::deserialize(deserializer)?;
CollectionType::try_from(v).map_err(serde::de::Error::custom)
}
}
impl From<CollectionType> for i32 {
fn from(v: CollectionType) -> Self {
match v {
@@ -26,16 +36,24 @@ impl From<CollectionType> for i32 {
}
}
impl From<i32> for CollectionType {
fn from(v: i32) -> Self {
impl TryFrom<i32> for CollectionType {
type Error = anyhow::Error;
fn try_from(v: i32) -> Result<Self, Self::Error> {
match v {
1 => CollectionType::Series,
2 => CollectionType::Season,
_ => panic!("invalid collection type"),
1 => Ok(CollectionType::Series),
2 => Ok(CollectionType::Season),
v => Err(anyhow!("got invalid collection type {}", v)),
}
}
}
impl CollectionType {
pub fn from_expected(v: i32) -> Self {
Self::try_from(v).expect("invalid collection type")
}
}
impl Display for CollectionType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {

View File

@@ -184,7 +184,7 @@ impl<'a, W: AsyncWrite> AssWriter<'a, W> {
}
}
fn escape_text(text: &str) -> Cow<str> {
fn escape_text(text: &'_ str) -> Cow<'_, str> {
let text = text.trim();
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
Cow::from(text.replace('\n', "\\N"))

View File

@@ -26,7 +26,7 @@ pub struct DanmakuOption {
pub bottom_percentage: f64,
/// 透明度0-255
pub opacity: u8,
/// 是否加粗1代表是0代表否
/// 是否加粗1 代表是0 代表否
pub bold: bool,
/// 描边
pub outline: f64,

View File

@@ -39,7 +39,7 @@ pub struct Danmu {
impl Danmu {
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
///
/// 汉字算一个全宽英文算2/3宽
/// 汉字算一个全宽,英文算 2/3
pub fn length(&self, config: &CanvasConfig<'_>) -> f64 {
let pts = config.danmaku_option.font_size
* self

View File

@@ -29,7 +29,7 @@ pub struct SubTitleItem {
impl SubTitleInfo {
pub fn is_ai_sub(&self) -> bool {
// ai aisubtitle.hdslb.com/bfs/ai_subtitle/xxxx
// 非 ai aisubtitle.hdslb.com/bfs/subtitle/xxxx
// 非 aiaisubtitle.hdslb.com/bfs/subtitle/xxxx
self.subtitle_url.contains("ai_subtitle")
}
}

View File

@@ -1,4 +1,4 @@
use anyhow::{Result, ensure};
use anyhow::{Context, Result, ensure};
use futures::TryStreamExt;
use futures::stream::FuturesUnordered;
use prost::Message;
@@ -16,19 +16,6 @@ pub struct Video<'a> {
pub bvid: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Tag {
pub tag_name: String,
}
impl serde::Serialize for Tag {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.tag_name)
}
}
#[derive(Debug, serde::Deserialize, Default)]
pub struct PageInfo {
pub cid: i64,
@@ -84,8 +71,8 @@ impl<'a> Video<'a> {
Ok(serde_json::from_value(res["data"].take())?)
}
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
let mut res = self
pub async fn get_tags(&self) -> Result<Vec<String>> {
let res = self
.client
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
.await
@@ -96,10 +83,15 @@ impl<'a> Video<'a> {
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(res["data"].take())?)
Ok(res["data"]
.as_array()
.context("tags is not an array")?
.iter()
.filter_map(|v| v["tag_name"].as_str().map(String::from))
.collect())
}
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter<'a>> {
let tasks = FuturesUnordered::new();
for i in 1..=page.duration.div_ceil(360) {
tasks.push(self.get_danmaku_segment(page, i as i64));
@@ -171,14 +163,18 @@ impl<'a> Video<'a> {
.await?
.validate()?;
// 接口返回的信息,包含了一系列的字幕,每个字幕包含了字幕的语言和 json 下载地址
let subtitles_info: SubTitlesInfo = serde_json::from_value(res["data"]["subtitle"].take())?;
let tasks = subtitles_info
.subtitles
.into_iter()
.filter(|v| !v.is_ai_sub())
.map(|v| self.get_subtitle(v))
.collect::<FuturesUnordered<_>>();
tasks.try_collect().await
match serde_json::from_value::<Option<SubTitlesInfo>>(res["data"]["subtitle"].take())? {
Some(subtitles_info) => {
let tasks = subtitles_info
.subtitles
.into_iter()
.filter(|v| !v.is_ai_sub())
.map(|v| self.get_subtitle(v))
.collect::<FuturesUnordered<_>>();
tasks.try_collect().await
}
None => Ok(vec![]),
}
}
async fn get_subtitle(&self, info: SubTitleInfo) -> Result<SubTitle> {

View File

@@ -81,7 +81,7 @@ mod tests {
"test_truncate",
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
编译将发生在一个被称作「Cargo」的构建系统中。在这里被引用的指针将被授予「生命周期」之力导引对象安全。\
你将扮演一位名为「Rustacean」的神秘角色, 在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
你将扮演一位名为「Rustacean」的神秘角色在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
征服她们、通过编译同时逐步发掘「C++」程序崩溃的真相。"})
)
.unwrap(),

View File

@@ -1,6 +1,10 @@
use std::time::Duration;
use anyhow::{Context, Result};
use bili_sync_migration::{Migrator, MigratorTrait};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
use sea_orm::sqlx::{ConnectOptions as SqlxConnectOptions, Sqlite};
use sea_orm::{ConnectOptions, Database, DatabaseConnection, SqlxSqliteConnector};
use crate::config::CONFIG_DIR;
@@ -11,10 +15,24 @@ fn database_url() -> String {
async fn database_connection() -> Result<DatabaseConnection> {
let mut option = ConnectOptions::new(database_url());
option
.max_connections(100)
.max_connections(50)
.min_connections(5)
.acquire_timeout(std::time::Duration::from_secs(90));
Ok(Database::connect(option).await?)
.acquire_timeout(Duration::from_secs(90));
let connect_option = option
.get_url()
.parse::<SqliteConnectOptions>()
.context("Failed to parse database URL")?
.disable_statement_logging()
.busy_timeout(Duration::from_secs(90))
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.optimize_on_close(true, None);
Ok(SqlxSqliteConnector::from_sqlx_sqlite_pool(
option
.sqlx_pool_options::<Sqlite>()
.connect_with(connect_option)
.await?,
))
}
async fn migrate_database() -> Result<()> {
@@ -26,9 +44,9 @@ async fn migrate_database() -> Result<()> {
/// 进行数据库迁移并获取数据库连接,供外部使用
pub async fn setup_database() -> Result<DatabaseConnection> {
tokio::fs::create_dir_all(CONFIG_DIR.as_path())
.await
.context("Failed to create config directory")?;
tokio::fs::create_dir_all(CONFIG_DIR.as_path()).await.context(
"Failed to create config directory. Please check if you have granted necessary permissions to your folder.",
)?;
migrate_database().await.context("Failed to migrate database")?;
database_connection().await.context("Failed to connect to database")
}

View File

@@ -149,7 +149,7 @@ impl Downloader {
}
}
}
res.with_context(|| format!("failed to download from {:?}", urls))
res.context("failed to download file")
}
pub async fn merge(&self, video_path: &Path, audio_path: &Path, output_path: &Path) -> Result<()> {
@@ -167,7 +167,8 @@ impl Downloader {
output_path.to_string_lossy().as_ref(),
])
.output()
.await?;
.await
.context("failed to run ffmpeg")?;
if !output.status.success() {
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
}

View File

@@ -42,10 +42,10 @@ impl From<Result<ExecutionStatus>> for ExecutionStatus {
}
}
// 未包裹的 reqwest::Error
if let Some(error) = cause.downcast_ref::<reqwest::Error>() {
if is_ignored_reqwest_error(error) {
return ExecutionStatus::Ignored(err);
}
if let Some(error) = cause.downcast_ref::<reqwest::Error>()
&& is_ignored_reqwest_error(error)
{
return ExecutionStatus::Ignored(err);
}
}
ExecutionStatus::Failed(err)

View File

@@ -47,14 +47,14 @@ async fn main() {
if !cfg!(debug_assertions) {
spawn_task(
"定时下载",
video_downloader(connection, bili_client),
video_downloader(connection.clone(), bili_client),
&tracker,
token.clone(),
);
}
tracker.close();
handle_shutdown(tracker, token).await
handle_shutdown(connection, tracker, token).await
}
fn spawn_task(
@@ -77,7 +77,7 @@ fn spawn_task(
}
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
async fn init() -> (DatabaseConnection, LogHelper) {
let (tx, _rx) = tokio::sync::broadcast::channel(30);
let log_history = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
let log_writer = LogHelper::new(tx, log_history.clone());
@@ -85,7 +85,7 @@ async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
init_logger(&ARGS.log_level, Some(log_writer.clone()));
info!("欢迎使用 Bili-Sync当前程序版本{}", config::version());
info!("项目地址https://github.com/amtoaer/bili-sync");
let connection = Arc::new(setup_database().await.expect("数据库初始化失败"));
let connection = setup_database().await.expect("数据库初始化失败");
info!("数据库初始化完成");
VersionedConfig::init(&connection).await.expect("配置初始化失败");
info!("配置初始化完成");
@@ -93,16 +93,21 @@ async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
(connection, log_writer)
}
async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) {
async fn handle_shutdown(connection: DatabaseConnection, tracker: TaskTracker, token: CancellationToken) {
tokio::select! {
_ = tracker.wait() => {
error!("所有任务均已终止,程序退出")
error!("所有任务均已终止..")
}
_ = terminate() => {
info!("接收到终止信号,正在终止任务..");
info!("接收到终止信号,开始终止任务..");
token.cancel();
tracker.wait().await;
info!("所有任务均已终止,程序退出");
info!("所有任务均已终止..");
}
}
info!("正在关闭数据库连接..");
match connection.close().await {
Ok(()) => info!("数据库连接已关闭,程序结束"),
Err(e) => error!("关闭数据库连接时遇到错误:{:#},程序异常结束", e),
}
}

View File

@@ -21,12 +21,12 @@ use crate::config::VersionedConfig;
struct Asset;
pub async fn http_server(
database_connection: Arc<DatabaseConnection>,
database_connection: DatabaseConnection,
bili_client: Arc<BiliClient>,
log_writer: LogHelper,
) -> Result<()> {
let app = router()
.fallback_service(get(frontend_files))
.fallback_service(get(frontend_files).head(frontend_files))
.layer(Extension(database_connection))
.layer(Extension(bili_client))
.layer(Extension(log_writer));
@@ -34,7 +34,7 @@ pub async fn http_server(
let listener = tokio::net::TcpListener::bind(&config.bind_address)
.await
.context("bind address failed")?;
info!("开始运行管理页: http://{}", config.bind_address);
info!("开始运行管理页http://{}", config.bind_address);
Ok(axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await?)
}
@@ -48,34 +48,51 @@ async fn frontend_files(request: Request) -> impl IntoResponse {
};
let mime_type = content.mime_type();
let content_type = mime_type.as_deref().unwrap_or("application/octet-stream");
if cfg!(debug_assertions) {
(
[(header::CONTENT_TYPE, content_type)],
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
content.data().unwrap(),
)
.into_response()
} else {
let accepted_encodings = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
.unwrap_or_default();
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
if accepted_encodings.contains(encoding) {
if let Some(data) = data {
return (
[
(header::CONTENT_TYPE, content_type),
(header::CONTENT_ENCODING, encoding),
],
data,
)
.into_response();
}
}
}
"Unsupported Encoding".into_response()
let default_headers = [
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "no-cache"),
(header::ETAG, &content.hash()),
];
if let Some(if_none_match) = request.headers().get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = if_none_match.to_str()
&& client_etag == content.hash()
{
return (StatusCode::NOT_MODIFIED, default_headers).into_response();
}
if request.method() == axum::http::Method::HEAD {
return (StatusCode::OK, default_headers).into_response();
}
if cfg!(debug_assertions) {
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
return (StatusCode::OK, default_headers, content.data().unwrap()).into_response();
}
let accepted_encodings = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
.unwrap_or_default();
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
if accepted_encodings.contains(encoding)
&& let Some(data) = data
{
return (
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "no-cache"),
(header::ETAG, &content.hash()),
(header::CONTENT_ENCODING, encoding),
],
data,
)
.into_response();
}
}
(
StatusCode::NOT_ACCEPTABLE,
"Client must support gzip or brotli compression",
)
.into_response()
}

View File

@@ -11,7 +11,7 @@ use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
use crate::workflow::process_video_source;
/// 启动周期下载视频的任务
pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) {
pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<BiliClient>) {
let mut anchor = chrono::Local::now().date_naive();
loop {
info!("开始执行本轮视频下载任务..");

View File

@@ -124,7 +124,7 @@ impl VideoInfo {
ctime: Set(ctime.naive_utc()),
pubtime: Set(pubtime.naive_utc()),
favtime: if base_model.favtime != NaiveDateTime::default() {
NotSet // 之前设置了 favtime不覆盖
Set(base_model.favtime) // 之前设置了 favtime使用之前的值等价于 unset但设置上以支持后续的规则匹配
} else {
Set(pubtime.naive_utc()) // 未设置过 favtime使用 pubtime 填充
},
@@ -152,10 +152,7 @@ impl VideoInfo {
}
impl PageInfo {
pub fn into_active_model(
self,
video_model: &bili_sync_entity::video::Model,
) -> bili_sync_entity::page::ActiveModel {
pub fn into_active_model(self, video_model_id: i32) -> bili_sync_entity::page::ActiveModel {
let (width, height) = match &self.dimension {
Some(d) => {
if d.rotate == 0 {
@@ -167,7 +164,7 @@ impl PageInfo {
None => (None, None),
};
bili_sync_entity::page::ActiveModel {
video_id: Set(video_model.id),
video_id: Set(video_model_id),
cid: Set(self.cid),
pid: Set(self.page),
name: Set(self.name),

View File

@@ -3,6 +3,7 @@ pub mod filenamify;
pub mod format_arg;
pub mod model;
pub mod nfo;
pub mod rule;
pub mod signal;
pub mod status;
pub mod task_notifier;

View File

@@ -6,7 +6,7 @@ use sea_orm::sea_query::{OnConflict, SimpleExpr};
use sea_orm::{DatabaseTransaction, TransactionTrait};
use crate::adapter::{VideoSource, VideoSourceEnum};
use crate::bilibili::{PageInfo, VideoInfo};
use crate::bilibili::VideoInfo;
use crate::config::{Config, LegacyConfig};
use crate::utils::status::STATUS_COMPLETED;
@@ -41,6 +41,7 @@ pub async fn filter_unhandled_video_pages(
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
.and(video::Column::Category.eq(2))
.and(video::Column::SinglePage.is_not_null())
.and(video::Column::ShouldDownload.eq(true))
.and(additional_expr),
)
.find_with_related(page::Entity)
@@ -73,16 +74,8 @@ pub async fn create_videos(
}
/// 尝试创建 Page Model如果发生冲突则忽略
pub async fn create_pages(
pages_info: Vec<PageInfo>,
video_model: &bili_sync_entity::video::Model,
connection: &DatabaseTransaction,
) -> Result<()> {
let page_models = pages_info
.into_iter()
.map(|p| p.into_active_model(video_model))
.collect::<Vec<page::ActiveModel>>();
for page_chunk in page_models.chunks(50) {
pub async fn create_pages(pages_model: Vec<page::ActiveModel>, connection: &DatabaseTransaction) -> Result<()> {
for page_chunk in pages_model.chunks(200) {
page::Entity::insert_many(page_chunk.to_vec())
.on_conflict(
OnConflict::columns([page::Column::VideoId, page::Column::Pid])

View File

@@ -261,7 +261,7 @@ mod tests {
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
),
bvid: "BV1nWcSeeEkV".to_string(),
tags: Some(serde_json::json!(["tag1", "tag2"])),
tags: Some(vec!["tag1".to_owned(), "tag2".to_owned()].into()),
..Default::default()
};
assert_eq!(
@@ -343,10 +343,7 @@ impl<'a> From<&'a video::Model> for Movie<'a> {
NFOTimeType::FavTime => video.favtime,
NFOTimeType::PubTime => video.pubtime,
},
tags: video
.tags
.as_ref()
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
}
}
}
@@ -363,10 +360,7 @@ impl<'a> From<&'a video::Model> for TVShow<'a> {
NFOTimeType::FavTime => video.favtime,
NFOTimeType::PubTime => video.pubtime,
},
tags: video
.tags
.as_ref()
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
}
}
}

View File

@@ -0,0 +1,267 @@
use bili_sync_entity::rule::{AndGroup, Condition, Rule, RuleTarget};
use bili_sync_entity::{page, video};
use chrono::{Local, NaiveDateTime};
pub(crate) trait Evaluatable<T> {
fn evaluate(&self, value: T) -> bool;
}
pub(crate) trait FieldEvaluatable {
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool;
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool;
}
impl Evaluatable<&str> for Condition<String> {
fn evaluate(&self, value: &str) -> bool {
match self {
Condition::Equals(expected) => expected == value,
Condition::Contains(substring) => value.contains(substring),
Condition::Prefix(prefix) => value.starts_with(prefix),
Condition::Suffix(suffix) => value.ends_with(suffix),
Condition::MatchesRegex(_, regex) => regex.is_match(value),
_ => false,
}
}
}
impl Evaluatable<usize> for Condition<usize> {
fn evaluate(&self, value: usize) -> bool {
match self {
Condition::Equals(expected) => *expected == value,
Condition::GreaterThan(threshold) => value > *threshold,
Condition::LessThan(threshold) => value < *threshold,
Condition::Between(start, end) => value > *start && value < *end,
_ => false,
}
}
}
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
fn evaluate(&self, value: &NaiveDateTime) -> bool {
match self {
Condition::Equals(expected) => expected == value,
Condition::GreaterThan(threshold) => value > threshold,
Condition::LessThan(threshold) => value < threshold,
Condition::Between(start, end) => value > start && value < end,
_ => false,
}
}
}
impl FieldEvaluatable for RuleTarget {
/// 修改模型后进行评估,此时能访问的是未保存的 activeModel就地使用 activeModel 评估
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
match self {
RuleTarget::Title(cond) => video.name.try_as_ref().is_some_and(|title| cond.evaluate(title)),
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
RuleTarget::Tags(cond) => video
.tags
.try_as_ref()
.and_then(|t| t.as_ref())
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
RuleTarget::FavTime(cond) => video
.favtime
.try_as_ref()
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
.is_some_and(|fav_time| cond.evaluate(&fav_time)),
RuleTarget::PubTime(cond) => video
.pubtime
.try_as_ref()
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
.is_some_and(|pub_time| cond.evaluate(&pub_time)),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
}
}
/// 手动触发对历史视频的评估,拿到的是原始 Model直接使用
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
match self {
RuleTarget::Title(cond) => cond.evaluate(&video.name),
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
RuleTarget::Tags(cond) => video
.tags
.as_ref()
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
}
}
}
impl FieldEvaluatable for AndGroup {
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
self.iter().all(|target| target.evaluate(video, pages))
}
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
self.iter().all(|target| target.evaluate_model(video, pages))
}
}
impl FieldEvaluatable for Rule {
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
if self.0.is_empty() {
return true;
}
self.0.iter().any(|group| group.evaluate(video, pages))
}
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
if self.0.is_empty() {
return true;
}
self.0.iter().any(|group| group.evaluate_model(video, pages))
}
}
/// 对于 Option<Rule> 如果 rule 不存在应该被认为是通过评估
impl FieldEvaluatable for Option<Rule> {
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
self.as_ref().is_none_or(|rule| rule.evaluate(video, pages))
}
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
self.as_ref().is_none_or(|rule| rule.evaluate_model(video, pages))
}
}
#[cfg(test)]
mod tests {
use bili_sync_entity::page;
use chrono::NaiveDate;
use sea_orm::ActiveValue::Set;
use super::*;
#[test]
fn test_display() {
let test_cases = vec![
(
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
"「(标题包含“唐氏”)」",
),
(
Rule(vec![vec![
RuleTarget::Title(Condition::Prefix("街霸".to_string())),
RuleTarget::Tags(Condition::Contains("套路".to_string())),
]]),
"「(标题以“街霸”开头)且(标签包含“套路”)」",
),
(
Rule(vec![
vec![
RuleTarget::Title(Condition::Contains("Rust".to_string())),
RuleTarget::PageCount(Condition::GreaterThan(5)),
],
vec![
RuleTarget::Tags(Condition::Suffix("入门".to_string())),
RuleTarget::PubTime(Condition::GreaterThan(
NaiveDate::from_ymd_opt(2023, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
)),
],
]),
"标题包含“Rust”视频分页数量大于“5”」或「标签以“入门”结尾发布时间大于“2023-01-01 00:00:00”",
),
(
Rule(vec![vec![
RuleTarget::Not(Box::new(RuleTarget::Title(Condition::Contains("广告".to_string())))),
RuleTarget::PageCount(Condition::LessThan(10)),
]]),
"标题不包含“广告”视频分页数量小于“10”",
),
(
Rule(vec![vec![
RuleTarget::FavTime(Condition::Between(
NaiveDate::from_ymd_opt(2023, 6, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
NaiveDate::from_ymd_opt(2023, 12, 31)
.unwrap()
.and_hms_opt(23, 59, 59)
.unwrap(),
)),
// autocorrect-disable
RuleTarget::Tags(Condition::MatchesRegex(
"技术|教程".to_string(),
regex::Regex::new("技术|教程").unwrap(),
)),
]]),
"收藏时间在“2023-06-01 00:00:00”和“2023-12-31 23:59:59”之间标签匹配“技术|教程”)」",
// autocorrect-enable
),
];
for (rule, expected) in test_cases {
assert_eq!(rule.to_string(), expected);
}
}
#[test]
fn test_evaluate() {
let test_cases = vec![
(
(
video::ActiveModel {
name: Set("骂谁唐氏呢!!!".to_string()),
..Default::default()
},
vec![],
),
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
true,
),
(
(
video::ActiveModel::default(),
vec![page::ActiveModel::default(); 2],
),
Rule(vec![vec![RuleTarget::PageCount(Condition::Equals(1))]]),
false,
),
(
(
video::ActiveModel{
tags: Set(Some(vec!["原神".to_owned(),"永雏塔菲".to_owned(),"虚拟主播".to_owned()].into())),
..Default::default()
},
vec![],
),
Rule (vec![vec![RuleTarget::Not(Box::new(RuleTarget::Tags(Condition::Equals(
"原神".to_string(),
))))]],
),
false,
),
(
(
video::ActiveModel {
name: Set(
"万字怒扒网易《归唐》底裤中国首款大厂买断制单机靠谱吗——全网最全官方非独家幕后关于《归唐》PV 的所有秘密~都在这里了~".to_owned(),
),
..Default::default()
},
vec![],
),
Rule(vec![vec![RuleTarget::Not(Box::new(RuleTarget::Title(Condition::MatchesRegex(
r"^\S+字(解析|怒扒|拆解)".to_owned(),
regex::Regex::new(r"^\S+字(解析|怒扒)").unwrap(),
))))]],
),
false,
),
];
for ((video, pages), rule, expected) in test_cases {
assert_eq!(rule.evaluate(&video, &pages), expected);
}
}
}

View File

@@ -176,7 +176,7 @@ pub type VideoStatus = Status<5>;
pub type PageStatus = Status<5>;
#[cfg(test)]
mod test {
mod tests {
use anyhow::anyhow;
use super::*;

View File

@@ -7,7 +7,7 @@ use crate::config::VersionedConfig;
pub static TASK_STATUS_NOTIFIER: LazyLock<TaskStatusNotifier> = LazyLock::new(TaskStatusNotifier::new);
#[derive(Serialize)]
#[derive(Serialize, Default)]
pub struct TaskStatus {
is_running: bool,
last_run: Option<chrono::DateTime<chrono::Local>>,
@@ -21,17 +21,6 @@ pub struct TaskStatusNotifier {
rx: tokio::sync::watch::Receiver<Arc<TaskStatus>>,
}
impl Default for TaskStatus {
fn default() -> Self {
Self {
is_running: false,
last_run: None,
last_finish: None,
next_run: None,
}
}
}
impl TaskStatusNotifier {
pub fn new() -> Self {
let (tx, rx) = tokio::sync::watch::channel(Arc::new(TaskStatus::default()));
@@ -42,7 +31,7 @@ impl TaskStatusNotifier {
}
}
pub async fn start_running(&self) -> MutexGuard<()> {
pub async fn start_running(&'_ self) -> MutexGuard<'_, ()> {
let lock = self.mutex.lock().await;
let _ = self.tx.send(Arc::new(TaskStatus {
is_running: true,
@@ -55,7 +44,7 @@ impl TaskStatusNotifier {
pub fn finish_running(&self, _lock: MutexGuard<()>) {
let last_status = self.tx.borrow();
let last_run = last_status.last_run.clone();
let last_run = last_status.last_run;
drop(last_status);
let config = VersionedConfig::get().load();
let now = chrono::Local::now();

View File

@@ -23,6 +23,7 @@ use crate::utils::model::{
update_videos_model,
};
use crate::utils::nfo::NFO;
use crate::utils::rule::FieldEvaluatable;
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
/// 完整地处理某个视频来源
@@ -102,43 +103,49 @@ pub async fn fetch_video_details(
) -> Result<()> {
video_source.log_fetch_video_start();
let videos_model = filter_unfilled_videos(video_source.filter_expr(), connection).await?;
let semaphore = Semaphore::new(VersionedConfig::get().load().concurrent_limit.video);
let semaphore_ref = &semaphore;
let tasks = videos_model
.into_iter()
.map(|video_model| {
async move {
let video = Video::new(bili_client, video_model.bvid.clone());
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
match info {
Err(e) => {
error!(
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
&video_model.bvid, &video_model.name, e
);
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
video_active_model.valid = Set(false);
video_active_model.save(connection).await?;
}
.map(|video_model| async move {
let _permit = semaphore_ref.acquire().await.context("acquire semaphore failed")?;
let video = Video::new(bili_client, video_model.bvid.clone());
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
match info {
Err(e) => {
error!(
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
&video_model.bvid, &video_model.name, e
);
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
video_active_model.valid = Set(false);
video_active_model.save(connection).await?;
}
Ok((tags, mut view_info)) => {
let VideoInfo::Detail { pages, .. } = &mut view_info else {
unreachable!()
};
let pages = std::mem::take(pages);
let pages_len = pages.len();
let txn = connection.begin().await?;
// 将分页信息写入数据库
create_pages(pages, &video_model, &txn).await?;
let mut video_active_model = view_info.into_detail_model(video_model);
video_source.set_relation_id(&mut video_active_model);
video_active_model.single_page = Set(Some(pages_len == 1));
video_active_model.tags = Set(Some(serde_json::to_value(tags)?));
video_active_model.save(&txn).await?;
txn.commit().await?;
}
};
Ok::<_, anyhow::Error>(())
}
}
Ok((tags, mut view_info)) => {
let VideoInfo::Detail { pages, .. } = &mut view_info else {
unreachable!()
};
// 构造 page model
let pages = std::mem::take(pages);
let pages = pages
.into_iter()
.map(|p| p.into_active_model(video_model.id))
.collect::<Vec<page::ActiveModel>>();
// 更新 video model 的各项有关属性
let mut video_active_model = view_info.into_detail_model(video_model);
video_source.set_relation_id(&mut video_active_model);
video_active_model.single_page = Set(Some(pages.len() == 1));
video_active_model.tags = Set(Some(tags.into()));
video_active_model.should_download = Set(video_source.rule().evaluate(&video_active_model, &pages));
let txn = connection.begin().await?;
create_pages(pages, &txn).await?;
video_active_model.save(&txn).await?;
txn.commit().await?;
}
};
Ok::<_, anyhow::Error>(())
})
.collect::<FuturesUnordered<_>>();
tasks.try_collect::<Vec<_>>().await?;
@@ -281,18 +288,18 @@ pub async fn download_video_pages(
ExecutionStatus::Succeeded => info!("处理视频「{}」{}成功", &video_model.name, task_name),
ExecutionStatus::Ignored(e) => {
error!(
"处理视频「{}」{}出现常见错误,已忽略: {:#}",
"处理视频「{}」{}出现常见错误,已忽略{:#}",
&video_model.name, task_name, e
)
}
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => {
error!("处理视频「{}」{}失败: {:#}", &video_model.name, task_name, e)
error!("处理视频「{}」{}失败{:#}", &video_model.name, task_name, e)
}
});
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")? {
if e.downcast_ref::<DownloadAbortError>().is_some() {
return Err(e);
}
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")?
&& e.downcast_ref::<DownloadAbortError>().is_some()
{
return Err(e);
}
let mut video_active_model: video::ActiveModel = video_model.into();
video_active_model.download_status = Set(status.into());
@@ -471,20 +478,20 @@ pub async fn download_page(
),
ExecutionStatus::Ignored(e) => {
error!(
"处理视频「{}」第 {} 页{}出现常见错误,已忽略: {:#}",
"处理视频「{}」第 {} 页{}出现常见错误,已忽略{:#}",
&video_model.name, page_model.pid, task_name, e
)
}
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => error!(
"处理视频「{}」第 {} 页{}失败: {:#}",
"处理视频「{}」第 {} 页{}失败{:#}",
&video_model.name, page_model.pid, task_name, e
),
});
// 如果下载视频时触发风控,直接返回 DownloadAbortError
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")? {
if let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>() {
bail!(DownloadAbortError());
}
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")?
&& let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>()
{
bail!(DownloadAbortError());
}
let mut page_active_model: page::ActiveModel = page_model.into();
page_active_model.download_status = Set(status.into());

View File

@@ -5,5 +5,8 @@ edition = { workspace = true }
publish = { workspace = true }
[dependencies]
derivative = { workspace = true }
sea-orm = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod rule;
pub mod string_vec;

View File

@@ -0,0 +1,120 @@
use std::fmt::Display;
use derivative::Derivative;
use sea_orm::FromJsonQueryResult;
use sea_orm::prelude::DateTime;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Debug, Serialize, Deserialize, Derivative)]
#[derivative(PartialEq, Eq)]
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
pub enum Condition<T: Serialize + Display> {
Equals(T),
Contains(T),
#[serde(deserialize_with = "deserialize_regex", serialize_with = "serialize_regex")]
MatchesRegex(String, #[derivative(PartialEq = "ignore")] regex::Regex),
Prefix(T),
Suffix(T),
GreaterThan(T),
LessThan(T),
Between(T, T),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
#[serde(rename_all = "camelCase", tag = "field", content = "rule")]
pub enum RuleTarget {
Title(Condition<String>),
Tags(Condition<String>),
FavTime(Condition<DateTime>),
PubTime(Condition<DateTime>),
PageCount(Condition<usize>),
Not(Box<RuleTarget>),
}
pub type AndGroup = Vec<RuleTarget>;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
pub struct Rule(pub Vec<AndGroup>);
impl<T: Serialize + Display> Display for Condition<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Condition::Equals(v) => write!(f, "等于“{}”", v),
Condition::Contains(v) => write!(f, "包含“{}”", v),
Condition::MatchesRegex(pat, _) => write!(f, "匹配“{}”", pat),
Condition::Prefix(v) => write!(f, "以“{}”开头", v),
Condition::Suffix(v) => write!(f, "以“{}”结尾", v),
Condition::GreaterThan(v) => write!(f, "大于“{}”", v),
Condition::LessThan(v) => write!(f, "小于“{}”", v),
Condition::Between(start, end) => write!(f, "在“{}”和“{}”之间", start, end),
}
}
}
impl Display for RuleTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn get_field_name(rt: &RuleTarget, depth: usize) -> &'static str {
match rt {
RuleTarget::Title(_) => "标题",
RuleTarget::Tags(_) => "标签",
RuleTarget::FavTime(_) => "收藏时间",
RuleTarget::PubTime(_) => "发布时间",
RuleTarget::PageCount(_) => "视频分页数量",
RuleTarget::Not(inner) => {
if depth == 0 {
get_field_name(inner, depth + 1)
} else {
"格式化失败"
}
}
}
}
let field_name = get_field_name(self, 0);
match self {
RuleTarget::Not(inner) => match inner.as_ref() {
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}不{}", field_name, cond)
}
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::Not(_) => write!(f, "格式化失败"),
},
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}{}", field_name, cond)
}
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond),
}
}
}
impl Display for Rule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let groups: Vec<String> = self
.0
.iter()
.map(|group| {
let conditions: Vec<String> = group.iter().map(|target| format!("{}", target)).collect();
format!("{}", conditions.join(""))
})
.collect();
write!(f, "{}", groups.join(""))
}
}
fn deserialize_regex<'de, D>(deserializer: D) -> Result<(String, regex::Regex), D::Error>
where
D: Deserializer<'de>,
{
let pattern = String::deserialize(deserializer)?;
// 反序列化时预编译 regex优化性能
let regex = regex::Regex::new(&pattern).map_err(serde::de::Error::custom)?;
Ok((pattern, regex))
}
fn serialize_regex<S>(pattern: &str, _regex: &regex::Regex, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(pattern)
}

View File

@@ -0,0 +1,20 @@
use sea_orm::FromJsonQueryResult;
use serde::{Deserialize, Serialize};
// reference: https://www.sea-ql.org/SeaORM/docs/generate-entity/column-types/#json-column
// 在 entity 中使用裸 Vec 仅在 postgres 中支持sea-orm 会将其映射为 postgres array
// 如果需要实现跨数据库的 array必须将其包裹在 wrapper type 中
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
pub struct StringVec(pub Vec<String>);
impl From<Vec<String>> for StringVec {
fn from(value: Vec<String>) -> Self {
Self(value)
}
}
impl From<StringVec> for Vec<String> {
fn from(value: StringVec) -> Self {
value.0
}
}

View File

@@ -2,6 +2,8 @@
use sea_orm::entity::prelude::*;
use crate::rule::Rule;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "collection")]
pub struct Model {
@@ -14,6 +16,7 @@ pub struct Model {
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub rule: Option<Rule>,
pub enabled: bool,
}

View File

@@ -2,6 +2,8 @@
use sea_orm::entity::prelude::*;
use crate::rule::Rule;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "favorite")]
pub struct Model {
@@ -13,6 +15,7 @@ pub struct Model {
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub rule: Option<Rule>,
pub enabled: bool,
}

View File

@@ -2,6 +2,8 @@
use sea_orm::entity::prelude::*;
use crate::rule::Rule;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "submission")]
pub struct Model {
@@ -12,6 +14,7 @@ pub struct Model {
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub rule: Option<Rule>,
pub enabled: bool,
}

View File

@@ -2,6 +2,8 @@
use sea_orm::entity::prelude::*;
use crate::string_vec::StringVec;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
#[sea_orm(table_name = "video")]
pub struct Model {
@@ -25,7 +27,8 @@ pub struct Model {
pub favtime: DateTime,
pub download_status: u32,
pub valid: bool,
pub tags: Option<serde_json::Value>,
pub should_download: bool,
pub tags: Option<StringVec>,
pub single_page: Option<bool>,
pub created_at: String,
}

View File

@@ -2,6 +2,8 @@
use sea_orm::entity::prelude::*;
use crate::rule::Rule;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "watch_later")]
pub struct Model {
@@ -10,6 +12,7 @@ pub struct Model {
pub path: String,
pub created_at: String,
pub latest_row_at: DateTime,
pub rule: Option<Rule>,
pub enabled: bool,
}

View File

@@ -1,2 +1,5 @@
mod custom_type;
mod entities;
pub use custom_type::*;
pub use entities::*;

View File

@@ -8,6 +8,7 @@ mod m20250122_062926_add_latest_row_at;
mod m20250612_090826_add_enabled;
mod m20250613_043257_add_config;
mod m20250712_080013_add_video_created_at_index;
mod m20250903_094454_add_rule_and_should_download;
pub struct Migrator;
@@ -23,6 +24,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250612_090826_add_enabled::Migration),
Box::new(m20250613_043257_add_config::Migration),
Box::new(m20250712_080013_add_video_created_at_index::Migration),
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
]
}
}

View File

@@ -0,0 +1,124 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::schema::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Video::Table)
.add_column(boolean(Video::ShouldDownload).default(true))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.add_column(text_null(WatchLater::Rule))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.add_column(text_null(Submission::Rule))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.add_column(text_null(Favorite::Rule))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.add_column(text_null(Collection::Rule))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Video::Table)
.drop_column(Video::ShouldDownload)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(WatchLater::Table)
.drop_column(WatchLater::Rule)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Submission::Table)
.drop_column(Submission::Rule)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Favorite::Table)
.drop_column(Favorite::Rule)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Collection::Table)
.drop_column(Collection::Rule)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Video {
Table,
ShouldDownload,
}
#[derive(DeriveIden)]
enum WatchLater {
Table,
Rule,
}
#[derive(DeriveIden)]
enum Submission {
Table,
Rule,
}
#[derive(DeriveIden)]
enum Favorite {
Table,
Rule,
}
#[derive(DeriveIden)]
enum Collection {
Table,
Rule,
}

View File

@@ -21,7 +21,7 @@ export default defineConfig({
nav: [
{ text: "主页", link: "/" },
{
text: "v2.6.0",
text: "v2.7.0",
items: [
{
text: "程序更新",

View File

@@ -73,10 +73,18 @@ UP 主头像和信息的保存位置。对于使用 Emby、Jellyfin 媒体服务
在修改该 Token 后需要对应修改前端保存的 Token才能正常访问管理页面。
### 启动 CDN 排序
表示程序每次执行扫描下载的间隔时间,单位为秒
一般情况下b 站会为视频、音频流提供一个 baseUrl 与多个 backupUrl程序默认会按照 baseUrl -> backupUrl 的顺序请求,依次尝试下载
如果启用 CDN 排序,那么程序不再使用默认顺序,而是将所有 url 放到一起统一排序来决定请求顺序。排序优先级从高到低为:
1. 服务商 CDN`upos-sz-mirrorxxxx.bilivideo.com`
2. 自建 CDN`cn-xxxx-dx-v-xxxx.bilivideo.com`
3. MCDN`xxxx.mcdn.bilivideo.com`
4. PCDN`xxxx.v1d.szbdyd.com`
这会让程序优先请求质量更高的 CDN可能会提高下载速度并增加成功率但效果因地区、网络环境而异。
## B 站认证

View File

@@ -32,7 +32,7 @@
EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/分集`,方便起见,我采用了如下的对应关系:
1. **文件夹**:对应 b 站的 video source
2. **电视剧** 对应 b 站的 video
2. **电视剧**:对应 b 站的 video
3. **第一季的所有分集**:对应 b 站的 page。
特别的,当 video 仅有一个 page 时为了避免过多的层级bili-sync 会将 page 展开到第二层级,变成与电视剧同级的电影。

View File

@@ -1,7 +1,7 @@
# bili-sync 是什么?
> [!TIP]
> 当前最新程序版本为 v2.6.0,文档将始终与最新程序版本保持一致。
> 当前最新程序版本为 v2.7.0,文档将始终与最新程序版本保持一致。
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。

View File

@@ -13,7 +13,7 @@
在[程序发布页](https://github.com/amtoaer/bili-sync/releases)选择最新版本中对应机器架构的压缩包,解压后会获取一个名为 `bili-sync-rs` 的可执行文件,直接双击执行。
### 其二: 使用 Docker Compose 运行
### 其二:使用 Docker Compose 运行
Linux/amd64 与 Linux/arm64 两个平台可直接使用 Docker 或 Docker Compose 运行,此处以 Compose 为例:
> 请注意其中的注释,有不清楚的地方可以先继续往下看。
@@ -88,9 +88,9 @@ Jul 12 16:11:10 INFO 开始运行管理页: http://0.0.0.0:12345
认证后会看到一系列的配置,除绑定地址外的选项**基本都会实时生效**。为避免意料外的情况,建议将配置文件一次修改完毕后再点击保存。
如无特殊需求一般仅需修改“B站认证”与“视频质量”两个标签下的配置。
如无特殊需求一般仅需修改“B 站认证”与“视频质量”两个标签下的配置。
其中“B站认证”在一次填写后即可忽略程序会在**每日第一次运行视频下载任务**时检查认证状态,并在有必要时自动刷新。
其中“B 站认证”在一次填写后即可忽略,程序会在**每日第一次运行视频下载任务**时检查认证状态,并在有必要时自动刷新。
对于这些设置项的含义,请参考[配置说明](./configuration.md),可善用右侧导航在不同配置项间跳转。
@@ -98,7 +98,7 @@ Jul 12 16:11:10 INFO 开始运行管理页: http://0.0.0.0:12345
配置完毕后,我们便可以随时添加视频源订阅。
用户在正确填写“B站认证”后可以在“快捷订阅”部分查看自己创建的收藏夹、关注的合集与 UP 主一键订阅,也可以在“视频源”页手动添加并管理。
用户在正确填写“B 站认证”后可以在“快捷订阅”部分查看自己创建的收藏夹、关注的合集与 UP 主一键订阅,也可以在“视频源”页手动添加并管理。
对于手动添加的视频源,可参考如下页面获取所需的参数:

View File

@@ -15,7 +15,7 @@ from pathlib import Path
def main():
if len(sys.argv) <= 1:
print("用法: python 2.0.3_add_fanart.py <path1> <path2> ...")
print("用法python 2.0.3_add_fanart.py <path1> <path2> ...")
exit(1)
paths = [Path(path) for path in sys.argv[1:]]
for path in paths:

View File

@@ -7,7 +7,7 @@
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.525.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "2.22.2",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
@@ -16,7 +16,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.7",
"bits-ui": "^2.8.6",
"bits-ui": "^2.11.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -155,7 +155,7 @@
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.12", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA=="],
"@lucide/svelte": ["@lucide/svelte@0.525.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-dyUxkXzepagLUzL8jHQNdeH286nC66ClLACsg+Neu/bjkRJWPWMzkT+H0DKlE70QdkicGCfs1ZGmXCc351hmZA=="],
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -301,7 +301,7 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
"bits-ui": ["bits-ui@2.11.0", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.31.1", "svelte-toolbelt": "^0.10.4", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-j/lOFHz6ZDWwj9sOUb6zYSJQdvPc7kr1IRyAdPjln4wOw9UVvKCosbRFEyP4JEzvNFX7HksMG4naDrDHta5bSA=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
@@ -615,7 +615,7 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
"runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
@@ -649,7 +649,7 @@
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.5", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w=="],
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
@@ -745,6 +745,8 @@
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
"svelte-toolbelt/runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],

View File

@@ -1,11 +1,11 @@
{
"name": "bili-sync-web",
"version": "2.6.0",
"version": "2.7.0",
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.525.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "2.22.2",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
@@ -14,7 +14,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.7",
"bits-ui": "^2.8.6",
"bits-ui": "^2.11.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

View File

@@ -4,6 +4,16 @@
@custom-variant dark (&:is(.dark *));
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
html {
scroll-behavior: smooth !important;
}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -21,7 +21,8 @@ import type {
DashBoardResponse,
SysInfo,
TaskStatus,
ResetRequest
ResetRequest,
UpdateVideoSourceResponse
} from './types';
import { wsManager } from './ws';
@@ -59,7 +60,7 @@ class ApiClient {
clearAuthToken() {
delete this.defaultHeaders['Authorization'];
localStorage.removeItem('authToken');
// 断开WebSocket连接因为token已经无效
// 断开 WebSocket 连接,因为 token 已经无效
wsManager.disconnect();
}
@@ -212,8 +213,12 @@ class ApiClient {
type: string,
id: number,
request: UpdateVideoSourceRequest
): Promise<ApiResponse<boolean>> {
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
}
async evaluateVideoSourceRules(type: string, id: number): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
}
async getConfig(): Promise<ApiResponse<Config>> {
@@ -261,6 +266,8 @@ const api = {
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
apiClient.updateVideoSource(type, id, request),
evaluateVideoSourceRules: (type: string, id: number) =>
apiClient.evaluateVideoSourceRules(type, id),
getConfig: () => apiClient.getConfig(),
updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(),

View File

@@ -0,0 +1,479 @@
<script lang="ts">
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 { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import PlusIcon from '@lucide/svelte/icons/plus';
import MinusIcon from '@lucide/svelte/icons/minus';
import XIcon from '@lucide/svelte/icons/x';
import type { Rule, RuleTarget, Condition } from '$lib/types';
import { onMount } from 'svelte';
interface Props {
rule: Rule | null;
onRuleChange: (rule: Rule | null) => void;
}
let { rule, onRuleChange }: Props = $props();
const FIELD_OPTIONS = [
{ value: 'title', label: '标题' },
{ value: 'tags', label: '标签' },
{ value: 'favTime', label: '收藏时间' },
{ value: 'pubTime', label: '发布时间' },
{ value: 'pageCount', label: '视频分页数量' }
];
const getOperatorOptions = (field: string) => {
switch (field) {
case 'title':
case 'tags':
return [
{ value: 'equals', label: '等于' },
{ value: 'contains', label: '包含' },
{ value: 'prefix', label: '以...开头' },
{ value: 'suffix', label: '以...结尾' },
{ value: 'matchesRegex', label: '匹配正则' }
];
case 'pageCount':
return [
{ value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '大于' },
{ value: 'lessThan', label: '小于' },
{ value: 'between', label: '范围' }
];
case 'favTime':
case 'pubTime':
return [
{ value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '晚于' },
{ value: 'lessThan', label: '早于' },
{ value: 'between', label: '时间范围' }
];
default:
return [];
}
};
interface LocalCondition {
field: string;
operator: string;
value: string;
value2?: string;
isNot: boolean;
}
interface LocalAndGroup {
conditions: LocalCondition[];
}
let localRule: LocalAndGroup[] = $state([]);
onMount(() => {
if (rule && rule.length > 0) {
localRule = rule.map((andGroup) => ({
conditions: andGroup.map((target) => convertRuleTargetToLocal(target))
}));
} else {
localRule = [];
}
});
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
if (typeof target.rule === 'object' && 'field' in target.rule) {
// 嵌套的 not
const innerCondition = convertRuleTargetToLocal(target.rule);
return {
...innerCondition,
isNot: true
};
}
const condition = target.rule as Condition<string | number | Date>;
let value = '';
let value2 = '';
if (Array.isArray(condition.value)) {
value = String(condition.value[0] || '');
value2 = String(condition.value[1] || '');
} else {
value = String(condition.value || '');
}
return {
field: target.field,
operator: condition.operator,
value,
value2,
isNot: false
};
}
function convertLocalToRule(): Rule | null {
if (localRule.length === 0) return null;
return localRule.map((andGroup) =>
andGroup.conditions.map((condition) => {
let value: string | number | Date | (string | number | Date)[];
if (condition.field === 'pageCount') {
if (condition.operator === 'between') {
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
} else {
value = parseInt(condition.value) || 0;
}
} else if (condition.field === 'favTime' || condition.field === 'pubTime') {
if (condition.operator === 'between') {
value = [condition.value, condition.value2 || ''];
} else {
value = condition.value;
}
} else {
if (condition.operator === 'between') {
value = [condition.value, condition.value2 || ''];
} else {
value = condition.value;
}
}
const conditionObj: Condition<string | number | Date> = {
operator: condition.operator,
value
};
let target: RuleTarget<string | number | Date> = {
field: condition.field,
rule: conditionObj
};
if (condition.isNot) {
target = {
field: 'not',
rule: target
};
}
return target;
})
);
}
function addAndGroup() {
localRule.push({ conditions: [] });
onRuleChange?.(convertLocalToRule());
}
function removeAndGroup(index: number) {
localRule.splice(index, 1);
onRuleChange?.(convertLocalToRule());
}
function addCondition(groupIndex: number) {
localRule[groupIndex].conditions.push({
field: 'title',
operator: 'contains',
value: '',
isNot: false
});
onRuleChange?.(convertLocalToRule());
}
function removeCondition(groupIndex: number, conditionIndex: number) {
localRule[groupIndex].conditions.splice(conditionIndex, 1);
onRuleChange?.(convertLocalToRule());
}
function updateCondition(
groupIndex: number,
conditionIndex: number,
field: string,
value: string
) {
const condition = localRule[groupIndex].conditions[conditionIndex];
if (field === 'field') {
condition.field = value;
const operators = getOperatorOptions(value);
condition.operator = operators[0]?.value || 'equals';
condition.value = '';
condition.value2 = '';
} else if (field === 'operator') {
condition.operator = value;
// 如果切换到/从 between 操作符,重置值
if (value === 'between') {
condition.value2 = condition.value2 || '';
}
} else if (field === 'value') {
condition.value = value;
} else if (field === 'value2') {
condition.value2 = value;
} else if (field === 'isNot') {
condition.isNot = value === 'true';
}
onRuleChange?.(convertLocalToRule());
}
function clearRules() {
localRule = [];
onRuleChange?.(convertLocalToRule());
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">过滤规则</Label>
<div class="flex gap-2">
{#if localRule.length > 0}
<Button size="sm" variant="outline" onclick={clearRules}>清空规则</Button>
{/if}
<Button size="sm" onclick={addAndGroup}>
<PlusIcon class="mr-1 h-3 w-3" />
添加规则组
</Button>
</div>
</div>
{#if localRule.length === 0}
<div class="border-muted-foreground/25 rounded-lg border-2 border-dashed p-8 text-center">
<p class="text-muted-foreground mb-4 text-sm">暂无过滤规则,将下载所有视频</p>
<Button size="sm" onclick={addAndGroup}>
<PlusIcon class="mr-1 h-3 w-3" />
添加第一个规则组
</Button>
</div>
{:else}
<div class="space-y-4">
{#each localRule as andGroup, groupIndex (groupIndex)}
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Badge variant="secondary">规则组 {groupIndex + 1}</Badge>
</div>
<Button
size="sm"
variant="ghost"
onclick={() => removeAndGroup(groupIndex)}
class="h-7 w-7 p-0"
>
<XIcon class="h-3 w-3" />
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-3">
{#each andGroup.conditions as condition, conditionIndex (conditionIndex)}
<div class="space-y-3 rounded-lg border p-4">
<div class="flex items-center justify-between">
<Badge variant="secondary">条件 {conditionIndex + 1}</Badge>
<Button
size="sm"
variant="ghost"
onclick={() => removeCondition(groupIndex, conditionIndex)}
class="h-7 w-7 p-0"
>
<MinusIcon class="h-3 w-3" />
</Button>
</div>
<!-- 取反选项 -->
<div class="flex items-center space-x-2">
<Checkbox
id={`not-${groupIndex}-${conditionIndex}`}
checked={condition.isNot}
onCheckedChange={(checked) =>
updateCondition(
groupIndex,
conditionIndex,
'isNot',
checked ? 'true' : 'false'
)}
/>
<Label for={`not-${groupIndex}-${conditionIndex}`} class="text-sm">
取反NOT
</Label>
</div>
<!-- 字段和操作符 -->
<div class="grid grid-cols-2 gap-3">
<!-- 字段选择 -->
<div>
<Label class="text-muted-foreground text-xs">字段</Label>
<select
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={condition.field}
onchange={(e) =>
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
>
{#each FIELD_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- 操作符选择 -->
<div>
<Label class="text-muted-foreground text-xs">操作符</Label>
<select
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={condition.operator}
onchange={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'operator',
e.currentTarget.value
)}
>
{#each getOperatorOptions(condition.field) as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
<!-- 值输入 -->
<div>
<Label class="text-muted-foreground text-xs"></Label>
{#if condition.operator === 'between'}
<div class="grid grid-cols-2 gap-2">
{#if condition.field === 'pageCount'}
<Input
type="number"
placeholder="最小值"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value',
e.currentTarget.value
)}
/>
<Input
type="number"
placeholder="最大值"
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value2',
e.currentTarget.value
)}
/>
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
<Input
type="datetime-local"
placeholder="开始时间"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value',
e.currentTarget.value + ':00' // 前端选择器只能精确到分钟,此处附加额外的 :00 以满足后端传参条件
)}
/>
<Input
type="datetime-local"
placeholder="结束时间"
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value2',
e.currentTarget.value + ':00'
)}
/>
{:else}
<Input
type="text"
placeholder="起始值"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value',
e.currentTarget.value
)}
/>
<Input
type="text"
placeholder="结束值"
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value2',
e.currentTarget.value
)}
/>
{/if}
</div>
{:else if condition.field === 'pageCount'}
<Input
type="number"
placeholder="输入数值"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
/>
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
<Input
type="datetime-local"
placeholder="选择时间"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'value',
e.currentTarget.value + ':00'
)}
/>
{:else}
<Input
type="text"
placeholder="输入文本"
class="h-9"
value={condition.value}
oninput={(e) =>
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
/>
{/if}
</div>
</div>
{/each}
<Button
size="sm"
variant="outline"
onclick={() => addCondition(groupIndex)}
class="w-full"
>
<PlusIcon class="mr-1 h-3 w-3" />
添加条件
</Button>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
{#if localRule.length > 0}
<div class="text-muted-foreground bg-muted/50 rounded p-3 text-xs">
<p class="mb-1 font-medium">规则说明:</p>
<ul class="space-y-1">
<li>• 多个规则组之间是"或"的关系,同一规则组内的条件是"且"的关系</li>
<li>
• 规则内配置的时间不包含时区,在处理时会默认应用<strong>服务器时区</strong
>,不受浏览器影响
</li>
</ul>
</div>
{/if}
</div>

View File

@@ -19,7 +19,7 @@
export let onsubmit: (request: UpdateVideoStatusRequest) => void;
// 视频任务名称(与后端 VideoStatus 对应)
const videoTaskNames = ['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分页下载'];
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
// 分页任务名称(与后端 PageStatus 对应)
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];

View File

@@ -45,7 +45,7 @@
case 'collection':
return '合集';
case 'upper':
return 'UP主';
return 'UP 主';
default:
return '';
}
@@ -125,7 +125,7 @@
function getAvatarUrl(): string {
switch (type) {
case 'upper':
return `/image-proxy?url=${(item as UpperWithSubscriptionStatus).face}`;
return (item as UpperWithSubscriptionStatus).face;
default:
return '';
}

View File

@@ -34,7 +34,7 @@
let customPath = '';
let loading = false;
// 根据类型和item生成默认路径
// 根据类型和 item 生成默认路径
function generateDefaultPath(): string {
if (!item) return '';
@@ -49,7 +49,7 @@
}
case 'upper': {
const upper = item as UpperWithSubscriptionStatus;
return `UP主/${upper.uname}`;
return `UP 主/${upper.uname}`;
}
default:
return '';
@@ -63,7 +63,7 @@
case 'collection':
return '合集';
case 'upper':
return 'UP主';
return 'UP 主';
default:
return '';
}
@@ -145,7 +145,7 @@
open = false;
}
// 当对话框打开时重置path
// 当对话框打开时重置 path
$: if (open && item) {
customPath = generateDefaultPath();
}

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
import Trigger from './popover-trigger.svelte';
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>

View File

@@ -49,20 +49,30 @@
}
}
function getOverallStatus(downloadStatus: number[]): {
function getOverallStatus(
downloadStatus: number[],
shouldDownload: boolean
): {
text: string;
color: 'default' | 'secondary' | 'destructive' | 'outline';
style: string;
} {
if (!shouldDownload) {
// 被过滤规则排除,显示为“跳过”
return { text: '跳过', style: 'bg-gray-100 text-gray-700' };
}
const completed = downloadStatus.filter((status) => status === 7).length;
const total = downloadStatus.length;
const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length;
if (completed === total) {
return { text: '完成', color: 'outline' };
// 全部完成,显示为“完成”
return { text: '完成', style: 'bg-emerald-700 text-emerald-100' };
} else if (failed > 0) {
return { text: '失败', color: 'destructive' };
// 出现了失败,显示为“失败”
return { text: '失败', style: 'bg-rose-700 text-rose-100' };
} else {
return { text: '进行中', color: 'secondary' };
// 还未开始,显示为“等待”
return { text: '等待', style: 'bg-yellow-700 text-yellow-100' };
}
}
@@ -74,7 +84,7 @@
return defaultTaskNames[index] || `任务${index + 1}`;
}
$: overallStatus = getOverallStatus(video.download_status);
$: overallStatus = getOverallStatus(video.download_status, video.should_download);
$: completed = video.download_status.filter((status) => status === 7).length;
$: total = video.download_status.length;
@@ -112,7 +122,10 @@
>
{displayTitle}
</CardTitle>
<Badge variant={overallStatus.color} class="shrink-0 px-2 py-1 text-xs font-medium">
<Badge
variant="secondary"
class="shrink-0 px-2 py-1 text-xs font-medium {overallStatus.style} "
>
{overallStatus.text}
</Badge>
</div>

View File

@@ -36,6 +36,7 @@ export interface VideoInfo {
bvid: string;
name: string;
upper_name: string;
should_download: boolean;
download_status: [number, number, number, number, number];
}
@@ -136,7 +137,7 @@ export interface CollectionsResponse {
total: number;
}
// UP主相关类型
// UP 主相关类型
export interface UpperWithSubscriptionStatus {
mid: number;
uname: string;
@@ -168,11 +169,27 @@ export interface InsertSubmissionRequest {
path: string;
}
// Rule 相关类型
export interface Condition<T> {
operator: string;
value: T | T[];
}
export interface RuleTarget<T> {
field: string;
rule: Condition<T> | RuleTarget<T>;
}
export type AndGroup = RuleTarget<string | number | Date>[];
export type Rule = AndGroup[];
// 视频源详细信息类型
export interface VideoSourceDetail {
id: number;
name: string;
path: string;
rule?: Rule | null;
ruleDisplay?: string | null;
enabled: boolean;
}
@@ -188,6 +205,7 @@ export interface VideoSourcesDetailsResponse {
export interface UpdateVideoSourceRequest {
path: string;
enabled: boolean;
rule?: Rule | null;
}
// 配置相关类型
@@ -295,3 +313,7 @@ export interface TaskStatus {
last_finish: Date | null;
next_run: Date | null;
}
export interface UpdateVideoSourceResponse {
ruleDisplay?: string;
}

View File

@@ -122,7 +122,7 @@ export class WebSocketManager {
} catch (error) {
console.error('Failed to parse WebSocket message:', error, event.data);
toast.error('解析 WebSocket 消息失败', {
description: `消息内容: ${event.data}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
description: `消息内容${event.data}\n错误信息${error instanceof Error ? error.message : String(error)}`
});
}
}
@@ -137,7 +137,7 @@ export class WebSocketManager {
} catch (error) {
console.error('Failed to send message:', error);
toast.error('发送 WebSocket 消息失败', {
description: `消息内容: ${JSON.stringify(message)}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
description: `消息内容${JSON.stringify(message)}\n错误信息${error instanceof Error ? error.message : String(error)}`
});
}
}

View File

@@ -50,7 +50,7 @@
const response = await api.getDashboard();
dashboardData = response.data;
} catch (error) {
console.error('加载仪表盘数据失败:', error);
console.error('加载仪表盘数据失败', error);
toast.error('加载仪表盘数据失败', {
description: (error as ApiError).message
});

View File

@@ -17,11 +17,11 @@
async function loadCollections(page: number = 0) {
loading = true;
try {
const response = await api.getFollowedCollections(page + 1, pageSize); // API使用1基索引
const response = await api.getFollowedCollections(page + 1, pageSize); // API 使用 1 基索引
collections = response.data.collections;
totalCount = response.data.total;
} catch (error) {
console.error('加载合集失败:', error);
console.error('加载合集失败', error);
toast.error('加载合集失败', {
description: (error as ApiError).message
});
@@ -92,7 +92,7 @@
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无合集数据</p>
<p class="text-muted-foreground text-sm">请先在B站关注一些合集,或检查账号配置</p>
<p class="text-muted-foreground text-sm">请先在 B 站关注一些合集,或检查账号配置</p>
</div>
</div>
{/if}

View File

@@ -17,7 +17,7 @@
const response = await api.getCreatedFavorites();
favorites = response.data.favorites;
} catch (error) {
console.error('加载收藏夹失败:', error);
console.error('加载收藏夹失败', error);
toast.error('加载收藏夹失败', {
description: (error as ApiError).message
});
@@ -73,7 +73,7 @@
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无收藏夹数据</p>
<p class="text-muted-foreground text-sm">请先在B站创建收藏夹,或检查账号配置</p>
<p class="text-muted-foreground text-sm">请先在 B 站创建收藏夹,或检查账号配置</p>
</div>
</div>
{/if}

View File

@@ -17,12 +17,12 @@
async function loadUppers(page: number = 0) {
loading = true;
try {
const response = await api.getFollowedUppers(page + 1, pageSize); // API使用1基索引
const response = await api.getFollowedUppers(page + 1, pageSize); // API 使用 1 基索引
uppers = response.data.uppers;
totalCount = response.data.total;
} catch (error) {
console.error('加载UP主失败:', error);
toast.error('加载UP主失败', {
console.error('加载 UP 主失败', error);
toast.error('加载 UP 主失败', {
description: (error as ApiError).message
});
} finally {
@@ -92,8 +92,8 @@
{:else}
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无UP主数据</p>
<p class="text-muted-foreground text-sm">请先在B站关注一些UP主或检查账号配置</p>
<p class="text-muted-foreground">暂无 UP 主数据</p>
<p class="text-muted-foreground text-sm">请先在 B 站关注一些 UP 主,或检查账号配置</p>
</div>
</div>
{/if}

View File

@@ -8,17 +8,19 @@
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import EditIcon from '@lucide/svelte/icons/edit';
import SaveIcon from '@lucide/svelte/icons/save';
import XIcon from '@lucide/svelte/icons/x';
import FolderIcon from '@lucide/svelte/icons/folder';
import HeartIcon from '@lucide/svelte/icons/heart';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types';
import api from '$lib/api';
import RuleEditor from '$lib/components/rule-editor.svelte';
import ListRestartIcon from '@lucide/svelte/icons/list-restart';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
let loading = false;
@@ -29,19 +31,31 @@
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
let adding = false;
// 编辑对话框状态
let showEditDialog = false;
let editingSource: VideoSourceDetail | null = null;
let editingType = '';
let editingIdx: number = 0;
let saving = false;
// 规则评估对话框状态
let showEvaluateDialog = false;
let evaluateSource: VideoSourceDetail | null = null;
let evaluateType = '';
let evaluating = false;
// 编辑表单数据
let editForm = {
path: '',
enabled: false,
rule: null as Rule | null
};
// 表单数据
let favoriteForm = { fid: '', path: '' };
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
let submissionForm = { upper_id: '', path: '' };
type ExtendedVideoSource = VideoSourceDetail & {
type: string;
originalIndex: number;
editing?: boolean;
editingPath?: string;
editingEnabled?: boolean;
};
const TAB_CONFIG = {
favorites: { label: '收藏夹', icon: HeartIcon },
collections: { label: '合集 / 列表', icon: FolderIcon },
@@ -64,75 +78,88 @@
}
}
function startEdit(type: string, index: number) {
if (!videoSourcesData) return;
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
if (!sources?.[index]) return;
const source = sources[index] as ExtendedVideoSource;
source.editing = true;
source.editingPath = source.path;
source.editingEnabled = source.enabled;
videoSourcesData = { ...videoSourcesData };
// 打开编辑对话框
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
editingSource = source;
editingType = type;
editingIdx = idx;
editForm = {
path: source.path,
enabled: source.enabled,
rule: source.rule || null
};
showEditDialog = true;
}
function cancelEdit(type: string, index: number) {
if (!videoSourcesData) return;
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
if (!sources?.[index]) return;
const source = sources[index] as ExtendedVideoSource;
source.editing = false;
source.editingPath = undefined;
source.editingEnabled = undefined;
videoSourcesData = { ...videoSourcesData };
function openEvaluateRules(type: string, source: VideoSourceDetail) {
evaluateSource = source;
evaluateType = type;
showEvaluateDialog = true;
}
async function saveEdit(type: string, index: number) {
if (!videoSourcesData) return;
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
if (!sources?.[index]) return;
// 保存编辑
async function saveEdit() {
if (!editingSource) return;
const source = sources[index] as ExtendedVideoSource;
if (!source.editingPath?.trim()) {
if (!editForm.path?.trim()) {
toast.error('路径不能为空');
return;
}
saving = true;
try {
await api.updateVideoSource(type, source.id, {
path: source.editingPath,
enabled: source.editingEnabled ?? false
let response = await api.updateVideoSource(editingType, editingSource.id, {
path: editForm.path,
enabled: editForm.enabled,
rule: editForm.rule
});
source.path = source.editingPath;
source.enabled = source.editingEnabled ?? false;
source.editing = false;
source.editingPath = undefined;
source.editingEnabled = undefined;
videoSourcesData = { ...videoSourcesData };
// 更新本地数据
if (videoSourcesData && editingSource) {
const sources = videoSourcesData[
editingType as keyof VideoSourcesDetailsResponse
] as VideoSourceDetail[];
sources[editingIdx] = {
...sources[editingIdx],
path: editForm.path,
enabled: editForm.enabled,
rule: editForm.rule,
ruleDisplay: response.data.ruleDisplay
};
videoSourcesData = { ...videoSourcesData };
}
showEditDialog = false;
toast.success('保存成功');
} catch (error) {
toast.error('保存失败', {
description: (error as ApiError).message
});
} finally {
saving = false;
}
}
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
async function evaluateRules() {
if (!evaluateSource) return;
evaluating = true;
try {
let response = await api.evaluateVideoSourceRules(evaluateType, evaluateSource.id);
if (response && response.data) {
showEvaluateDialog = false;
toast.success('重新评估规则成功');
} else {
toast.error('重新评估规则失败');
}
} catch (error) {
toast.error('重新评估规则失败', {
description: (error as ApiError).message
});
} finally {
evaluating = false;
}
}
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
if (!videoSourcesData) return [];
const sources = videoSourcesData[
tabValue as keyof VideoSourcesDetailsResponse
] as VideoSourceDetail[];
// 直接返回原始数据的引用,只添加必要的属性
return sources.map((source, originalIndex) => {
// 使用类型断言来扩展 VideoSourceDetail
const extendedSource = source as ExtendedVideoSource;
extendedSource.type = tabValue;
extendedSource.originalIndex = originalIndex;
return extendedSource;
});
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
}
// 打开添加对话框
@@ -238,80 +265,69 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
<Table.Head class="w-[20%]">名称</Table.Head>
<Table.Head class="w-[40%]">下载路径</Table.Head>
<Table.Head class="w-[15%]">过滤规则</Table.Head>
<Table.Head class="w-[15%]">状态</Table.Head>
<Table.Head class="w-[10%] text-right">操作</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sources as source, index (index)}
<Table.Row>
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
<Table.Cell class="w-[30%] md:w-[40%]">
{#if source.editing}
<input
bind:value={source.editingPath}
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 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"
placeholder="输入下载路径"
/>
<Table.Cell class="font-medium">{source.name}</Table.Cell>
<Table.Cell>
<code
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
>
{source.path || '未设置'}
</code>
</Table.Cell>
<Table.Cell>
{#if source.rule && source.rule.length > 0}
<div class="flex items-center gap-1">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-muted-foreground text-sm"
>{source.rule.length} 条规则</span
>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">{source.ruleDisplay}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{:else}
<code
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
>
{source.path || '未设置'}
</code>
<span class="text-muted-foreground text-sm">-</span>
{/if}
</Table.Cell>
<Table.Cell class="w-[25%] md:w-[20%]">
{#if source.editing}
<div class="flex h-8 items-center">
<Switch bind:checked={source.editingEnabled} />
</div>
{:else}
<div class="flex h-8 items-center gap-2">
<Switch checked={source.enabled} disabled />
<span class="text-muted-foreground text-sm whitespace-nowrap">
{source.enabled ? '已启用' : '已禁用'}
</span>
</div>
{/if}
<Table.Cell>
<div class="flex h-8 items-center gap-2">
<Switch checked={source.enabled} disabled />
<span class="text-muted-foreground text-sm whitespace-nowrap">
{source.enabled ? '已启用' : '已禁用'}
</span>
</div>
</Table.Cell>
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
{#if source.editing}
<div
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
>
<Button
size="sm"
variant="outline"
onclick={() => saveEdit(key, source.originalIndex)}
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
title="保存"
>
<SaveIcon class="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onclick={() => cancelEdit(key, source.originalIndex)}
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
title="取消"
>
<XIcon class="h-3 w-3" />
</Button>
</div>
{:else}
<Button
size="sm"
variant="outline"
onclick={() => startEdit(key, source.originalIndex)}
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
title="编辑"
>
<EditIcon class="h-3 w-3" />
</Button>
{/if}
<Table.Cell class="text-right">
<Button
size="sm"
variant="outline"
onclick={() => openEditDialog(key, source, index)}
class="h-8 w-8 p-0"
title="编辑"
>
<EditIcon class="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onclick={() => openEvaluateRules(key, source)}
class="h-8 w-8 p-0"
title="重新评估规则"
>
<ListRestartIcon class="h-3 w-3" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
@@ -352,11 +368,77 @@
</div>
{/if}
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
<!-- 编辑对话框 -->
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
class="no-scrollbar max-h-[85vh] !max-w-[90vw] overflow-y-auto lg:!max-w-[70vw]"
>
<Dialog.Title class="text-lg font-semibold">
编辑视频源: {editingSource?.name || ''}
</Dialog.Title>
<div class="mt-6 space-y-6">
<!-- 下载路径 -->
<div>
<Label for="edit-path" class="text-sm font-medium">下载路径</Label>
<Input
id="edit-path"
type="text"
bind:value={editForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-2"
/>
</div>
<!-- 启用状态 -->
<div class="flex items-center space-x-2">
<Switch bind:checked={editForm.enabled} />
<Label class="text-sm font-medium">启用此视频源</Label>
</div>
<!-- 规则编辑器 -->
<div>
<RuleEditor rule={editForm.rule} onRuleChange={(rule) => (editForm.rule = rule)} />
</div>
</div>
<div class="mt-8 flex justify-end gap-3">
<Button variant="outline" onclick={() => (showEditDialog = false)} disabled={saving}>
取消
</Button>
<Button onclick={saveEdit} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={showEvaluateDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重新评估规则</AlertDialog.Title>
<AlertDialog.Description>
确定要重新评估视频源 <strong>"{evaluateSource?.name}"</strong> 的过滤规则吗?<br />
规则修改后默认仅对新视频生效,该操作可使用当前规则对数据库中已存在的历史视频进行重新评估,<span
class="text-destructive font-medium">无法撤销</span
><br />
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
disabled={evaluating}
onclick={() => {
showEvaluateDialog = false;
}}>取消</AlertDialog.Cancel
>
<AlertDialog.Action onclick={evaluateRules} disabled={evaluating}>
{evaluating ? '重新评估中' : '确认重新评估'}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- 添加对话框 -->
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Content>
<Dialog.Title class="text-lg font-semibold">
{#if addDialogType === 'favorites'}
添加收藏夹

View File

@@ -25,8 +25,8 @@
async function loadVideoDetail() {
const videoId = parseInt($page.params.id);
if (isNaN(videoId)) {
error = '无效的视频ID';
toast.error('无效的视频ID');
error = '无效的视频 ID';
toast.error('无效的视频 ID');
return;
}
loading = true;
@@ -35,7 +35,7 @@
const result = await api.getVideo(videoId);
videoData = result.data;
} catch (error) {
console.error('加载视频详情失败:', error);
console.error('加载视频详情失败', error);
toast.error('加载视频详情失败', {
description: (error as ApiError).message
});
@@ -79,7 +79,7 @@
toast.error('状态更新失败');
}
} catch (error) {
console.error('状态更新失败:', error);
console.error('状态更新失败', error);
toast.error('状态更新失败', {
description: (error as ApiError).message
});
@@ -156,11 +156,12 @@
bvid: videoData.video.bvid,
name: videoData.video.name,
upper_name: videoData.video.upper_name,
download_status: videoData.video.download_status
download_status: videoData.video.download_status,
should_download: videoData.video.should_download
}}
mode="detail"
showActions={false}
taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分页下载']}
taskNames={['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载']}
bind:resetDialogOpen
bind:resetting
onReset={async (forceReset: boolean) => {
@@ -209,7 +210,8 @@
id: pageInfo.id,
name: `P${pageInfo.pid}: ${pageInfo.name}`,
upper_name: '',
download_status: pageInfo.download_status
download_status: pageInfo.download_status,
should_download: videoData.video.should_download
}}
mode="page"
showActions={false}
@@ -223,8 +225,8 @@
{:else}
<div class="py-12 text-center">
<div class="space-y-2">
<p class="text-muted-foreground">暂无分P数据</p>
<p class="text-muted-foreground text-sm">该视频可能为单P视频</p>
<p class="text-muted-foreground">暂无分 P 数据</p>
<p class="text-muted-foreground text-sm">该视频可能为单 P 视频</p>
</div>
</div>
{/if}

View File

@@ -75,7 +75,7 @@
const result = await api.getVideos(params);
videosData = result.data;
} catch (error) {
console.error('加载视频失败:', error);
console.error('加载视频失败', error);
toast.error('加载视频失败', {
description: (error as ApiError).message
});
@@ -111,7 +111,7 @@
});
}
} catch (error) {
console.error('重置失败:', error);
console.error('重置失败', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
@@ -133,7 +133,7 @@
toast.info('没有需要重置的视频');
}
} catch (error) {
console.error('重置失败:', error);
console.error('重置失败', error);
toast.error('重置失败', {
description: (error as ApiError).message
});

View File

@@ -11,8 +11,7 @@ export default defineConfig({
ws: true,
rewriteWsOrigin: true
},
'/api': 'http://localhost:12345',
'/image-proxy': 'http://localhost:12345'
'/api': 'http://localhost:12345'
},
host: true
}