diff --git a/crates/bili_sync/src/api/helper.rs b/crates/bili_sync/src/api/helper.rs index 296e1c6..864e8ff 100644 --- a/crates/bili_sync/src/api/helper.rs +++ b/crates/bili_sync/src/api/helper.rs @@ -1,9 +1,11 @@ use std::borrow::Borrow; +use bili_sync_entity::video; +use bili_sync_migration::SimpleExpr; use itertools::Itertools; -use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction}; +use sea_orm::{ColumnTrait, Condition, ConnectionTrait, DatabaseTransaction}; -use crate::api::request::StatusFilter; +use crate::api::request::{StatusFilter, ValidationFilter}; use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo}; use crate::utils::status::VideoStatus; @@ -18,6 +20,20 @@ impl StatusFilter { } } +impl ValidationFilter { + pub fn to_video_query(&self) -> SimpleExpr { + match self { + ValidationFilter::Invalid => video::Column::Valid.eq(false), + ValidationFilter::Skipped => video::Column::Valid + .eq(true) + .and(video::Column::ShouldDownload.eq(false)), + ValidationFilter::Normal => video::Column::Valid + .eq(true) + .and(video::Column::ShouldDownload.eq(true)), + } + } +} + pub trait VideoRecord { fn as_id_status_tuple(&self) -> (i32, u32); } diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index e4fcf69..6bba0c2 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -12,6 +12,14 @@ pub enum StatusFilter { Waiting, } +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ValidationFilter { + Skipped, + Invalid, + Normal, +} + #[derive(Deserialize)] pub struct VideosRequest { pub collection: Option, @@ -20,6 +28,7 @@ pub struct VideosRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, pub page: Option, pub page_size: Option, } @@ -38,6 +47,7 @@ pub struct ResetFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, #[serde(default)] pub force: bool, } @@ -75,6 +85,7 @@ pub struct UpdateFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, pub status_filter: Option, + pub validation_filter: Option, #[serde(default)] #[validate(nested)] pub video_updates: Vec, diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index a1ffa1d..687832b 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -65,6 +65,9 @@ pub async fn get_videos( if let Some(status_filter) = params.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = params.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let total_count = query.clone().count(&db).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { (page, page_size) @@ -226,6 +229,9 @@ pub async fn reset_filtered_video_status( if let Some(status_filter) = request.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = request.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let all_videos = query.into_partial_model::().all(&db).await?; let all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) @@ -362,6 +368,9 @@ pub async fn update_filtered_video_status( if let Some(status_filter) = request.status_filter { query = query.filter(status_filter.to_video_query()); } + if let Some(validation_filter) = request.validation_filter { + query = query.filter(validation_filter.to_video_query()); + } let mut all_videos = query.into_partial_model::().all(&db).await?; let mut all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) diff --git a/web/src/lib/components/validation-filter.svelte b/web/src/lib/components/validation-filter.svelte new file mode 100644 index 0000000..4747b42 --- /dev/null +++ b/web/src/lib/components/validation-filter.svelte @@ -0,0 +1,95 @@ + + +
+ + {currentOption ? currentOption.label : '未应用'} + + + + + {#snippet child({ props })} + + {/snippet} + + + + 有效性 + {#each validationOptions as option (option.value)} + handleSelect(option.value)}> + + + {option.label} + + {#if value === option.value} + + {/if} + + {/each} + + { + closeAndFocusTrigger(); + onRemove?.(); + }} + > + + 移除筛选 + + + + +
diff --git a/web/src/lib/components/video-card.svelte b/web/src/lib/components/video-card.svelte index 0379545..b679383 100644 --- a/web/src/lib/components/video-card.svelte +++ b/web/src/lib/components/video-card.svelte @@ -65,7 +65,7 @@ } { if (!valid) { // 视频属性表明已失效,或由于各种条件判断(充电视频等)判定为无效的情况 - return { text: '无效', style: 'bg-gray-100 text-gray-700' }; + return { text: '失效', style: 'bg-gray-100 text-gray-700' }; } if (!shouldDownload) { // 被过滤规则排除,显示为“跳过” diff --git a/web/src/lib/stores/filter.ts b/web/src/lib/stores/filter.ts index ff5eaa4..7033123 100644 --- a/web/src/lib/stores/filter.ts +++ b/web/src/lib/stores/filter.ts @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null; +export type ValidationFilterValue = 'skipped' | 'invalid' | 'normal' | null; export interface AppState { query: string; @@ -10,17 +11,19 @@ export interface AppState { id: string; } | null; statusFilter: StatusFilterValue | null; + validationFilter: ValidationFilterValue | null; } export const appStateStore = writable({ query: '', currentPage: 0, videoSource: null, - statusFilter: null + statusFilter: null, + validationFilter: 'normal' }); export const ToQuery = (state: AppState): string => { - const { query, videoSource, currentPage, statusFilter } = state; + const { query, videoSource, currentPage, statusFilter, validationFilter } = state; const params = new URLSearchParams(); if (currentPage > 0) { params.set('page', String(currentPage)); @@ -34,6 +37,9 @@ export const ToQuery = (state: AppState): string => { if (statusFilter) { params.set('status_filter', statusFilter); } + if (validationFilter) { + params.set('validation_filter', validationFilter); + } const queryString = params.toString(); return queryString ? `videos?${queryString}` : 'videos'; }; @@ -48,6 +54,7 @@ export const ToFilterParams = ( submission?: number; watch_later?: number; status_filter?: Exclude; + validation_filter?: Exclude; } => { const params: { query?: string; @@ -56,6 +63,7 @@ export const ToFilterParams = ( submission?: number; watch_later?: number; status_filter?: Exclude; + validation_filter?: Exclude; } = {}; if (state.query.trim()) { @@ -69,12 +77,20 @@ export const ToFilterParams = ( if (state.statusFilter) { params.status_filter = state.statusFilter; } + if (state.validationFilter) { + params.validation_filter = state.validationFilter; + } return params; }; // 检查是否有活动的筛选条件 export const hasActiveFilters = (state: AppState): boolean => { - return !!(state.query.trim() || state.videoSource || state.statusFilter); + return !!( + state.query.trim() || + state.videoSource || + state.statusFilter || + state.validationFilter + ); }; export const setQuery = (query: string) => { @@ -98,6 +114,13 @@ export const setStatusFilter = (statusFilter: StatusFilterValue | null) => { })); }; +export const setValidationFilter = (validationFilter: ValidationFilterValue | null) => { + appStateStore.update((state) => ({ + ...state, + validationFilter + })); +}; + export const resetCurrentPage = () => { appStateStore.update((state) => ({ ...state, @@ -109,12 +132,14 @@ export const setAll = ( query: string, currentPage: number, videoSource: { type: string; id: string } | null, - statusFilter: StatusFilterValue | null + statusFilter: StatusFilterValue | null, + validationFilter: ValidationFilterValue | null = 'normal' ) => { appStateStore.set({ query, currentPage, videoSource, - statusFilter + statusFilter, + validationFilter }); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c30aaad..bc30453 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,7 +9,8 @@ export interface VideosRequest { submission?: number; watch_later?: number; query?: string; - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; page?: number; page_size?: number; } @@ -108,8 +109,8 @@ export interface UpdateFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; - // 仅更新下载失败 - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; video_updates?: StatusUpdate[]; page_updates?: StatusUpdate[]; } @@ -124,8 +125,8 @@ export interface ResetFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; - // 仅重置下载失败 - failed_only?: boolean; + status_filter?: 'failed' | 'succeeded' | 'waiting'; + validation_filter?: 'skipped' | 'invalid' | 'normal'; force: boolean; } diff --git a/web/src/routes/videos/+page.svelte b/web/src/routes/videos/+page.svelte index be4e238..7202aca 100644 --- a/web/src/routes/videos/+page.svelte +++ b/web/src/routes/videos/+page.svelte @@ -26,16 +26,19 @@ setCurrentPage, setQuery, setStatusFilter, + setValidationFilter, ToQuery, ToFilterParams, hasActiveFilters, - type StatusFilterValue + type StatusFilterValue, + type ValidationFilterValue } from '$lib/stores/filter'; import { toast } from 'svelte-sonner'; import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte'; import SearchBar from '$lib/components/search-bar.svelte'; import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte'; import StatusFilter from '$lib/components/status-filter.svelte'; + import ValidationFilter from '$lib/components/validation-filter.svelte'; const pageSize = 20; @@ -71,10 +74,18 @@ statusFilterParam === 'waiting' ? statusFilterParam : null; + const validationFilterParam = searchParams.get('validation_filter'); + const validationFilter: ValidationFilterValue = + validationFilterParam === 'skipped' || + validationFilterParam === 'invalid' || + validationFilterParam === 'normal' + ? validationFilterParam + : null; return { query: searchParams.get('query') || '', videoSource, statusFilter, + validationFilter, pageNum: parseInt(searchParams.get('page') || '0') }; } @@ -83,7 +94,8 @@ query: string, pageNum: number = 0, filter?: { type: string; id: string } | null, - statusFilter: StatusFilterValue | null = null + statusFilter: StatusFilterValue | null = null, + validationFilter: ValidationFilterValue | null = null ) { loading = true; try { @@ -100,6 +112,9 @@ if (statusFilter) { params.status_filter = statusFilter; } + if (validationFilter) { + params.validation_filter = validationFilter; + } const result = await api.getVideos(params); videosData = result.data; } catch (error) { @@ -118,9 +133,10 @@ } async function handleSearchParamsChange(searchParams: URLSearchParams) { - const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams); - setAll(query, pageNum, videoSource, statusFilter); - loadVideos(query, pageNum, videoSource, statusFilter); + const { query, videoSource, pageNum, statusFilter, validationFilter } = + getApiParams(searchParams); + setAll(query, pageNum, videoSource, statusFilter, validationFilter); + loadVideos(query, pageNum, videoSource, statusFilter, validationFilter); } async function handleResetVideo(id: number, forceReset: boolean) { @@ -131,8 +147,8 @@ toast.success('重置成功', { description: `视频「${data.video.name}」已重置` }); - const { query, currentPage, videoSource, statusFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter); + const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); } else { toast.info('重置无效', { description: `视频「${data.video.name}」没有失败的状态,无需重置` @@ -159,8 +175,8 @@ description: `视频「${data.video.name}」已清空重置` }); } - const { query, currentPage, videoSource, statusFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter); + const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); } catch (error) { console.error('清空重置失败:', error); toast.error('清空重置失败', { @@ -183,8 +199,8 @@ toast.success('重置成功', { description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页` }); - const { query, currentPage, videoSource, statusFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter); + const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); } else { toast.info('没有需要重置的视频'); } @@ -214,8 +230,8 @@ toast.success('更新成功', { description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` }); - const { query, currentPage, videoSource, statusFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter); + const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); } else { toast.info('没有视频被更新'); } @@ -257,6 +273,14 @@ }; parts.push(`状态:${statusLabels[state.statusFilter]}`); } + if (state.validationFilter && state.validationFilter !== 'normal') { + const validationLabels = { + skipped: '跳过', + invalid: '失效', + normal: '有效' + }; + parts.push(`有效性:${validationLabels[state.validationFilter]}`); + } return parts; } @@ -330,6 +354,22 @@ }} /> +
+ 有效性: + { + setValidationFilter(value); + resetCurrentPage(); + goto(`/${ToQuery($appStateStore)}`); + }} + onRemove={() => { + setValidationFilter(null); + resetCurrentPage(); + goto(`/${ToQuery($appStateStore)}`); + }} + /> +
来源: @@ -337,11 +377,11 @@ {filters} selectedLabel={$appStateStore.videoSource} onSelect={(type, id) => { - setAll('', 0, { type, id }, $appStateStore.statusFilter); + setAll('', 0, { type, id }, $appStateStore.statusFilter, $appStateStore.validationFilter); goto(`/${ToQuery($appStateStore)}`); }} onRemove={() => { - setAll('', 0, null, $appStateStore.statusFilter); + setAll('', 0, null, $appStateStore.statusFilter, $appStateStore.validationFilter); goto(`/${ToQuery($appStateStore)}`); }} />