mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Updated Library page
This commit is contained in:
parent
0353362311
commit
2e4dec6de3
2 changed files with 265 additions and 115 deletions
|
|
@ -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(
|
||||
() => (
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<ScanSearch size={18} />
|
||||
<Title order={5} style={{ lineHeight: 1 }}>Library scans</Title>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{onStartScan && (
|
||||
<Tooltip label="Start quick scan">
|
||||
<ActionIcon variant="light" onClick={onStartScan}>
|
||||
<Play size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onStartFullScan && (
|
||||
<Button variant="light" size="xs" onClick={onStartFullScan}>
|
||||
Full scan
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip label="Refresh">
|
||||
<ActionIcon variant="light" onClick={handleRefresh}>
|
||||
<RefreshCcw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
),
|
||||
[onStartScan, onStartFullScan]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Library scans"
|
||||
position="right"
|
||||
size="md"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 6 }}
|
||||
withCloseButton
|
||||
title={header}
|
||||
>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{scansLoading ? (
|
||||
|
|
@ -58,29 +113,69 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => {
|
|||
<Loader />
|
||||
</Group>
|
||||
) : scans.length === 0 ? (
|
||||
<Text c="dimmed">No scans recorded yet.</Text>
|
||||
<Stack align="center" py="lg" gap={4}>
|
||||
<Text c="dimmed">No scans recorded yet.</Text>
|
||||
{onStartScan && (
|
||||
<Button size="xs" onClick={onStartScan} mt="xs">
|
||||
Start a scan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline active={0} reverseActive bulletSize={20} lineWidth={2}>
|
||||
{scans.map((scan) => (
|
||||
<Timeline.Item
|
||||
key={scan.id}
|
||||
title={dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
|
||||
bullet={<Badge color={statusColor[scan.status] || 'gray'}>{scan.status}</Badge>}
|
||||
>
|
||||
<Stack spacing={4} mt="xs">
|
||||
{(() => {
|
||||
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 (
|
||||
<Stack spacing={2}>
|
||||
<Timeline active={0} reverseActive bulletSize={18} lineWidth={2}>
|
||||
{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 (
|
||||
<Timeline.Item
|
||||
key={scan.id}
|
||||
title={dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
|
||||
bullet={
|
||||
<Badge color={statusColor[status] || 'gray'} variant="filled">
|
||||
{status}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Stack gap={6} mt="xs">
|
||||
{/* Summary + row actions */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={500}>{scan.summary || 'Scan'}</Text>
|
||||
<Group gap="xs">
|
||||
{isRunning(status) && (
|
||||
<Tooltip label="Cancel running scan">
|
||||
<ActionIcon
|
||||
color="yellow"
|
||||
variant="light"
|
||||
onClick={() => onCancelJob(scan.id)}
|
||||
>
|
||||
<Ban size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isQueued(status) && (
|
||||
<Tooltip label="Remove from queue">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => onDeleteQueuedJob(scan.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Progress */}
|
||||
{total > 0 && (
|
||||
<Stack gap={2}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={500}>
|
||||
{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)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
<Text size="sm">
|
||||
Summary: {scan.summary || 'No summary yet'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Finished {scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Files processed: {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated {scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
|
||||
</Text>
|
||||
{scan.unmatched_files > 0 && (
|
||||
<Text size="xs" c="yellow.4">
|
||||
Unmatched files: {scan.unmatched_files}
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<Text size="xs" c="dimmed">
|
||||
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} · Finished{' '}
|
||||
{scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
|
||||
</Text>
|
||||
)}
|
||||
{scan.log && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{scan.log}
|
||||
<Text size="xs" c="dimmed">
|
||||
Files {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated{' '}
|
||||
{scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
{scan.unmatched_files > 0 && (
|
||||
<Text size="xs" c="yellow.4">
|
||||
Unmatched files: {scan.unmatched_files}
|
||||
</Text>
|
||||
)}
|
||||
{scan.log && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{scan.log}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Stack spacing="xl">
|
||||
<MediaCarousel
|
||||
|
|
@ -443,51 +490,51 @@ const LibraryPage = () => {
|
|||
<Select
|
||||
label="Sort by"
|
||||
data={SORT_OPTIONS}
|
||||
value={sortOption}
|
||||
onChange={(value) => setSortOption(value || 'default')}
|
||||
w={220}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={() => fetchItems(selectedLibraryId)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
{sortOption === 'default' ? (
|
||||
recommendedView
|
||||
) : sortOption === 'genre' ? (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
value={sortOption}
|
||||
onChange={(value) => setSortOption(value || 'default')}
|
||||
w={220}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={() => fetchItems(selectedLibraryId)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
{sortOption === 'default' ? (
|
||||
recommendedView
|
||||
) : sortOption === 'genre' ? (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{sortOption === 'alpha' && availableLetters.size > 0 && (
|
||||
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
|
||||
)}
|
||||
<MediaGrid
|
||||
items={sortedLibraryItems}
|
||||
loading={itemsLoading}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
groupByLetter={sortOption === 'alpha'}
|
||||
letterRefs={letterRefs}
|
||||
cardSize="md"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{sortOption === 'alpha' && availableLetters.size > 0 && (
|
||||
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
|
||||
)}
|
||||
<MediaGrid
|
||||
items={sortedLibraryItems}
|
||||
loading={itemsLoading}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
groupByLetter={sortOption === 'alpha'}
|
||||
letterRefs={letterRefs}
|
||||
cardSize="md"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})();
|
||||
|
||||
const categoriesView = (
|
||||
|
|
@ -533,29 +580,34 @@ const LibraryPage = () => {
|
|||
w={220}
|
||||
disabled={librariesLoading || libraries.length === 0}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setFormOpen(true)}>
|
||||
Add Library
|
||||
</Button>
|
||||
|
||||
{/* Open scan drawer ONLY */}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleScanLibrary}
|
||||
onClick={handleOpenScanDrawer}
|
||||
title="View recent scans"
|
||||
>
|
||||
<ListChecks size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Start a scan explicitly */}
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={() => handleStartScan(false)}
|
||||
title="Start library scan"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
data={TABS}
|
||||
/>
|
||||
<SegmentedControl value={activeTab} onChange={setActiveTab} data={TABS} />
|
||||
<Group align="center" gap="sm">
|
||||
<TextInput
|
||||
leftSection={<Search size={16} />}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
||||
<MediaDetailModal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue