From 4db7e6763afe286207ba2b06625b37d142a20b69 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 17:08:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=AF=84=E4=BC=B0=E5=8E=86=E5=8F=B2=E8=A7=86=E9=A2=91=EF=BC=8C?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA=E8=A7=86=E9=A2=91=E7=9A=84?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=AF=84=E4=BC=B0=E7=8A=B6=E6=80=81=20(#465)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/adapter/collection.rs | 4 +- crates/bili_sync/src/adapter/favorite.rs | 4 +- crates/bili_sync/src/adapter/mod.rs | 2 +- crates/bili_sync/src/adapter/submission.rs | 4 +- crates/bili_sync/src/adapter/watch_later.rs | 4 +- crates/bili_sync/src/api/response.rs | 1 + .../src/api/routes/video_sources/mod.rs | 83 ++++++++++++++++++- crates/bili_sync/src/utils/rule.rs | 41 +++++++++ crates/bili_sync/src/workflow.rs | 4 +- web/src/app.css | 10 +++ web/src/lib/api.ts | 6 ++ web/src/lib/components/video-card.svelte | 27 ++++-- web/src/lib/types.ts | 1 + web/src/routes/video-sources/+page.svelte | 75 ++++++++++++++++- web/src/routes/video/[id]/+page.svelte | 6 +- 15 files changed, 246 insertions(+), 26 deletions(-) diff --git a/crates/bili_sync/src/adapter/collection.rs b/crates/bili_sync/src/adapter/collection.rs index 319d18a..b320f15 100644 --- a/crates/bili_sync/src/adapter/collection.rs +++ b/crates/bili_sync/src/adapter/collection.rs @@ -64,8 +64,8 @@ impl VideoSource for collection::Model { None } - fn rule(&self) -> Option<&Rule> { - self.rule.as_ref() + fn rule(&self) -> &Option { + &self.rule } async fn refresh<'a>( diff --git a/crates/bili_sync/src/adapter/favorite.rs b/crates/bili_sync/src/adapter/favorite.rs index 3c07bd8..c4651c0 100644 --- a/crates/bili_sync/src/adapter/favorite.rs +++ b/crates/bili_sync/src/adapter/favorite.rs @@ -43,8 +43,8 @@ impl VideoSource for favorite::Model { }) } - fn rule(&self) -> Option<&Rule> { - self.rule.as_ref() + fn rule(&self) -> &Option { + &self.rule } async fn refresh<'a>( diff --git a/crates/bili_sync/src/adapter/mod.rs b/crates/bili_sync/src/adapter/mod.rs index fc36fb7..4ddd0cd 100644 --- a/crates/bili_sync/src/adapter/mod.rs +++ b/crates/bili_sync/src/adapter/mod.rs @@ -69,7 +69,7 @@ pub trait VideoSource { video_info.ok() } - fn rule(&self) -> Option<&Rule>; + fn rule(&self) -> &Option; 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 8aaf4a0..0b9e541 100644 --- a/crates/bili_sync/src/adapter/submission.rs +++ b/crates/bili_sync/src/adapter/submission.rs @@ -42,8 +42,8 @@ impl VideoSource for submission::Model { }) } - fn rule(&self) -> Option<&Rule> { - self.rule.as_ref() + fn rule(&self) -> &Option { + &self.rule } async fn refresh<'a>( diff --git a/crates/bili_sync/src/adapter/watch_later.rs b/crates/bili_sync/src/adapter/watch_later.rs index 9f1e31c..32f63f2 100644 --- a/crates/bili_sync/src/adapter/watch_later.rs +++ b/crates/bili_sync/src/adapter/watch_later.rs @@ -42,8 +42,8 @@ impl VideoSource for watch_later::Model { }) } - fn rule(&self) -> Option<&Rule> { - self.rule.as_ref() + fn rule(&self) -> &Option { + &self.rule } async fn refresh<'a>( diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index 828953f..38d014f 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -59,6 +59,7 @@ pub struct VideoInfo { pub bvid: String, pub name: String, pub upper_name: String, + pub should_download: bool, #[serde(serialize_with = "serde_video_download_status")] pub download_status: u32, } 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 731fe38..0a070e2 100644 --- a/crates/bili_sync/src/api/routes/video_sources/mod.rs +++ b/crates/bili_sync/src/api/routes/video_sources/mod.rs @@ -4,10 +4,12 @@ use anyhow::Result; use axum::Router; use axum::extract::{Extension, Path}; use axum::routing::{get, post, put}; +use bili_sync_entity::rule::Rule; use bili_sync_entity::*; use bili_sync_migration::Expr; use sea_orm::ActiveValue::Set; -use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect}; +use sea_orm::entity::prelude::*; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, TransactionTrait}; use crate::adapter::_ActiveModel; use crate::api::error::InnerApiError; @@ -19,12 +21,14 @@ use crate::api::response::{ }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission}; +use crate::utils::rule::FieldEvaluatable; pub(super) fn router() -> Router { Router::new() .route("/video-sources", get(get_video_sources)) .route("/video-sources/details", get(get_video_sources_details)) .route("/video-sources/{type}/{id}", put(update_video_source)) + .route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source)) .route("/video-sources/favorites", post(insert_favorite)) .route("/video-sources/collections", post(insert_collection)) .route("/video-sources/submissions", post(insert_submission)) @@ -211,6 +215,83 @@ pub async fn update_video_source( Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display })) } +pub async fn evaluate_video_source( + Path((source_type, id)): Path<(String, i32)>, + Extension(db): Extension, +) -> Result, ApiError> { + // 找出对应 source 的规则与 video 筛选条件 + let (rule, filter_condition) = match source_type.as_str() { + "collections" => ( + collection::Entity::find_by_id(id) + .select_only() + .column(collection::Column::Rule) + .into_tuple::>() + .one(&db) + .await? + .and_then(|r| r), + video::Column::CollectionId.eq(id), + ), + "favorites" => ( + favorite::Entity::find_by_id(id) + .select_only() + .column(favorite::Column::Rule) + .into_tuple::>() + .one(&db) + .await? + .and_then(|r| r), + video::Column::FavoriteId.eq(id), + ), + "submissions" => ( + submission::Entity::find_by_id(id) + .select_only() + .column(submission::Column::Rule) + .into_tuple::>() + .one(&db) + .await? + .and_then(|r| r), + video::Column::SubmissionId.eq(id), + ), + "watch_later" => ( + watch_later::Entity::find_by_id(id) + .select_only() + .column(watch_later::Column::Rule) + .into_tuple::>() + .one(&db) + .await? + .and_then(|r| r), + video::Column::WatchLaterId.eq(id), + ), + _ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()), + }; + let videos: Vec<(video::Model, Vec)> = video::Entity::find() + .filter(filter_condition) + .find_with_related(page::Entity) + .all(&db) + .await?; + let video_should_download_pairs = videos + .into_iter() + .map(|(video, pages)| (video.id, rule.evaluate_model(&video, &pages))) + .collect::>(); + let txn = db.begin().await?; + for chunk in video_should_download_pairs.chunks(500) { + let sql = format!( + "WITH tempdata(id, should_download) AS (VALUES {}) \ + UPDATE video \ + SET should_download = tempdata.should_download \ + FROM tempdata \ + WHERE video.id = tempdata.id", + chunk + .iter() + .map(|item| format!("({}, {})", item.0, item.1)) + .collect::>() + .join(", ") + ); + txn.execute_unprepared(&sql).await?; + } + txn.commit().await?; + Ok(ApiResponse::ok(true)) +} + /// 新增收藏夹订阅 pub async fn insert_favorite( Extension(db): Extension, diff --git a/crates/bili_sync/src/utils/rule.rs b/crates/bili_sync/src/utils/rule.rs index 5cc767c..0e770c9 100644 --- a/crates/bili_sync/src/utils/rule.rs +++ b/crates/bili_sync/src/utils/rule.rs @@ -8,6 +8,7 @@ pub(crate) trait Evaluatable { pub(crate) trait FieldEvaluatable { fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool; + fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool; } impl Evaluatable<&str> for Condition { @@ -48,6 +49,7 @@ impl Evaluatable<&NaiveDateTime> for Condition { } impl FieldEvaluatable for RuleTarget { + /// 修改模型后进行评估,此时能访问的是未保存的 activeModel,就地使用 activeModel 评估 fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool { match self { RuleTarget::Title(cond) => video.name.try_as_ref().is_some_and(|title| cond.evaluate(&title)), @@ -72,12 +74,33 @@ impl FieldEvaluatable for RuleTarget { RuleTarget::Not(inner) => !inner.evaluate(video, pages), } } + + /// 手动触发对历史视频的评估,拿到的是原始 Model,直接使用 + fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool { + match self { + RuleTarget::Title(cond) => cond.evaluate(&video.name), + // 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b")) + // 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改 + RuleTarget::Tags(cond) => video + .tags + .as_ref() + .is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(&tag))), + RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()), + RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()), + RuleTarget::PageCount(cond) => cond.evaluate(pages.len()), + RuleTarget::Not(inner) => !inner.evaluate_model(video, pages), + } + } } impl FieldEvaluatable for AndGroup { fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool { self.iter().all(|target| target.evaluate(video, pages)) } + + fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool { + self.iter().all(|target| target.evaluate_model(video, pages)) + } } impl FieldEvaluatable for Rule { @@ -87,6 +110,24 @@ impl FieldEvaluatable for Rule { } self.0.iter().any(|group| group.evaluate(video, pages)) } + + fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool { + if self.0.is_empty() { + return true; + } + self.0.iter().any(|group| group.evaluate_model(video, pages)) + } +} + +/// 对于 Option 如果 rule 不存在应该被认为是通过评估 +impl FieldEvaluatable for Option { + fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool { + self.as_ref().is_none_or(|rule| rule.evaluate(video, pages)) + } + + fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool { + self.as_ref().is_none_or(|rule| rule.evaluate_model(video, pages)) + } } #[cfg(test)] diff --git a/crates/bili_sync/src/workflow.rs b/crates/bili_sync/src/workflow.rs index e14e167..b9c1370 100644 --- a/crates/bili_sync/src/workflow.rs +++ b/crates/bili_sync/src/workflow.rs @@ -138,9 +138,7 @@ pub async fn fetch_video_details( video_source.set_relation_id(&mut video_active_model); video_active_model.single_page = Set(Some(pages.len() == 1)); video_active_model.tags = Set(Some(tags.into())); - video_active_model.should_download = Set(video_source - .rule() - .is_none_or(|r| r.evaluate(&video_active_model, &pages))); + video_active_model.should_download = Set(video_source.rule().evaluate(&video_active_model, &pages)); let txn = connection.begin().await?; create_pages(pages, &txn).await?; video_active_model.save(&txn).await?; diff --git a/web/src/app.css b/web/src/app.css index 10891f0..528aa90 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -4,6 +4,16 @@ @custom-variant dark (&:is(.dark *)); +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} + html { scroll-behavior: smooth !important; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ca9980b..6c4a4ce 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -217,6 +217,10 @@ class ApiClient { return this.put(`/video-sources/${type}/${id}`, request); } + async evaluateVideoSourceRules(type: string, id: number): Promise> { + return this.post(`/video-sources/${type}/${id}/evaluate`, null); + } + async getConfig(): Promise> { return this.get('/config'); } @@ -262,6 +266,8 @@ const api = { getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(), updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) => apiClient.updateVideoSource(type, id, request), + evaluateVideoSourceRules: (type: string, id: number) => + apiClient.evaluateVideoSourceRules(type, id), getConfig: () => apiClient.getConfig(), updateConfig: (config: Config) => apiClient.updateConfig(config), getDashboard: () => apiClient.getDashboard(), diff --git a/web/src/lib/components/video-card.svelte b/web/src/lib/components/video-card.svelte index 71ada90..da79b9a 100644 --- a/web/src/lib/components/video-card.svelte +++ b/web/src/lib/components/video-card.svelte @@ -49,20 +49,30 @@ } } - function getOverallStatus(downloadStatus: number[]): { + function getOverallStatus( + downloadStatus: number[], + shouldDownload: boolean + ): { text: string; - color: 'default' | 'secondary' | 'destructive' | 'outline'; + style: string; } { + if (!shouldDownload) { + // 被筛选规则排除,显示为“跳过” + return { text: '跳过', style: 'bg-gray-100 text-gray-700' }; + } const completed = downloadStatus.filter((status) => status === 7).length; const total = downloadStatus.length; const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length; if (completed === total) { - return { text: '完成', color: 'outline' }; + // 全部完成,显示为“完成” + return { text: '完成', style: 'bg-emerald-700 text-emerald-100' }; } else if (failed > 0) { - return { text: '失败', color: 'destructive' }; + // 出现了失败,显示为“失败” + return { text: '失败', style: 'bg-rose-700 text-rose-100' }; } else { - return { text: '进行中', color: 'secondary' }; + // 还未开始,显示为“等待” + return { text: '等待', style: 'bg-yellow-700 text-yellow-100' }; } } @@ -74,7 +84,7 @@ return defaultTaskNames[index] || `任务${index + 1}`; } - $: overallStatus = getOverallStatus(video.download_status); + $: overallStatus = getOverallStatus(video.download_status, video.should_download); $: completed = video.download_status.filter((status) => status === 7).length; $: total = video.download_status.length; @@ -112,7 +122,10 @@ > {displayTitle} - + {overallStatus.text} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c0de8c3..2b3e903 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -36,6 +36,7 @@ export interface VideoInfo { bvid: string; name: string; upper_name: string; + should_download: boolean; download_status: [number, number, number, number, number]; } diff --git a/web/src/routes/video-sources/+page.svelte b/web/src/routes/video-sources/+page.svelte index f0f1108..99d5734 100644 --- a/web/src/routes/video-sources/+page.svelte +++ b/web/src/routes/video-sources/+page.svelte @@ -19,6 +19,8 @@ import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types'; import api from '$lib/api'; import RuleEditor from '$lib/components/rule-editor.svelte'; + import ListRestartIcon from '@lucide/svelte/icons/list-restart'; + import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; let videoSourcesData: VideoSourcesDetailsResponse | null = null; let loading = false; @@ -36,6 +38,12 @@ let editingIdx: number = 0; let saving = false; + // 规则评估对话框状态 + let showEvaluateDialog = false; + let evaluateSource: VideoSourceDetail | null = null; + let evaluateType = ''; + let evaluating = false; + // 编辑表单数据 let editForm = { path: '', @@ -83,6 +91,12 @@ showEditDialog = true; } + function openEvaluateRules(type: string, source: VideoSourceDetail) { + evaluateSource = source; + evaluateType = type; + showEvaluateDialog = true; + } + // 保存编辑 async function saveEdit() { if (!editingSource) return; @@ -91,7 +105,6 @@ toast.error('路径不能为空'); return; } - saving = true; try { let response = await api.updateVideoSource(editingType, editingSource.id, { @@ -99,7 +112,6 @@ enabled: editForm.enabled, rule: editForm.rule }); - // 更新本地数据 if (videoSourcesData && editingSource) { const sources = videoSourcesData[ @@ -114,7 +126,6 @@ }; videoSourcesData = { ...videoSourcesData }; } - showEditDialog = false; toast.success('保存成功'); } catch (error) { @@ -126,6 +137,26 @@ } } + async function evaluateRules() { + if (!evaluateSource) return; + evaluating = true; + try { + let response = await api.evaluateVideoSourceRules(evaluateType, evaluateSource.id); + if (response && response.data) { + showEvaluateDialog = false; + toast.success('重新评估规则成功'); + } else { + toast.error('重新评估规则失败'); + } + } catch (error) { + toast.error('重新评估规则失败', { + description: (error as ApiError).message + }); + } finally { + evaluating = false; + } + } + function getSourcesForTab(tabValue: string): VideoSourceDetail[] { if (!videoSourcesData) return []; return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[]; @@ -288,6 +319,15 @@ > + {/each} @@ -330,7 +370,9 @@ - + 编辑视频源: {editingSource?.name || ''} @@ -369,6 +411,31 @@ + + + + 重新评估规则 + + 确定要重新评估视频源 "{evaluateSource?.name}" 的筛选规则吗?
+ 规则修改后默认仅对新视频生效,该操作可使用当前规则对数据库中已存在的历史视频进行重新评估,无法撤销
+
+
+ + { + showEvaluateDialog = false; + }}>取消 + + {evaluating ? '重新评估中' : '确认重新评估'} + + +
+
+ diff --git a/web/src/routes/video/[id]/+page.svelte b/web/src/routes/video/[id]/+page.svelte index 21b7657..bd83330 100644 --- a/web/src/routes/video/[id]/+page.svelte +++ b/web/src/routes/video/[id]/+page.svelte @@ -156,7 +156,8 @@ bvid: videoData.video.bvid, name: videoData.video.name, upper_name: videoData.video.upper_name, - download_status: videoData.video.download_status + download_status: videoData.video.download_status, + should_download: videoData.video.should_download }} mode="detail" showActions={false} @@ -209,7 +210,8 @@ id: pageInfo.id, name: `P${pageInfo.pid}: ${pageInfo.name}`, upper_name: '', - download_status: pageInfo.download_status + download_status: pageInfo.download_status, + should_download: videoData.video.should_download }} mode="page" showActions={false}