From 210c94398a3f72376ab5c6f801b1f81bad20c33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Wed, 24 Sep 2025 00:42:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=9A=84=E7=AD=9B=E9=80=89=E8=A7=84=E5=88=99=20(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 14 + Cargo.toml | 1 + crates/bili_sync/src/adapter/collection.rs | 5 + crates/bili_sync/src/adapter/favorite.rs | 5 + crates/bili_sync/src/adapter/mod.rs | 3 + crates/bili_sync/src/adapter/submission.rs | 5 + crates/bili_sync/src/adapter/watch_later.rs | 5 + crates/bili_sync/src/api/request.rs | 2 + crates/bili_sync/src/api/response.rs | 11 + .../src/api/routes/video_sources/mod.rs | 49 +- crates/bili_sync/src/bilibili/video.rs | 26 +- crates/bili_sync/src/utils/convert.rs | 2 +- crates/bili_sync/src/utils/mod.rs | 1 + crates/bili_sync/src/utils/model.rs | 1 + crates/bili_sync/src/utils/nfo.rs | 12 +- crates/bili_sync/src/utils/rule.rs | 224 ++++++++ crates/bili_sync/src/utils/status.rs | 2 +- crates/bili_sync/src/workflow.rs | 6 +- crates/bili_sync_entity/Cargo.toml | 3 + .../bili_sync_entity/src/custom_type/mod.rs | 2 + .../bili_sync_entity/src/custom_type/rule.rs | 120 +++++ .../src/custom_type/string_vec.rs | 20 + .../src/entities/collection.rs | 3 + .../bili_sync_entity/src/entities/favorite.rs | 3 + .../src/entities/submission.rs | 3 + crates/bili_sync_entity/src/entities/video.rs | 5 +- .../src/entities/watch_later.rs | 3 + crates/bili_sync_entity/src/lib.rs | 3 + crates/bili_sync_migration/src/lib.rs | 2 + ...903_094454_add_rule_and_should_download.rs | 124 +++++ web/bun.lock | 14 +- web/package.json | 4 +- web/src/lib/api.ts | 7 +- web/src/lib/components/rule-editor.svelte | 479 ++++++++++++++++++ web/src/lib/components/ui/popover/index.ts | 17 + .../ui/popover/popover-content.svelte | 29 ++ .../ui/popover/popover-trigger.svelte | 17 + web/src/lib/types.ts | 21 + web/src/routes/video-sources/+page.svelte | 273 +++++----- 39 files changed, 1345 insertions(+), 181 deletions(-) create mode 100644 crates/bili_sync/src/utils/rule.rs create mode 100644 crates/bili_sync_entity/src/custom_type/mod.rs create mode 100644 crates/bili_sync_entity/src/custom_type/rule.rs create mode 100644 crates/bili_sync_entity/src/custom_type/string_vec.rs create mode 100644 crates/bili_sync_migration/src/m20250903_094454_add_rule_and_should_download.rs create mode 100644 web/src/lib/components/rule-editor.svelte create mode 100644 web/src/lib/components/ui/popover/index.ts create mode 100644 web/src/lib/components/ui/popover/popover-content.svelte create mode 100644 web/src/lib/components/ui/popover/popover-trigger.svelte diff --git a/Cargo.lock b/Cargo.lock index 01a6a04..722338e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -533,7 +533,10 @@ dependencies = [ name = "bili_sync_entity" version = "2.6.3" dependencies = [ + "derivative", + "regex", "sea-orm", + "serde", "serde_json", ] @@ -1034,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" diff --git a/Cargo.toml b/Cargo.toml index 8e1e0e2..c6536bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/bili_sync/src/adapter/collection.rs b/crates/bili_sync/src/adapter/collection.rs index 2ffdf5d..319d18a 100644 --- a/crates/bili_sync/src/adapter/collection.rs +++ b/crates/bili_sync/src/adapter/collection.rs @@ -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; @@ -63,6 +64,10 @@ impl VideoSource for collection::Model { None } + fn rule(&self) -> Option<&Rule> { + self.rule.as_ref() + } + async fn refresh<'a>( self, bili_client: &'a BiliClient, diff --git a/crates/bili_sync/src/adapter/favorite.rs b/crates/bili_sync/src/adapter/favorite.rs index 3bb8162..3c07bd8 100644 --- a/crates/bili_sync/src/adapter/favorite.rs +++ b/crates/bili_sync/src/adapter/favorite.rs @@ -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.as_ref() + } + async fn refresh<'a>( self, bili_client: &'a BiliClient, diff --git a/crates/bili_sync/src/adapter/mod.rs b/crates/bili_sync/src/adapter/mod.rs index 63c9ffb..fc36fb7 100644 --- a/crates/bili_sync/src/adapter/mod.rs +++ b/crates/bili_sync/src/adapter/mod.rs @@ -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()); } diff --git a/crates/bili_sync/src/adapter/submission.rs b/crates/bili_sync/src/adapter/submission.rs index 8851691..8aaf4a0 100644 --- a/crates/bili_sync/src/adapter/submission.rs +++ b/crates/bili_sync/src/adapter/submission.rs @@ -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.as_ref() + } + async fn refresh<'a>( self, bili_client: &'a BiliClient, diff --git a/crates/bili_sync/src/adapter/watch_later.rs b/crates/bili_sync/src/adapter/watch_later.rs index 93c8e2d..9f1e31c 100644 --- a/crates/bili_sync/src/adapter/watch_later.rs +++ b/crates/bili_sync/src/adapter/watch_later.rs @@ -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.as_ref() + } + async fn refresh<'a>( self, bili_client: &'a BiliClient, diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index d9da879..407982f 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -1,3 +1,4 @@ +use bili_sync_entity::rule::Rule; use serde::Deserialize; use validator::Validate; @@ -86,4 +87,5 @@ pub struct UpdateVideoSourceRequest { #[validate(custom(function = "crate::utils::validation::validate_path"))] pub path: String, pub enabled: bool, + pub rule: Option, } diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index 2f57bf3..828953f 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -1,3 +1,4 @@ +use bili_sync_entity::rule::Rule; use bili_sync_entity::*; use sea_orm::{DerivePartialModel, FromQueryResult}; use serde::Serialize; @@ -169,9 +170,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, + #[serde(default)] + pub rule_display: Option, pub enabled: bool, } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateVideoSourceResponse { + pub rule_display: Option, +} diff --git a/crates/bili_sync/src/api/routes/video_sources/mod.rs b/crates/bili_sync/src/api/routes/video_sources/mod.rs index 86195d5..731fe38 100644 --- a/crates/bili_sync/src/api/routes/video_sources/mod.rs +++ b/crates/bili_sync/src/api/routes/video_sources/mod.rs @@ -14,7 +14,9 @@ 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}; @@ -75,13 +77,14 @@ pub async fn get_video_sources( pub async fn get_video_sources_details( Extension(db): Extension, ) -> Result, 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::() @@ -92,22 +95,31 @@ pub async fn get_video_sources_details( favorite::Column::Id, favorite::Column::Name, favorite::Column::Path, + favorite::Column::Rule, favorite::Column::Enabled ]) .into_model::() .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::() .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::() .all(&db) )?; @@ -116,9 +128,18 @@ pub async fn get_video_sources_details( 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, @@ -132,24 +153,28 @@ pub async fn update_video_source( Path((source_type, id)): Path<(String, i32)>, Extension(db): Extension, ValidatedJson(request): ValidatedJson, -) -> Result, ApiError> { +) -> Result, 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).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).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).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).await? { @@ -160,6 +185,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 +196,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() })) } @@ -181,7 +208,7 @@ pub async fn update_video_source( return Err(InnerApiError::NotFound(id).into()); }; active_model.save(&db).await?; - Ok(ApiResponse::ok(true)) + Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display })) } /// 新增收藏夹订阅 @@ -196,7 +223,7 @@ 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) @@ -225,7 +252,7 @@ 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) @@ -246,7 +273,7 @@ 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) diff --git a/crates/bili_sync/src/bilibili/video.rs b/crates/bili_sync/src/bilibili/video.rs index e799c65..e8852f5 100644 --- a/crates/bili_sync/src/bilibili/video.rs +++ b/crates/bili_sync/src/bilibili/video.rs @@ -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(&self, serializer: S) -> core::result::Result - 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> { - let mut res = self + pub async fn get_tags(&self) -> Result> { + let res = self .client .request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag") .await @@ -96,7 +83,12 @@ impl<'a> Video<'a> { .json::() .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 { diff --git a/crates/bili_sync/src/utils/convert.rs b/crates/bili_sync/src/utils/convert.rs index a8fa865..ee34f7b 100644 --- a/crates/bili_sync/src/utils/convert.rs +++ b/crates/bili_sync/src/utils/convert.rs @@ -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 填充 }, diff --git a/crates/bili_sync/src/utils/mod.rs b/crates/bili_sync/src/utils/mod.rs index 7bcf70e..08dc5ac 100644 --- a/crates/bili_sync/src/utils/mod.rs +++ b/crates/bili_sync/src/utils/mod.rs @@ -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; diff --git a/crates/bili_sync/src/utils/model.rs b/crates/bili_sync/src/utils/model.rs index 78fc57c..6215906 100644 --- a/crates/bili_sync/src/utils/model.rs +++ b/crates/bili_sync/src/utils/model.rs @@ -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) diff --git a/crates/bili_sync/src/utils/nfo.rs b/crates/bili_sync/src/utils/nfo.rs index 3726584..8e719ac 100644 --- a/crates/bili_sync/src/utils/nfo.rs +++ b/crates/bili_sync/src/utils/nfo.rs @@ -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()), } } } diff --git a/crates/bili_sync/src/utils/rule.rs b/crates/bili_sync/src/utils/rule.rs new file mode 100644 index 0000000..5cc767c --- /dev/null +++ b/crates/bili_sync/src/utils/rule.rs @@ -0,0 +1,224 @@ +use bili_sync_entity::rule::{AndGroup, Condition, Rule, RuleTarget}; +use bili_sync_entity::{page, video}; +use chrono::{Local, NaiveDateTime}; + +pub(crate) trait Evaluatable { + fn evaluate(&self, value: T) -> bool; +} + +pub(crate) trait FieldEvaluatable { + fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool; +} + +impl Evaluatable<&str> for Condition { + 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 for Condition { + 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 { + 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 { + 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), + } + } +} + +impl FieldEvaluatable for AndGroup { + fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool { + self.iter().all(|target| target.evaluate(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)) + } +} + +#[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(), + )), + RuleTarget::Tags(Condition::MatchesRegex( + "技术|教程".to_string(), + regex::Regex::new("技术|教程").unwrap(), + )), + ]]), + "「(收藏时间在“2023-06-01 00:00:00”和“2023-12-31 23:59:59”之间)且(标签匹配“技术|教程”)」", + ), + ]; + + 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); + } + } +} diff --git a/crates/bili_sync/src/utils/status.rs b/crates/bili_sync/src/utils/status.rs index e913ff5..779465e 100644 --- a/crates/bili_sync/src/utils/status.rs +++ b/crates/bili_sync/src/utils/status.rs @@ -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::*; diff --git a/crates/bili_sync/src/workflow.rs b/crates/bili_sync/src/workflow.rs index af08ae0..e14e167 100644 --- a/crates/bili_sync/src/workflow.rs +++ b/crates/bili_sync/src/workflow.rs @@ -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}; /// 完整地处理某个视频来源 @@ -136,7 +137,10 @@ pub async fn fetch_video_details( 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.tags = Set(Some(tags.into())); + video_active_model.should_download = Set(video_source + .rule() + .is_none_or(|r| r.evaluate(&video_active_model, &pages))); let txn = connection.begin().await?; create_pages(pages, &txn).await?; video_active_model.save(&txn).await?; diff --git a/crates/bili_sync_entity/Cargo.toml b/crates/bili_sync_entity/Cargo.toml index f16459f..18b8f70 100644 --- a/crates/bili_sync_entity/Cargo.toml +++ b/crates/bili_sync_entity/Cargo.toml @@ -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 } diff --git a/crates/bili_sync_entity/src/custom_type/mod.rs b/crates/bili_sync_entity/src/custom_type/mod.rs new file mode 100644 index 0000000..d87b844 --- /dev/null +++ b/crates/bili_sync_entity/src/custom_type/mod.rs @@ -0,0 +1,2 @@ +pub mod rule; +pub mod string_vec; diff --git a/crates/bili_sync_entity/src/custom_type/rule.rs b/crates/bili_sync_entity/src/custom_type/rule.rs new file mode 100644 index 0000000..e38f406 --- /dev/null +++ b/crates/bili_sync_entity/src/custom_type/rule.rs @@ -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 { + 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), + Tags(Condition), + FavTime(Condition), + PubTime(Condition), + PageCount(Condition), + Not(Box), +} + +pub type AndGroup = Vec; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)] +pub struct Rule(pub Vec); + +impl Display for Condition { + 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 = self + .0 + .iter() + .map(|group| { + let conditions: Vec = 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(|e| serde::de::Error::custom(e))?; + Ok((pattern, regex)) +} + +fn serialize_regex(pattern: &String, _regex: ®ex::Regex, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(pattern) +} diff --git a/crates/bili_sync_entity/src/custom_type/string_vec.rs b/crates/bili_sync_entity/src/custom_type/string_vec.rs new file mode 100644 index 0000000..98d8ae9 --- /dev/null +++ b/crates/bili_sync_entity/src/custom_type/string_vec.rs @@ -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); + +impl From> for StringVec { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl From for Vec { + fn from(value: StringVec) -> Self { + value.0 + } +} diff --git a/crates/bili_sync_entity/src/entities/collection.rs b/crates/bili_sync_entity/src/entities/collection.rs index e38839e..43d369e 100644 --- a/crates/bili_sync_entity/src/entities/collection.rs +++ b/crates/bili_sync_entity/src/entities/collection.rs @@ -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, pub enabled: bool, } diff --git a/crates/bili_sync_entity/src/entities/favorite.rs b/crates/bili_sync_entity/src/entities/favorite.rs index 56c1daf..f8db60c 100644 --- a/crates/bili_sync_entity/src/entities/favorite.rs +++ b/crates/bili_sync_entity/src/entities/favorite.rs @@ -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, pub enabled: bool, } diff --git a/crates/bili_sync_entity/src/entities/submission.rs b/crates/bili_sync_entity/src/entities/submission.rs index 6937fb9..4aa993f 100644 --- a/crates/bili_sync_entity/src/entities/submission.rs +++ b/crates/bili_sync_entity/src/entities/submission.rs @@ -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, pub enabled: bool, } diff --git a/crates/bili_sync_entity/src/entities/video.rs b/crates/bili_sync_entity/src/entities/video.rs index cc40502..9c51b79 100644 --- a/crates/bili_sync_entity/src/entities/video.rs +++ b/crates/bili_sync_entity/src/entities/video.rs @@ -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, + pub should_download: bool, + pub tags: Option, pub single_page: Option, pub created_at: String, } diff --git a/crates/bili_sync_entity/src/entities/watch_later.rs b/crates/bili_sync_entity/src/entities/watch_later.rs index da4986d..35b669c 100644 --- a/crates/bili_sync_entity/src/entities/watch_later.rs +++ b/crates/bili_sync_entity/src/entities/watch_later.rs @@ -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, pub enabled: bool, } diff --git a/crates/bili_sync_entity/src/lib.rs b/crates/bili_sync_entity/src/lib.rs index 21c129f..8223ef9 100644 --- a/crates/bili_sync_entity/src/lib.rs +++ b/crates/bili_sync_entity/src/lib.rs @@ -1,2 +1,5 @@ +mod custom_type; mod entities; + +pub use custom_type::*; pub use entities::*; diff --git a/crates/bili_sync_migration/src/lib.rs b/crates/bili_sync_migration/src/lib.rs index ff8ee24..464860e 100644 --- a/crates/bili_sync_migration/src/lib.rs +++ b/crates/bili_sync_migration/src/lib.rs @@ -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), ] } } diff --git a/crates/bili_sync_migration/src/m20250903_094454_add_rule_and_should_download.rs b/crates/bili_sync_migration/src/m20250903_094454_add_rule_and_should_download.rs new file mode 100644 index 0000000..bb01c7d --- /dev/null +++ b/crates/bili_sync_migration/src/m20250903_094454_add_rule_and_should_download.rs @@ -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, +} diff --git a/web/bun.lock b/web/bun.lock index 2eee93c..a4a588f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/package.json b/web/package.json index d11d285..d4a5042 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,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", @@ -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", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6e0d6a4..ca9980b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -21,7 +21,8 @@ import type { DashBoardResponse, SysInfo, TaskStatus, - ResetRequest + ResetRequest, + UpdateVideoSourceResponse } from './types'; import { wsManager } from './ws'; @@ -212,8 +213,8 @@ class ApiClient { type: string, id: number, request: UpdateVideoSourceRequest - ): Promise> { - return this.put(`/video-sources/${type}/${id}`, request); + ): Promise> { + return this.put(`/video-sources/${type}/${id}`, request); } async getConfig(): Promise> { diff --git a/web/src/lib/components/rule-editor.svelte b/web/src/lib/components/rule-editor.svelte new file mode 100644 index 0000000..dd03444 --- /dev/null +++ b/web/src/lib/components/rule-editor.svelte @@ -0,0 +1,479 @@ + + +
+
+ +
+ {#if localRule.length > 0} + + {/if} + +
+
+ + {#if localRule.length === 0} +
+

暂无过滤规则,将下载所有视频

+ +
+ {:else} +
+ {#each localRule as andGroup, groupIndex (groupIndex)} + + +
+
+ 规则组 {groupIndex + 1} +
+ +
+
+ + {#each andGroup.conditions as condition, conditionIndex (conditionIndex)} +
+
+ 条件 {conditionIndex + 1} + +
+ + +
+ + updateCondition( + groupIndex, + conditionIndex, + 'isNot', + checked ? 'true' : 'false' + )} + /> + +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ + {#if condition.operator === 'between'} +
+ {#if condition.field === 'pageCount'} + + updateCondition( + groupIndex, + conditionIndex, + 'value', + e.currentTarget.value + )} + /> + + updateCondition( + groupIndex, + conditionIndex, + 'value2', + e.currentTarget.value + )} + /> + {:else if condition.field === 'favTime' || condition.field === 'pubTime'} + + updateCondition( + groupIndex, + conditionIndex, + 'value', + e.currentTarget.value + ':00' // 前端选择器只能精确到分钟,此处附加额外的 :00 以满足后端传参条件 + )} + /> + + updateCondition( + groupIndex, + conditionIndex, + 'value2', + e.currentTarget.value + ':00' + )} + /> + {:else} + + updateCondition( + groupIndex, + conditionIndex, + 'value', + e.currentTarget.value + )} + /> + + updateCondition( + groupIndex, + conditionIndex, + 'value2', + e.currentTarget.value + )} + /> + {/if} +
+ {:else if condition.field === 'pageCount'} + + updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)} + /> + {:else if condition.field === 'favTime' || condition.field === 'pubTime'} + + updateCondition( + groupIndex, + conditionIndex, + 'value', + e.currentTarget.value + ':00' + )} + /> + {:else} + + updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)} + /> + {/if} +
+
+ {/each} + + +
+
+ {/each} +
+ {/if} + + {#if localRule.length > 0} +
+

规则说明:

+
    +
  • • 多个规则组之间是"或"的关系,同一规则组内的条件是"且"的关系
  • +
  • + • 规则内配置的时间不包含时区,在处理时会默认应用服务器时区,不受浏览器影响 +
  • +
+
+ {/if} +
diff --git a/web/src/lib/components/ui/popover/index.ts b/web/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..0dcc3cc --- /dev/null +++ b/web/src/lib/components/ui/popover/index.ts @@ -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 +}; diff --git a/web/src/lib/components/ui/popover/popover-content.svelte b/web/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..620f392 --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/web/src/lib/components/ui/popover/popover-trigger.svelte b/web/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..f21b1b3 --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index baa1e3e..c0de8c3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -168,11 +168,27 @@ export interface InsertSubmissionRequest { path: string; } +// Rule 相关类型 +export interface Condition { + operator: string; + value: T | T[]; +} + +export interface RuleTarget { + field: string; + rule: Condition | RuleTarget; +} + +export type AndGroup = RuleTarget[]; +export type Rule = AndGroup[]; + // 视频源详细信息类型 export interface VideoSourceDetail { id: number; name: string; path: string; + rule?: Rule | null; + ruleDisplay?: string | null; enabled: boolean; } @@ -188,6 +204,7 @@ export interface VideoSourcesDetailsResponse { export interface UpdateVideoSourceRequest { path: string; enabled: boolean; + rule?: Rule | null; } // 配置相关类型 @@ -295,3 +312,7 @@ export interface TaskStatus { last_finish: Date | null; next_run: Date | null; } + +export interface UpdateVideoSourceResponse { + ruleDisplay?: string; +} diff --git a/web/src/routes/video-sources/+page.svelte b/web/src/routes/video-sources/+page.svelte index abd3363..f0f1108 100644 --- a/web/src/routes/video-sources/+page.svelte +++ b/web/src/routes/video-sources/+page.svelte @@ -8,17 +8,17 @@ 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'; let videoSourcesData: VideoSourcesDetailsResponse | null = null; let loading = false; @@ -29,19 +29,25 @@ 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 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 +70,65 @@ } } - 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; + // 保存编辑 + async function saveEdit() { + if (!editingSource) return; - const source = sources[index] as ExtendedVideoSource; - source.editing = false; - source.editingPath = undefined; - source.editingEnabled = undefined; - videoSourcesData = { ...videoSourcesData }; - } - - async function saveEdit(type: string, index: number) { - if (!videoSourcesData) return; - const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse]; - if (!sources?.[index]) 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[] { + 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 +234,60 @@ - 名称 - 下载路径 - 状态 - 操作 + 名称 + 下载路径 + 筛选规则 + 状态 + 操作 {#each sources as source, index (index)} - {source.name} - - {#if source.editing} - + {source.name} + + + {source.path || '未设置'} + + + + {#if source.rule && source.rule.length > 0} +
+ + +
+ {source.rule.length} 条规则 +
+
+ +

{source.ruleDisplay}

+
+
+
{:else} - - {source.path || '未设置'} - + {/if}
- - {#if source.editing} -
- -
- {:else} -
- - - {source.enabled ? '已启用' : '已禁用'} - -
- {/if} + +
+ + + {source.enabled ? '已启用' : '已禁用'} + +
- - {#if source.editing} -
- - -
- {:else} - - {/if} + +
{/each} @@ -352,11 +328,50 @@ {/if} + + + + + 编辑视频源: {editingSource?.name || ''} + +
+ +
+ + +
+ + +
+ + +
+ + +
+ (editForm.rule = rule)} /> +
+
+
+ + +
+
+
+ + - - + {#if addDialogType === 'favorites'} 添加收藏夹