feat: Added missing status badge, Library filter/re-download button, an…
- "src/frontend/src/components/StatusBadge.tsx" - "src/frontend/src/pages/Library.tsx" - "src/frontend/src/pages/System.tsx" - "src/frontend/src/api/hooks/useLibrary.ts" - "src/frontend/src/api/hooks/useSystem.ts" GSD-Task: S06/T03
This commit is contained in:
parent
a11c4c56c5
commit
e6711e91a5
5 changed files with 185 additions and 6 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../client';
|
||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||
import type { PaginatedResponse } from '@shared/types/api';
|
||||
|
|
@ -44,3 +44,17 @@ export function useLibraryContent(filters: LibraryFilters = {}) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ──
|
||||
|
||||
/** Re-download a missing content item. Invalidates library queries on success. */
|
||||
export function useRequeueContent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (contentId: number) =>
|
||||
apiClient.post(`/api/v1/content/${contentId}/requeue`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const systemKeys = {
|
|||
apiKey: ['system', 'apikey'] as const,
|
||||
appSettings: ['system', 'appSettings'] as const,
|
||||
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
||||
missingScanStatus: ['system', 'missingScanStatus'] as const,
|
||||
};
|
||||
|
||||
// ── Queries ──
|
||||
|
|
@ -91,3 +92,44 @@ export function useUpdateYtDlp() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Missing File Scan ──
|
||||
|
||||
interface ScanResult {
|
||||
checked: number;
|
||||
missing: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface MissingScanStatusResponse {
|
||||
lastRun: string;
|
||||
result: ScanResult;
|
||||
}
|
||||
|
||||
interface MissingScanTriggerResponse {
|
||||
success: boolean;
|
||||
data: ScanResult;
|
||||
}
|
||||
|
||||
/** Fetch last missing file scan status. Does not auto-refresh. */
|
||||
export function useMissingScanStatus() {
|
||||
return useQuery({
|
||||
queryKey: systemKeys.missingScanStatus,
|
||||
queryFn: () =>
|
||||
apiClient.get<{ success: boolean; data: MissingScanStatusResponse | null }>(
|
||||
'/api/v1/system/missing-scan/status',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger an on-demand missing file scan. Invalidates scan status on success. */
|
||||
export function useTriggerMissingScan() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post<MissingScanTriggerResponse>('/api/v1/system/missing-scan'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: systemKeys.missingScanStatus });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const STATUS_STYLES: Record<string, BadgeStyle> = {
|
|||
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
||||
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
|
||||
queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||
missing: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||
ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||
// Queue statuses
|
||||
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react';
|
||||
import { Library as LibraryIcon, RefreshCw, Film, Music, RotateCcw } from 'lucide-react';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { QualityLabel } from '../components/QualityLabel';
|
||||
|
|
@ -9,7 +9,7 @@ import { Pagination } from '../components/Pagination';
|
|||
import { SearchBar } from '../components/SearchBar';
|
||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||
import { SkeletonLibrary } from '../components/Skeleton';
|
||||
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||
import { useLibraryContent, useRequeueContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||
import { useChannels } from '../api/hooks/useChannels';
|
||||
import { formatDuration, formatFileSize } from '../utils/format';
|
||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||
|
|
@ -44,6 +44,7 @@ export function Library() {
|
|||
// Queries
|
||||
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
||||
const { data: channels } = useChannels();
|
||||
const requeue = useRequeueContent();
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
|
|
@ -80,6 +81,7 @@ export function Library() {
|
|||
{ value: 'downloading', label: 'Downloading' },
|
||||
{ value: 'downloaded', label: 'Downloaded' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'missing', label: 'Missing' },
|
||||
{ value: 'ignored', label: 'Ignored' },
|
||||
],
|
||||
},
|
||||
|
|
@ -260,8 +262,30 @@ export function Library() {
|
|||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '110px',
|
||||
render: (item) =>
|
||||
item.status === 'missing' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
requeue.mutate(item.id);
|
||||
}}
|
||||
disabled={requeue.isPending}
|
||||
className="btn btn-warning"
|
||||
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)', gap: 'var(--space-1)' }}
|
||||
title="Re-download this item"
|
||||
aria-label={`Re-download ${item.title}`}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Re-download
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[channels, navigate],
|
||||
[channels, navigate, requeue],
|
||||
);
|
||||
|
||||
// Extract pagination from response
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
|
||||
import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-react';
|
||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
|
||||
import { HealthStatus } from '../components/HealthStatus';
|
||||
import { SkeletonSystem } from '../components/Skeleton';
|
||||
import { formatBytes } from '../utils/format';
|
||||
|
|
@ -25,6 +25,8 @@ export function SystemPage() {
|
|||
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
||||
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
||||
const updateYtDlp = useUpdateYtDlp();
|
||||
const { data: missingScanData } = useMissingScanStatus();
|
||||
const triggerMissingScan = useTriggerMissingScan();
|
||||
|
||||
const isLoading = healthLoading || statusLoading;
|
||||
|
||||
|
|
@ -92,6 +94,102 @@ export function SystemPage() {
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Missing File Scan section ── */}
|
||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<Search size={18} style={{ color: 'var(--warning)' }} />
|
||||
Missing File Scan
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => triggerMissingScan.mutate()}
|
||||
disabled={triggerMissingScan.isPending}
|
||||
className="btn btn-warning"
|
||||
>
|
||||
{triggerMissingScan.isPending ? (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Scanning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} />
|
||||
Scan Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
{triggerMissingScan.data && (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
padding: 'var(--space-3)',
|
||||
marginBottom: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: triggerMissingScan.data.data.missing > 0 ? 'var(--warning-bg)' : 'var(--success-bg)',
|
||||
color: triggerMissingScan.data.data.missing > 0 ? 'var(--warning)' : 'var(--success)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Scan complete: checked {triggerMissingScan.data.data.checked} files, found {triggerMissingScan.data.data.missing} missing
|
||||
{' '}({(triggerMissingScan.data.data.duration / 1000).toFixed(1)}s)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{triggerMissingScan.error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: 'var(--space-3)',
|
||||
marginBottom: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger-bg)',
|
||||
color: 'var(--danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}
|
||||
>
|
||||
Scan failed: {triggerMissingScan.error instanceof Error ? triggerMissingScan.error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingScanData?.data ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<SystemInfoRow
|
||||
icon={<Activity size={14} style={{ color: 'var(--text-muted)' }} />}
|
||||
label="Last Scan"
|
||||
value={new Date(missingScanData.data.lastRun).toLocaleString()}
|
||||
/>
|
||||
<SystemInfoRow
|
||||
icon={<Search size={14} style={{ color: 'var(--info)' }} />}
|
||||
label="Files Checked"
|
||||
value={String(missingScanData.data.result.checked)}
|
||||
/>
|
||||
<SystemInfoRow
|
||||
icon={<HardDrive size={14} style={{ color: missingScanData.data.result.missing > 0 ? 'var(--warning)' : 'var(--success)' }} />}
|
||||
label="Missing Files"
|
||||
value={String(missingScanData.data.result.missing)}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', margin: 0 }}>
|
||||
No scan has been run yet. Click "Scan Now" to check for missing files.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── System Status section ── */}
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue