Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -6,6 +6,8 @@ import type {
|
||||
Config,
|
||||
DashBoardResponse,
|
||||
FavoritesResponse,
|
||||
FullSyncVideoSourceRequest,
|
||||
FullSyncVideoSourceResponse,
|
||||
QrcodeGenerateResponse as GenerateQrcodeResponse,
|
||||
InsertCollectionRequest,
|
||||
InsertFavoriteRequest,
|
||||
@@ -253,6 +255,14 @@ class ApiClient {
|
||||
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
|
||||
}
|
||||
|
||||
async fullSyncVideoSource(
|
||||
type: string,
|
||||
id: number,
|
||||
data: FullSyncVideoSourceRequest
|
||||
): Promise<ApiResponse<FullSyncVideoSourceResponse>> {
|
||||
return this.post<FullSyncVideoSourceResponse>(`/video-sources/${type}/${id}/full-sync`, data);
|
||||
}
|
||||
|
||||
async getDefaultPath(type: string, name: string): Promise<ApiResponse<string>> {
|
||||
return this.get<string>(`/video-sources/${type}/default-path`, { name });
|
||||
}
|
||||
@@ -327,6 +337,8 @@ const api = {
|
||||
removeVideoSource: (type: string, id: number) => apiClient.removeVideoSource(type, id),
|
||||
evaluateVideoSourceRules: (type: string, id: number) =>
|
||||
apiClient.evaluateVideoSourceRules(type, id),
|
||||
fullSyncVideoSource: (type: string, id: number, data: { delete_local: boolean }) =>
|
||||
apiClient.fullSyncVideoSource(type, id, data),
|
||||
getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name),
|
||||
testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader class="flex-shrink-0">
|
||||
<CardHeader class="shrink-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 头像或图标 - 简化设计 -->
|
||||
<div
|
||||
|
||||
95
web/src/lib/components/validation-filter.svelte
Normal file
95
web/src/lib/components/validation-filter.svelte
Normal 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>
|
||||
@@ -13,13 +13,17 @@
|
||||
BrushCleaningIcon,
|
||||
UserIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
EllipsisIcon
|
||||
EllipsisIcon,
|
||||
HeartIcon,
|
||||
FolderIcon,
|
||||
ClockIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
// 将 bvid 设置为可选属性,但保留 VideoInfo 的其它所有属性
|
||||
export let video: Omit<VideoInfo, 'bvid'> & { bvid?: string };
|
||||
export let source: { type: string; name: string } | null = null; // 视频源信息
|
||||
export let showActions: boolean = true; // 控制是否显示操作按钮
|
||||
export let mode: 'default' | 'detail' | 'page' = 'default'; // 卡片模式
|
||||
export let customTitle: string = ''; // 自定义标题
|
||||
@@ -57,11 +61,16 @@
|
||||
|
||||
function getOverallStatus(
|
||||
downloadStatus: number[],
|
||||
shouldDownload: boolean
|
||||
shouldDownload: boolean,
|
||||
valid: boolean
|
||||
): {
|
||||
text: string;
|
||||
style: string;
|
||||
} {
|
||||
if (!valid) {
|
||||
// 视频属性表明已失效,或由于各种条件判断(充电视频等)判定为无效的情况
|
||||
return { text: '失效', style: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
if (!shouldDownload) {
|
||||
// 被过滤规则排除,显示为“跳过”
|
||||
return { text: '跳过', style: 'bg-gray-100 text-gray-700' };
|
||||
@@ -90,7 +99,7 @@
|
||||
return defaultTaskNames[index] || `任务${index + 1}`;
|
||||
}
|
||||
|
||||
$: overallStatus = getOverallStatus(video.download_status, video.should_download);
|
||||
$: overallStatus = getOverallStatus(video.download_status, video.should_download, video.valid);
|
||||
$: completed = video.download_status.filter((status) => status === 7).length;
|
||||
$: total = video.download_status.length;
|
||||
|
||||
@@ -127,7 +136,7 @@
|
||||
</script>
|
||||
|
||||
<Card class={cardClasses}>
|
||||
<CardHeader class="shrink-0 pb-3">
|
||||
<CardHeader class="shrink-0 pb-1">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<CardTitle
|
||||
class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default'
|
||||
@@ -152,6 +161,24 @@
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if source}
|
||||
<div class="text-muted-foreground mt-2 flex min-w-0 items-center justify-end gap-1 text-sm">
|
||||
<Badge variant="outline" class="max-w-full shrink px-1.5 py-0.5">
|
||||
{#if source.type === 'favorite'}
|
||||
<HeartIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'collection'}
|
||||
<FolderIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'submission'}
|
||||
<UserIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'watch_later'}
|
||||
<ClockIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate" title={source.name}>
|
||||
{source.name}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0 pb-3' : 'pt-0 pb-4'}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -31,8 +32,13 @@ export interface VideoInfo {
|
||||
bvid: string;
|
||||
name: string;
|
||||
upper_name: string;
|
||||
valid: boolean;
|
||||
should_download: boolean;
|
||||
download_status: [number, number, number, number, number];
|
||||
collection_id?: number;
|
||||
favorite_id?: number;
|
||||
submission_id?: number;
|
||||
watch_later_id?: number;
|
||||
}
|
||||
|
||||
export interface VideosResponse {
|
||||
@@ -83,7 +89,16 @@ export interface UpdateFilteredVideoStatusResponse {
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status?: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface FullSyncVideoSourceRequest {
|
||||
delete_local: boolean;
|
||||
}
|
||||
|
||||
export interface FullSyncVideoSourceResponse {
|
||||
removed_count: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface StatusUpdate {
|
||||
@@ -107,8 +122,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[];
|
||||
}
|
||||
@@ -123,8 +138,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;
|
||||
}
|
||||
|
||||
@@ -319,6 +334,7 @@ export interface Config {
|
||||
concurrent_limit: ConcurrentLimit;
|
||||
time_format: string;
|
||||
cdn_sorting: boolean;
|
||||
try_upower_anyway: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,11 @@ interface ClientEvent {
|
||||
type LogsCallback = (data: string) => void;
|
||||
type TasksCallback = (data: TaskStatus) => void;
|
||||
type SysInfoCallback = (data: SysInfo) => void;
|
||||
type ErrorCallback = (error: Event) => void;
|
||||
|
||||
export class WebSocketManager {
|
||||
private static instance: WebSocketManager;
|
||||
private socket: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
@@ -41,7 +39,6 @@ export class WebSocketManager {
|
||||
private logsSubscribers: Set<LogsCallback> = new Set();
|
||||
private tasksSubscribers: Set<TasksCallback> = new Set();
|
||||
private sysInfoSubscribers: Set<SysInfoCallback> = new Set();
|
||||
private errorSubscribers: Set<ErrorCallback> = new Set();
|
||||
|
||||
private subscribedEvents: Set<EventType> = new Set();
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
@@ -61,7 +58,6 @@ export class WebSocketManager {
|
||||
if (this.connectionPromise) return this.connectionPromise;
|
||||
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connecting = true;
|
||||
const token = api.getAuthToken() || '';
|
||||
|
||||
try {
|
||||
@@ -73,7 +69,6 @@ export class WebSocketManager {
|
||||
);
|
||||
this.socket.onopen = () => {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connectionPromise = null;
|
||||
this.resubscribeEvents();
|
||||
@@ -84,20 +79,17 @@ export class WebSocketManager {
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
toast.error('WebSocket 连接发生错误,请检查网络或稍后重试');
|
||||
};
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
@@ -273,7 +265,6 @@ export class WebSocketManager {
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
this.subscribedEvents.clear();
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
DownloadIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
|
||||
let dashboardData: DashBoardResponse | null = null;
|
||||
let sysInfo: SysInfo | null = null;
|
||||
let taskStatus: TaskStatus | null = null;
|
||||
let loading = false;
|
||||
let triggering = false;
|
||||
let dashboardData = $state<DashBoardResponse | null>(null);
|
||||
let sysInfo = $state<SysInfo | null>(null);
|
||||
let taskStatus = $state<TaskStatus | null>(null);
|
||||
let loading = $state(false);
|
||||
let triggering = $state(false);
|
||||
let memoryHistory = $state<Array<{ time: number; used: number; process: number }>>([]);
|
||||
let cpuHistory = $state<Array<{ time: number; used: number; process: number }>>([]);
|
||||
let unsubscribeSysInfo: (() => void) | null = null;
|
||||
let unsubscribeTasks: (() => void) | null = null;
|
||||
|
||||
@@ -90,29 +92,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '仪表盘' }]);
|
||||
|
||||
unsubscribeSysInfo = api.subscribeToSysInfo((data) => {
|
||||
sysInfo = data;
|
||||
});
|
||||
unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => {
|
||||
taskStatus = data;
|
||||
});
|
||||
loadDashboard();
|
||||
return () => {
|
||||
if (unsubscribeSysInfo) {
|
||||
unsubscribeSysInfo();
|
||||
unsubscribeSysInfo = null;
|
||||
}
|
||||
if (unsubscribeTasks) {
|
||||
unsubscribeTasks();
|
||||
unsubscribeTasks = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 图表配置
|
||||
const videoChartConfig = {
|
||||
videos: {
|
||||
label: '视频数量',
|
||||
@@ -142,32 +121,51 @@
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
let memoryHistory: Array<{ time: number; used: number; process: number }> = [];
|
||||
let cpuHistory: Array<{ time: number; used: number; process: number }> = [];
|
||||
|
||||
$: if (sysInfo) {
|
||||
function pushSysInfo(data: SysInfo) {
|
||||
memoryHistory = [
|
||||
...memoryHistory.slice(-14),
|
||||
{
|
||||
time: sysInfo.timestamp,
|
||||
used: sysInfo.used_memory,
|
||||
process: sysInfo.process_memory
|
||||
time: data.timestamp,
|
||||
used: data.used_memory,
|
||||
process: data.process_memory
|
||||
}
|
||||
];
|
||||
cpuHistory = [
|
||||
...cpuHistory.slice(-14),
|
||||
{
|
||||
time: sysInfo.timestamp,
|
||||
used: sysInfo.used_cpu,
|
||||
process: sysInfo.process_cpu
|
||||
time: data.timestamp,
|
||||
used: data.used_cpu,
|
||||
process: data.process_cpu
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 计算磁盘使用率
|
||||
$: diskUsagePercent = sysInfo
|
||||
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
|
||||
: 0;
|
||||
const diskUsagePercent = $derived(
|
||||
sysInfo ? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100 : 0
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '仪表盘' }]);
|
||||
|
||||
unsubscribeSysInfo = api.subscribeToSysInfo((data) => {
|
||||
sysInfo = data;
|
||||
pushSysInfo(data);
|
||||
});
|
||||
unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => {
|
||||
taskStatus = data;
|
||||
});
|
||||
loadDashboard();
|
||||
return () => {
|
||||
if (unsubscribeSysInfo) {
|
||||
unsubscribeSysInfo();
|
||||
unsubscribeSysInfo = null;
|
||||
}
|
||||
if (unsubscribeTasks) {
|
||||
unsubscribeTasks();
|
||||
unsubscribeTasks = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script lang="ts">
|
||||
import api from '$lib/api';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
let unsubscribeLog: (() => void) | null = null;
|
||||
let logs: Array<{ timestamp: string; level: string; message: string }> = [];
|
||||
let shouldAutoScroll = true;
|
||||
let main: HTMLElement | null = null;
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function checkScrollPosition() {
|
||||
if (main) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = main;
|
||||
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
if (shouldAutoScroll && main) {
|
||||
main.scrollTop = main.scrollHeight;
|
||||
}
|
||||
@@ -28,9 +30,11 @@
|
||||
main?.addEventListener('scroll', checkScrollPosition);
|
||||
unsubscribeLog = api.subscribeToLogs((data: string) => {
|
||||
logs = [...logs.slice(-499), JSON.parse(data)];
|
||||
setTimeout(scrollToBottom, 0);
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(scrollToBottom, 20);
|
||||
});
|
||||
return () => {
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
main?.removeEventListener('scroll', checkScrollPosition);
|
||||
if (unsubscribeLog) {
|
||||
unsubscribeLog();
|
||||
|
||||
@@ -366,6 +366,24 @@
|
||||
<Switch id="cdn-sorting" bind:checked={formData.cdn_sorting} />
|
||||
<Label for="cdn-sorting">启用CDN排序</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="try-upower-anyway" bind:checked={formData.try_upower_anyway} />
|
||||
<div class="flex items-center gap-1">
|
||||
<Label for="try-upower-anyway">尝试下载未充电视频</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<InfoIcon class="text-muted-foreground h-3.5 w-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">
|
||||
当关闭该开关时,程序仅会下载已充电的视频,未充电的视频直接跳过;开启后不再检查充电状态,一律尝试下载。<br
|
||||
/>
|
||||
这可以帮助下载未充电视频的封面等元数据,也应该可以下载未充电视频的试看部分(如果存在的话)。
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Switch } from '$lib/components/ui/switch/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 { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
@@ -18,7 +19,8 @@
|
||||
InfoIcon,
|
||||
Trash2Icon,
|
||||
CircleCheckBigIcon,
|
||||
CircleXIcon
|
||||
CircleXIcon,
|
||||
RefreshCwIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -59,6 +61,13 @@
|
||||
let removeIdx: number = 0;
|
||||
let removing = false;
|
||||
|
||||
// 全量更新对话框状态
|
||||
let showFullSyncDialog = false;
|
||||
let fullSyncSource: VideoSourceDetail | null = null;
|
||||
let fullSyncType = '';
|
||||
let fullSyncDeleteLocal = false;
|
||||
let fullSyncing = false;
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = {
|
||||
path: '',
|
||||
@@ -130,6 +139,44 @@
|
||||
showRemoveDialog = true;
|
||||
}
|
||||
|
||||
function openFullSyncDialog(type: string, source: VideoSourceDetail) {
|
||||
fullSyncSource = source;
|
||||
fullSyncType = type;
|
||||
fullSyncDeleteLocal = false;
|
||||
showFullSyncDialog = true;
|
||||
}
|
||||
|
||||
async function fullSyncVideoSource() {
|
||||
if (!fullSyncSource) return;
|
||||
fullSyncing = true;
|
||||
try {
|
||||
let response = await api.fullSyncVideoSource(fullSyncType, fullSyncSource.id, {
|
||||
delete_local: fullSyncDeleteLocal
|
||||
});
|
||||
if (response && response.data) {
|
||||
showFullSyncDialog = false;
|
||||
toast.success('全量更新成功', {
|
||||
description: `已移除 ${response.data.removed_count} 个不存在的视频`
|
||||
});
|
||||
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||
toast.warning('部分本地文件夹删除失败', {
|
||||
description: response.data.warnings.join('\n'),
|
||||
duration: 10000,
|
||||
descriptionClass: 'whitespace-pre-line'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error('全量更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('全量更新失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
fullSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!editingSource) return;
|
||||
@@ -654,6 +701,21 @@
|
||||
<p class="text-xs">重新评估规则</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root disableHoverableContent={true}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openFullSyncDialog(key, source)}
|
||||
class="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCwIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">全量更新视频</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{#if activeTab !== 'watch_later'}
|
||||
<Tooltip.Root disableHoverableContent={true}>
|
||||
<Tooltip.Trigger>
|
||||
@@ -842,6 +904,48 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showFullSyncDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>全量更新视频</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
确定要全量更新视频源 <strong>"{fullSyncSource?.name}"</strong> 吗?<br />
|
||||
该操作会获取该视频源下所有当前存在的视频,移除数据库中已不存在于该源的视频及其分页数据,<span
|
||||
class="text-destructive font-medium">无法撤销</span
|
||||
>。<br /><br />
|
||||
请谨慎对“稍后再看”执行全量更新操作,因为其视频源本身就具有较强的时效性,执行全量更新可能导致大量视频被移除。<br
|
||||
/>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<div class="mb-2 flex items-center space-x-2">
|
||||
<Checkbox id="delete-local" bind:checked={fullSyncDeleteLocal} />
|
||||
<Label for="delete-local" class="text-sm font-medium text-orange-700">
|
||||
⚠️ 同时删除本地视频文件夹
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-orange-700">
|
||||
删除多余视频时同时删除视频对应的本地文件夹,请谨慎勾选
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
disabled={fullSyncing}
|
||||
onclick={() => {
|
||||
showFullSyncDialog = false;
|
||||
}}>取消</AlertDialog.Cancel
|
||||
>
|
||||
<AlertDialog.Action
|
||||
onclick={fullSyncVideoSource}
|
||||
disabled={fullSyncing}
|
||||
class="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
{fullSyncing ? '全量更新中' : '确认全量更新'}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<Dialog.Root bind:open={showAddDialog}>
|
||||
<Dialog.Content>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let statusEditorLoading = false;
|
||||
|
||||
async function loadVideoDetail() {
|
||||
const videoId = parseInt($page.params.id);
|
||||
const videoId = parseInt($page.params.id!);
|
||||
if (isNaN(videoId)) {
|
||||
error = '无效的视频 ID';
|
||||
toast.error('无效的视频 ID');
|
||||
@@ -212,14 +212,7 @@
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<VideoCard
|
||||
video={{
|
||||
id: videoData.video.id,
|
||||
bvid: videoData.video.bvid,
|
||||
name: videoData.video.name,
|
||||
upper_name: videoData.video.upper_name,
|
||||
download_status: videoData.video.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
}}
|
||||
video={videoData.video}
|
||||
mode="detail"
|
||||
showActions={false}
|
||||
taskNames={['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载']}
|
||||
@@ -254,7 +247,8 @@
|
||||
name: `P${pageInfo.pid}: ${pageInfo.name}`,
|
||||
upper_name: '',
|
||||
download_status: pageInfo.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
should_download: videoData.video.should_download,
|
||||
valid: videoData.video.valid
|
||||
}}
|
||||
mode="page"
|
||||
showActions={false}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
VideoSourcesResponse,
|
||||
ApiError,
|
||||
VideoSource,
|
||||
UpdateFilteredVideoStatusRequest
|
||||
UpdateFilteredVideoStatusRequest,
|
||||
VideoInfo
|
||||
} from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -26,16 +27,20 @@
|
||||
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';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -53,7 +58,9 @@
|
||||
let updatingAll = false;
|
||||
|
||||
let videoSources: VideoSourcesResponse | null = null;
|
||||
let videoSourcesLoaded = false;
|
||||
let filters: Record<string, Filter> | null = null;
|
||||
let sourceMap: SvelteMap<string, { type: string; name: string }> = new SvelteMap();
|
||||
|
||||
function getApiParams(searchParams: URLSearchParams) {
|
||||
let videoSource = null;
|
||||
@@ -71,10 +78,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 +98,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 +116,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 +137,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 +151,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 +179,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 +203,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 +234,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('没有视频被更新');
|
||||
}
|
||||
@@ -230,6 +250,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoSource(video: VideoInfo): { type: string; name: string } | null {
|
||||
if (video.collection_id != null) {
|
||||
return sourceMap.get(`collection:${video.collection_id}`) || null;
|
||||
}
|
||||
if (video.favorite_id != null) {
|
||||
return sourceMap.get(`favorite:${video.favorite_id}`) || null;
|
||||
}
|
||||
if (video.submission_id != null) {
|
||||
return sourceMap.get(`submission:${video.submission_id}`) || null;
|
||||
}
|
||||
if (video.watch_later_id != null) {
|
||||
return sourceMap.get(`watch_later:${video.watch_later_id}`) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取筛选条件的显示数组
|
||||
function getFilterDescriptionParts(): string[] {
|
||||
const state = $appStateStore;
|
||||
@@ -257,10 +293,18 @@
|
||||
};
|
||||
parts.push(`状态:${statusLabels[state.statusFilter]}`);
|
||||
}
|
||||
if (state.validationFilter) {
|
||||
const validationLabels = {
|
||||
skipped: '跳过',
|
||||
invalid: '失效',
|
||||
normal: '有效'
|
||||
};
|
||||
parts.push(`有效性:${validationLabels[state.validationFilter]}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
$: if ($page.url.search !== lastSearch) {
|
||||
$: if (videoSourcesLoaded && $page.url.search !== lastSearch) {
|
||||
lastSearch = $page.url.search;
|
||||
handleSearchParamsChange($page.url.searchParams);
|
||||
}
|
||||
@@ -280,8 +324,19 @@
|
||||
}
|
||||
])
|
||||
);
|
||||
sourceMap.clear();
|
||||
for (const source of Object.values(VIDEO_SOURCES)) {
|
||||
const sourceList = videoSources[source.type as keyof VideoSourcesResponse] as VideoSource[];
|
||||
for (const item of sourceList) {
|
||||
sourceMap.set(`${source.type}:${item.id}`, {
|
||||
type: source.type,
|
||||
name: item.name
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filters = null;
|
||||
sourceMap.clear();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -291,6 +346,7 @@
|
||||
}
|
||||
]);
|
||||
videoSources = (await api.getVideoSources()).data;
|
||||
videoSourcesLoaded = true;
|
||||
});
|
||||
|
||||
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
|
||||
@@ -313,6 +369,22 @@
|
||||
}}
|
||||
></SearchBar>
|
||||
<div class="flex items-center gap-3">
|
||||
<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 +409,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)}`);
|
||||
}}
|
||||
/>
|
||||
@@ -395,6 +467,7 @@
|
||||
{#each videosData.videos as video (video.id)}
|
||||
<VideoCard
|
||||
{video}
|
||||
source={getVideoSource(video)}
|
||||
onReset={async (forceReset: boolean) => {
|
||||
await handleResetVideo(video.id, forceReset);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user