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 { apiClient } from '../client';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||||
import type { PaginatedResponse } from '@shared/types/api';
|
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,
|
apiKey: ['system', 'apikey'] as const,
|
||||||
appSettings: ['system', 'appSettings'] as const,
|
appSettings: ['system', 'appSettings'] as const,
|
||||||
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
||||||
|
missingScanStatus: ['system', 'missingScanStatus'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── 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)' },
|
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
||||||
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
|
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
|
||||||
queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-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)' },
|
ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||||
// Queue statuses
|
// Queue statuses
|
||||||
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
|
|
@ -9,7 +9,7 @@ import { Pagination } from '../components/Pagination';
|
||||||
import { SearchBar } from '../components/SearchBar';
|
import { SearchBar } from '../components/SearchBar';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonLibrary } from '../components/Skeleton';
|
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 { useChannels } from '../api/hooks/useChannels';
|
||||||
import { formatDuration, formatFileSize } from '../utils/format';
|
import { formatDuration, formatFileSize } from '../utils/format';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||||
|
|
@ -44,6 +44,7 @@ export function Library() {
|
||||||
// Queries
|
// Queries
|
||||||
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
|
const requeue = useRequeueContent();
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
|
@ -80,6 +81,7 @@ export function Library() {
|
||||||
{ value: 'downloading', label: 'Downloading' },
|
{ value: 'downloading', label: 'Downloading' },
|
||||||
{ value: 'downloaded', label: 'Downloaded' },
|
{ value: 'downloaded', label: 'Downloaded' },
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
{ value: 'missing', label: 'Missing' },
|
||||||
{ value: 'ignored', label: 'Ignored' },
|
{ value: 'ignored', label: 'Ignored' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -260,8 +262,30 @@ export function Library() {
|
||||||
</span>
|
</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
|
// Extract pagination from response
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-react';
|
||||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
|
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
|
||||||
import { HealthStatus } from '../components/HealthStatus';
|
import { HealthStatus } from '../components/HealthStatus';
|
||||||
import { SkeletonSystem } from '../components/Skeleton';
|
import { SkeletonSystem } from '../components/Skeleton';
|
||||||
import { formatBytes } from '../utils/format';
|
import { formatBytes } from '../utils/format';
|
||||||
|
|
@ -25,6 +25,8 @@ export function SystemPage() {
|
||||||
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
||||||
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
||||||
const updateYtDlp = useUpdateYtDlp();
|
const updateYtDlp = useUpdateYtDlp();
|
||||||
|
const { data: missingScanData } = useMissingScanStatus();
|
||||||
|
const triggerMissingScan = useTriggerMissingScan();
|
||||||
|
|
||||||
const isLoading = healthLoading || statusLoading;
|
const isLoading = healthLoading || statusLoading;
|
||||||
|
|
||||||
|
|
@ -92,6 +94,102 @@ export function SystemPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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 ── */}
|
{/* ── System Status section ── */}
|
||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue