feat: 实现视频的筛选规则 (#457)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-09-24 00:42:27 +08:00
committed by GitHub
parent 6c7d295fe6
commit 210c94398a
39 changed files with 1345 additions and 181 deletions

14
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

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;
@@ -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,

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.as_ref()
}
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.as_ref()
}
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.as_ref()
}
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;
@@ -86,4 +87,5 @@ 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;
@@ -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<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

@@ -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<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>()
@@ -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::<VideoSourceDetail>()
.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),
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)
)?;
@@ -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<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).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)

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,7 +83,12 @@ 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> {

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 填充
},

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

@@ -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)

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,224 @@
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;
}
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 {
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);
}
}
}

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

@@ -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?;

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(|e| serde::de::Error::custom(e))?;
Ok((pattern, regex))
}
fn serialize_regex<S>(pattern: &String, _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

@@ -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

@@ -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",

View File

@@ -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<ApiResponse<boolean>> {
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
}
async getConfig(): Promise<ApiResponse<Config>> {

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

@@ -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

@@ -168,11 +168,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 +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;
}

View File

@@ -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 @@
<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-[25%]">名称</Table.Head>
<Table.Head class="w-[35%]">下载路径</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>
<div class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
{source.rule.length} 条规则
</div>
</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>
</Table.Cell>
</Table.Row>
{/each}
@@ -352,11 +328,50 @@
</div>
{/if}
<!-- 编辑对话框 -->
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content class="max-h-[85vh] w-4xl !max-w-none overflow-y-auto">
<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>
<!-- 添加对话框 -->
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
<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"
>
<Dialog.Content>
<Dialog.Title class="text-lg font-semibold">
{#if addDialogType === 'favorites'}
添加收藏夹