diff --git a/frontend/src/components/library/LibraryScanDrawer.jsx b/frontend/src/components/library/LibraryScanDrawer.jsx index 9e6e6ab9..c4718876 100644 --- a/frontend/src/components/library/LibraryScanDrawer.jsx +++ b/frontend/src/components/library/LibraryScanDrawer.jsx @@ -1,6 +1,8 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { + ActionIcon, Badge, + Button, Drawer, Group, Loader, @@ -8,8 +10,11 @@ import { ScrollArea, Stack, Text, + Title, + Tooltip, } from '@mantine/core'; import { Timeline } from '@mantine/core'; +import { Ban, Play, RefreshCcw, Trash2, ScanSearch } from 'lucide-react'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -19,10 +24,10 @@ dayjs.extend(relativeTime); const EMPTY_SCAN_LIST = []; - - const statusColor = { pending: 'gray', + queued: 'gray', + scheduled: 'gray', running: 'blue', started: 'blue', discovered: 'indigo', @@ -32,25 +37,75 @@ const statusColor = { cancelled: 'yellow', }; -const LibraryScanDrawer = ({ opened, onClose, libraryId }) => { - const scansLoading = useLibraryStore((state) => state.scansLoading); - const scans = - useLibraryStore((state) => state.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST; +const isRunning = (s) => + s === 'running' || s === 'started' || s === 'progress' || s === 'discovered'; +const isQueued = (s) => s === 'pending' || s === 'queued' || s === 'scheduled'; + +const LibraryScanDrawer = ({ + opened, + onClose, + libraryId, + // Optional actions provided by parent (no-ops by default) + onCancelJob = async () => {}, + onDeleteQueuedJob = async () => {}, + onStartScan = null, // () => void + onStartFullScan = null, // () => void +}) => { + const scansLoading = useLibraryStore((s) => s.scansLoading); + const scans = useLibraryStore((s) => s.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST; + const fetchScans = useLibraryStore((s) => s.fetchScans); + + // Fetch once when opened (WebSockets keep it live afterward) useEffect(() => { if (opened) { - useLibraryStore.getState().fetchScans(libraryId); + fetchScans(libraryId); } - }, [opened, libraryId]); + }, [opened, libraryId, fetchScans]); + + const handleRefresh = () => fetchScans(libraryId); + + const header = useMemo( + () => ( + + + + Library scans + + + + {onStartScan && ( + + + + + + )} + {onStartFullScan && ( + + )} + + + + + + + + ), + [onStartScan, onStartFullScan] + ); return ( {scansLoading ? ( @@ -58,29 +113,69 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => { ) : scans.length === 0 ? ( - No scans recorded yet. + + No scans recorded yet. + {onStartScan && ( + + )} + ) : ( - - {scans.map((scan) => ( - {scan.status}} - > - - {(() => { - const total = scan.total_files ?? scan.files ?? 0; - if (!total) return null; - const processedRaw = - scan.status === 'completed' - ? scan.total_files ?? scan.processed_files ?? scan.processed ?? 0 - : scan.processed_files ?? scan.processed ?? 0; - const processed = Math.min(processedRaw, total); - const percent = total - ? Math.min(100, Math.round((processed / total) * 100)) - : 0; - return ( - + + {scans.map((scan) => { + const status = scan.status || 'pending'; + const total = scan.total_files ?? scan.files ?? 0; + const processedRaw = + status === 'completed' + ? scan.total_files ?? scan.processed_files ?? scan.processed ?? 0 + : scan.processed_files ?? scan.processed ?? 0; + const processed = Math.min(processedRaw || 0, total || 0); + const percent = total ? Math.min(100, Math.round((processed / total) * 100)) : 0; + + return ( + + {status} + + } + > + + {/* Summary + row actions */} + + {scan.summary || 'Scan'} + + {isRunning(status) && ( + + onCancelJob(scan.id)} + > + + + + )} + {isQueued(status) && ( + + onDeleteQueuedJob(scan.id)} + > + + + + )} + + + + {/* Progress */} + {total > 0 && ( + {processed} / {total} processed @@ -93,36 +188,34 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => { value={percent} size="md" striped - animated={scan.status !== 'completed' && scan.status !== 'failed'} + animated={isRunning(status)} /> - ); - })()} - - Summary: {scan.summary || 'No summary yet'} - - - Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} - - - Finished {scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'} - - - Files processed: {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated {scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'} - - {scan.unmatched_files > 0 && ( - - Unmatched files: {scan.unmatched_files} + )} + + {/* Meta */} + + Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} · Finished{' '} + {scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'} - )} - {scan.log && ( - - {scan.log} + + Files {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated{' '} + {scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'} - )} - - - ))} + {scan.unmatched_files > 0 && ( + + Unmatched files: {scan.unmatched_files} + + )} + {scan.log && ( + + {scan.log} + + )} + + + ); + })} )} diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index e5e3c621..7aa71c8a 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -59,7 +59,8 @@ const parseDate = (value) => { const LibraryPage = () => { const navigate = useNavigate(); const { mediaType } = useParams(); - const normalizedMediaType = mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null; + const normalizedMediaType = + mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null; useEffect(() => { if (!normalizedMediaType) { @@ -388,13 +389,21 @@ const LibraryPage = () => { return () => document.removeEventListener('click', handleOutsideClick); }, [contextMenu]); - const handleScanLibrary = async () => { + // --- SCAN CONTROLS --- + + // Open the drawer only (do NOT start a scan) + const handleOpenScanDrawer = () => setScanDrawerOpen(true); + + // Explicitly start a scan (quick or full) + const handleStartScan = async (full = false) => { if (!selectedLibraryId) return; try { - await triggerScan(selectedLibraryId, { full: false }); + await triggerScan(selectedLibraryId, { full }); notifications.show({ - title: 'Scan started', - message: 'Library scan has been queued.', + title: full ? 'Full scan started' : 'Scan started', + message: full + ? 'A full library scan has been queued.' + : 'Library scan has been queued.', color: 'blue', }); setScanDrawerOpen(true); @@ -408,6 +417,44 @@ const LibraryPage = () => { } }; + // Cancel a running scan by job id + const handleCancelScanJob = async (jobId) => { + try { + await API.cancelLibraryScan(jobId); // implement in API + notifications.show({ + title: 'Scan canceled', + message: 'The running scan has been stopped.', + color: 'yellow', + }); + } catch (e) { + console.error(e); + notifications.show({ + title: 'Cancel failed', + message: 'Could not cancel this scan.', + color: 'red', + }); + } + }; + + // Remove a queued scan by job id + const handleDeleteQueuedScan = async (jobId) => { + try { + await API.deleteLibraryScan(jobId); // implement in API + notifications.show({ + title: 'Removed from queue', + message: 'The queued scan was removed.', + color: 'green', + }); + } catch (e) { + console.error(e); + notifications.show({ + title: 'Remove failed', + message: 'Could not remove this queued scan.', + color: 'red', + }); + } + }; + const recommendedView = ( {