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:
jlightner 2026-04-04 06:39:17 +00:00
parent a11c4c56c5
commit e6711e91a5
5 changed files with 185 additions and 6 deletions

View file

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

View file

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

View file

@ -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)' },

View file

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

View file

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