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 = (
{
-
- );
+
+ )}
+
+
+ );
})();
const categoriesView = (
@@ -533,29 +580,34 @@ const LibraryPage = () => {
w={220}
disabled={librariesLoading || libraries.length === 0}
/>
- }
- onClick={() => setFormOpen(true)}
- >
+ } onClick={() => setFormOpen(true)}>
Add Library
+
+ {/* Open scan drawer ONLY */}
+
+ {/* Start a scan explicitly */}
+ handleStartScan(false)}
+ title="Start library scan"
+ >
+
+
-
+
}
@@ -608,6 +660,11 @@ const LibraryPage = () => {
opened={scanDrawerOpen}
onClose={() => setScanDrawerOpen(false)}
libraryId={selectedLibraryId}
+ // NEW: enable controls inside the drawer
+ onCancelJob={handleCancelScanJob}
+ onDeleteQueuedJob={handleDeleteQueuedScan}
+ onStartScan={() => handleStartScan(false)}
+ onStartFullScan={() => handleStartScan(true)}
/>