feat: 支持筛选视频的有效性 (#673)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-03-15 16:44:48 +08:00
committed by GitHub
parent e97fa73542
commit d39cce043c
8 changed files with 225 additions and 28 deletions

View File

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

View File

@@ -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<i32>,
@@ -20,6 +28,7 @@ pub struct VideosRequest {
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
@@ -38,6 +47,7 @@ pub struct ResetFilteredVideoStatusRequest {
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
#[serde(default)]
pub force: bool,
}
@@ -75,6 +85,7 @@ pub struct UpdateFilteredVideoStatusRequest {
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
#[serde(default)]
#[validate(nested)]
pub video_updates: Vec<StatusUpdate>,

View File

@@ -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::<SimpleVideoInfo>().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::<SimpleVideoInfo>().all(&db).await?;
let mut all_pages = page::Entity::find()
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import {
CircleCheckBigIcon,
TriangleAlertIcon,
SkipForwardIcon,
ChevronDownIcon,
TrashIcon
} from '@lucide/svelte/icons';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { type ValidationFilterValue } from '$lib/stores/filter';
interface Props {
value: ValidationFilterValue;
onSelect?: (value: ValidationFilterValue) => void;
onRemove?: () => void;
}
let { value = $bindable('normal'), onSelect, onRemove }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusTrigger() {
open = false;
}
const validationOptions = [
{
value: 'normal' as const,
label: '有效',
icon: CircleCheckBigIcon
},
{
value: 'skipped' as const,
label: '跳过',
icon: SkipForwardIcon
},
{
value: 'invalid' as const,
label: '失效',
icon: TriangleAlertIcon
}
];
function handleSelect(selectedValue: ValidationFilterValue) {
value = selectedValue;
onSelect?.(selectedValue);
closeAndFocusTrigger();
}
const currentOption = $derived(validationOptions.find((opt) => opt.value === value));
</script>
<div class="inline-flex items-center gap-1">
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
{currentOption ? currentOption.label : '未应用'}
</span>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
<ChevronDownIcon class="h-3 w-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-50" align="end">
<DropdownMenu.Group>
<DropdownMenu.Label class="text-xs">有效性</DropdownMenu.Label>
{#each validationOptions as option (option.value)}
<DropdownMenu.Item class="text-xs" onclick={() => handleSelect(option.value)}>
<option.icon class="mr-2 size-3" />
<span class:font-semibold={value === option.value}>
{option.label}
</span>
{#if value === option.value}
<CircleCheckBigIcon class="ml-auto size-3" />
{/if}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {
closeAndFocusTrigger();
onRemove?.();
}}
>
<TrashIcon class="mr-2 size-3" />
<span class="text-xs font-medium">移除筛选</span>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View File

@@ -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) {
// 被过滤规则排除,显示为“跳过”

View File

@@ -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<AppState>({
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<StatusFilterValue, null>;
validation_filter?: Exclude<ValidationFilterValue, null>;
} => {
const params: {
query?: string;
@@ -56,6 +63,7 @@ export const ToFilterParams = (
submission?: number;
watch_later?: number;
status_filter?: Exclude<StatusFilterValue, null>;
validation_filter?: Exclude<ValidationFilterValue, null>;
} = {};
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
});
};

View File

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

View File

@@ -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 @@
}}
/>
</div>
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">有效性:</span>
<ValidationFilter
value={$appStateStore.validationFilter}
onSelect={(value) => {
setValidationFilter(value);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setValidationFilter(null);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
<!-- 视频源筛选 -->
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">来源:</span>
@@ -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)}`);
}}
/>