Black Mirror

This commit is contained in:
Dispatcharr 2025-10-08 07:55:31 -05:00
parent 0aa3e7a29d
commit 3751ffefb9
9 changed files with 573 additions and 221 deletions

View file

@ -1,4 +1,6 @@
import logging
import os
from pathlib import Path
from django.conf import settings
from django.core.signing import TimestampSigner
@ -34,6 +36,74 @@ class LibraryViewSet(viewsets.ModelViewSet):
ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"]
ordering = ["name"]
@action(detail=False, methods=["get"], url_path="browse")
def browse(self, request):
raw_path = request.query_params.get("path")
if not raw_path:
if os.name == "nt":
import string
entries = []
for letter in string.ascii_uppercase:
drive = Path(f"{letter}:/")
if drive.exists():
entries.append(
{
"name": f"{letter}:",
"path": str(drive.resolve()),
}
)
return Response({"path": "", "parent": None, "entries": entries})
root = Path("/").resolve()
entries = []
try:
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if child.is_dir():
entries.append(
{
"name": child.name or str(child),
"path": str(child),
}
)
except PermissionError:
entries = []
return Response({"path": str(root), "parent": None, "entries": entries})
try:
target = Path(raw_path).expanduser()
if not target.exists():
raise ValidationError({"detail": "Directory not found."})
if not target.is_dir():
target = target.parent
target = target.resolve()
except (ValueError, OSError, RuntimeError):
raise ValidationError({"detail": "Invalid path."})
entries = []
try:
for child in sorted(target.iterdir(), key=lambda p: p.name.lower()):
if child.is_dir():
entries.append(
{
"name": child.name or str(child),
"path": str(child),
}
)
except PermissionError:
entries = []
parent = str(target.parent) if target != target.parent else None
return Response(
{
"path": str(target),
"parent": parent,
"entries": entries,
}
)
def perform_create(self, serializer):
library = serializer.save()
if library.auto_scan_enabled:

View file

@ -207,6 +207,9 @@ def scan_library_task(
matched = 0
unmatched = 0
media_item_ids: Set[int] = set()
last_progress_emit = timezone.now()
progress_step = 5
progress_interval = 0.4 # seconds
_send_scan_event(
{
"status": "started",
@ -244,6 +247,7 @@ def scan_library_task(
scanner.mark_missing_files()
total_files = len(discoveries)
progress_step = max(1, total_files // 200)
for result in discoveries:
identify_result = _identify_media_file(
@ -257,25 +261,32 @@ def scan_library_task(
parent_media_id = identify_result.get("parent_media_item_id")
candidate_ids = {
candidate_id
for candidate_id in (media_id, parent_media_id)
if candidate_id
}
for candidate_id in candidate_ids:
is_new_media = candidate_id not in media_item_ids
media_item_ids.add(candidate_id)
if is_new_media:
try:
media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id)
except MediaItem.DoesNotExist:
continue
else:
_send_media_item_update(media_obj, status="progress")
for candidate_id in (media_id, parent_media_id)
if candidate_id
}
for candidate_id in candidate_ids:
is_new_media = candidate_id not in media_item_ids
media_item_ids.add(candidate_id)
if is_new_media:
try:
media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id)
except MediaItem.DoesNotExist:
continue
else:
_send_media_item_update(media_obj, status="progress")
if result.requires_probe:
probe_media_task.delay(result.file_id)
if result.requires_probe:
probe_media_task.delay(result.file_id)
processed += 1
scan.record_progress(processed=processed, matched=matched, unmatched=unmatched)
processed += 1
scan.record_progress(processed=processed, matched=matched, unmatched=unmatched)
now = timezone.now()
if (
processed == total_files
or processed % progress_step == 0
or (now - last_progress_emit).total_seconds() >= progress_interval
):
last_progress_emit = now
_send_scan_event(
{
"status": "progress",
@ -289,6 +300,12 @@ def scan_library_task(
}
)
summary = (
f"Processed {scan.total_files} files; "
f"new={scan.new_files}, updated={scan.updated_files}, "
f"removed={scan.removed_files}, matched={matched}, "
f"unmatched={unmatched}"
)
if media_item_ids:
metadata_qs = MediaItem.objects.filter(pk__in=media_item_ids).filter(
Q(metadata_last_synced_at__isnull=True)
@ -298,12 +315,6 @@ def scan_library_task(
for item in metadata_qs:
sync_metadata_task.delay(item.id)
summary = (
f"Processed {scan.total_files} files; "
f"new={scan.new_files}, updated={scan.updated_files}, "
f"removed={scan.removed_files}, matched={matched}, "
f"unmatched={unmatched}"
)
scanner.finalize(matched=matched, unmatched=unmatched, summary=summary)
logger.info("Completed scan for library %s", library.name)
_send_scan_event(
@ -417,9 +428,6 @@ def _identify_media_file(
else:
unmatched = 1
if not file_record.checksum:
compute_checksum_task.delay(file_record.id)
return {
"file_id": file_id,
"media_item_id": media_item.id if media_item else None,
@ -446,30 +454,15 @@ def _probe_media_file(*, file_id: int) -> None:
probe_data = probe_media_file(file_record.absolute_path)
apply_probe_metadata(file_record, probe_data)
@shared_task(name="media_library.probe_media")
def probe_media_task(file_id: int):
_probe_media_file(file_id=file_id)
def _compute_checksum(file_id: int) -> None:
try:
file_record = MediaFile.objects.get(pk=file_id)
except MediaFile.DoesNotExist:
return
checksum = file_record.calculate_checksum()
if not checksum:
return
if file_record.checksum != checksum:
if checksum and checksum != file_record.checksum:
file_record.checksum = checksum
file_record.save(update_fields=["checksum", "updated_at"])
@shared_task(name="media_library.compute_checksum")
def compute_checksum_task(file_id: int):
_compute_checksum(file_id)
@shared_task(name="media_library.probe_media")
def probe_media_task(file_id: int):
_probe_media_file(file_id=file_id)
def _sync_metadata(media_item_id: int) -> None:

View file

@ -52,6 +52,32 @@ def _json_safe(value):
return str(value)
def _first_numeric(value):
"""Extract the first integer-like value from guessit responses."""
if value is None:
return None
if isinstance(value, (list, tuple, set)):
for item in value:
normalized = _first_numeric(item)
if normalized is not None:
return normalized
return None
if isinstance(value, dict):
# Some guessit versions wrap values (e.g. {"season": 1})
for key in ("season", "episode", "number"):
if key in value:
normalized = _first_numeric(value[key])
if normalized is not None:
return normalized
return None
if isinstance(value, (int, float)):
return int(value)
try:
return int(value)
except (TypeError, ValueError):
return None
@dataclass
class DiscoveredFile:
file_id: int
@ -290,9 +316,9 @@ def classify_media_file(file_name: str) -> ClassificationResult:
classification = ClassificationResult(
detected_type=detected_type,
title=title,
year=data.get("year"),
season=data.get("season"),
episode=data.get("episode"),
year=_first_numeric(data.get("year")),
season=_first_numeric(data.get("season")),
episode=_first_numeric(data.get("episode")),
episode_title=data.get("episode_title"),
data=data,
)

View file

@ -236,11 +236,16 @@ export const WebsocketProvider = ({ children }) => {
if (parsedEvent.data.status === 'completed') {
const { library_id: libraryId } = parsedEvent.data;
if (
libraryId &&
useMediaLibraryStore.getState().selectedLibraryId === libraryId
) {
useMediaLibraryStore.getState().fetchItems(libraryId);
const mediaStore = useMediaLibraryStore.getState();
const activeLibraryIds = mediaStore.activeLibraryIds || [];
const shouldRefresh =
activeLibraryIds.length === 0 ||
(libraryId != null &&
activeLibraryIds.includes(Number(libraryId)));
if (shouldRefresh) {
mediaStore.fetchItems(
activeLibraryIds.length > 0 ? activeLibraryIds : undefined
);
}
notifications.show({
title: 'Library scan complete',

View file

@ -2432,6 +2432,23 @@ export default class API {
}
}
static async browseLibraryPath(path = '') {
try {
const params = new URLSearchParams();
if (path) {
params.append('path', path);
}
const query = params.toString();
const response = await request(
`${host}/api/media/libraries/browse/${query ? `?${query}` : ''}`
);
return response;
} catch (e) {
errorNotification('Failed to browse server directories', e);
throw e;
}
}
static async triggerLibraryScan(id, { full = false } = {}) {
try {
const response = await request(

View file

@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import {
ActionIcon,
Button,
Checkbox,
Loader,
Group,
Modal,
NumberInput,
@ -12,15 +13,15 @@ import {
Text,
TextInput,
Textarea,
ScrollArea,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { Plus, Trash2 } from 'lucide-react';
import { ArrowUp, FolderOpen, Plus, Trash2 } from 'lucide-react';
import API from '../../api';
const LIBRARY_TYPES = [
{ value: 'movies', label: 'Movies' },
{ value: 'shows', label: 'TV Shows' },
{ value: 'mixed', label: 'Mixed' },
{ value: 'other', label: 'Other' },
];
const defaultLocation = () => ({
@ -37,7 +38,7 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
initialValues: {
name: '',
description: '',
library_type: 'mixed',
library_type: 'movies',
metadata_language: 'en',
metadata_country: 'US',
scan_interval_minutes: 1440,
@ -47,12 +48,25 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
},
});
const [browser, setBrowser] = useState({
open: false,
index: null,
path: '',
parent: null,
entries: [],
loading: false,
error: null,
});
useEffect(() => {
if (library) {
form.setValues({
name: library.name || '',
description: library.description || '',
library_type: library.library_type || 'mixed',
library_type:
LIBRARY_TYPES.some((option) => option.value === library.library_type)
? library.library_type
: 'movies',
metadata_language: library.metadata_language || 'en',
metadata_country: library.metadata_country || 'US',
scan_interval_minutes: library.scan_interval_minutes || 1440,
@ -75,6 +89,12 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
}
}, [library, opened]);
useEffect(() => {
if (!opened) {
closeBrowser();
}
}, [opened]);
const addLocation = () => {
form.insertListItem('locations', defaultLocation());
};
@ -88,6 +108,68 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
form.removeListItem('locations', index);
};
const loadDirectory = async (targetPath) => {
const normalizedPath = targetPath ?? '';
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await API.browseLibraryPath(normalizedPath);
setBrowser((prev) => ({
...prev,
path: response.path ?? normalizedPath,
parent: response.parent || null,
entries: Array.isArray(response.entries) ? response.entries : [],
loading: false,
}));
} catch (error) {
console.error('Failed to browse directories', error);
setBrowser((prev) => ({
...prev,
loading: false,
error: 'Unable to load directories. Check permissions and try again.',
}));
}
};
const openDirectoryBrowser = (index) => {
const current = form.values.locations?.[index]?.path || '';
setBrowser({
open: true,
index,
path: current,
parent: null,
entries: [],
loading: true,
error: null,
});
void loadDirectory(current);
};
const closeBrowser = () => {
setBrowser({
open: false,
index: null,
path: '',
parent: null,
entries: [],
loading: false,
error: null,
});
};
const handleSelectDirectory = (path) => {
void loadDirectory(path ?? '');
};
const handleUseDirectory = () => {
if (browser.index == null) {
closeBrowser();
return;
}
const resolvedPath = browser.path || '';
form.setFieldValue(`locations.${browser.index}.path`, resolvedPath);
closeBrowser();
};
const submit = (values) => {
const payload = {
...values,
@ -100,15 +182,16 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
};
return (
<Modal
opened={opened}
onClose={onClose}
title={editing ? 'Edit Library' : 'Create Library'}
size="lg"
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
zIndex={400}
>
<form onSubmit={form.onSubmit(submit)}>
<>
<Modal
opened={opened}
onClose={onClose}
title={editing ? 'Edit Library' : 'Create Library'}
size="lg"
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
zIndex={400}
>
<form onSubmit={form.onSubmit(submit)}>
<Stack spacing="md">
<TextInput
label="Name"
@ -198,17 +281,29 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
<Trash2 size={16} />
</ActionIcon>
</Group>
<TextInput
placeholder="/path/to/library"
required
value={location.path}
onChange={(event) =>
form.setFieldValue(
`locations.${index}.path`,
event.currentTarget.value
)
}
/>
<Group align="flex-end" gap="sm">
<TextInput
placeholder="/path/to/library"
required
value={location.path}
onChange={(event) =>
form.setFieldValue(
`locations.${index}.path`,
event.currentTarget.value
)
}
style={{ flex: 1 }}
/>
<Button
variant="light"
size="xs"
leftSection={<FolderOpen size={14} />}
onClick={() => openDirectoryBrowser(index)}
type="button"
>
Browse
</Button>
</Group>
<Group>
<Checkbox
label="Include subdirectories"
@ -244,8 +339,88 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
</Button>
</Group>
</Stack>
</form>
</Modal>
</form>
</Modal>
<Modal
opened={browser.open}
onClose={closeBrowser}
title="Select library directory"
size="lg"
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
zIndex={410}
>
<Stack spacing="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{browser.path || '/'}
</Text>
<Button
size="xs"
variant="light"
leftSection={<ArrowUp size={14} />}
onClick={() => handleSelectDirectory(browser.parent)}
disabled={!browser.parent || browser.loading}
type="button"
>
Up one level
</Button>
</Group>
{browser.error && (
<Text size="sm" c="red">
{browser.error}
</Text>
)}
<ScrollArea h={260} offsetScrollbars>
{browser.loading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : browser.entries.length === 0 ? (
<Text c="dimmed" size="sm">
No subdirectories found.
</Text>
) : (
<Stack spacing="xs">
{browser.entries.map((entry) => (
<Button
key={entry.path}
variant="subtle"
fullWidth
justify="space-between"
onClick={() => handleSelectDirectory(entry.path)}
type="button"
>
<span>{entry.name || entry.path}</span>
<Text size="xs" c="dimmed">
{entry.path}
</Text>
</Button>
))}
</Stack>
)}
</ScrollArea>
<Group justify="space-between">
<Button
variant="light"
size="xs"
onClick={() => void loadDirectory(browser.path)}
loading={browser.loading}
type="button"
>
Refresh
</Button>
<Group gap="sm">
<Button variant="subtle" onClick={closeBrowser} type="button">
Cancel
</Button>
<Button onClick={handleUseDirectory} type="button">
Use this folder
</Button>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};

View file

@ -1,5 +1,7 @@
import React from 'react';
import { Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core';
import React, { useMemo } from 'react';
import { Box, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeGrid as VirtualGrid } from 'react-window';
import MediaCard from './MediaCard';
const groupItemsByLetter = (items) => {
@ -16,6 +18,48 @@ const groupItemsByLetter = (items) => {
return map;
};
const GRID_SPACING = 24;
const getColumnCount = (width, columns) => {
if (!width) return columns.base || 1;
if (width >= 1400 && columns.xl) return columns.xl;
if (width >= 1200 && columns.lg) return columns.lg;
if (width >= 992 && columns.md) return columns.md;
if (width >= 768 && columns.sm) return columns.sm;
return columns.base || 1;
};
const CARD_HEIGHT_MAP = {
sm: 220,
md: 260,
lg: 320,
};
const VirtualizedCell = ({ columnIndex, rowIndex, style, data }) => {
const { items, columnCount, onSelect, onContextMenu, cardSize } = data;
const index = rowIndex * columnCount + columnIndex;
if (index >= items.length) {
return null;
}
const item = items[index];
return (
<Box
style={{
...style,
padding: GRID_SPACING / 2,
boxSizing: 'border-box',
}}
>
<MediaCard
item={item}
onClick={onSelect}
onContextMenu={onContextMenu}
size={cardSize}
/>
</Box>
);
};
const MediaGrid = ({
items,
loading,
@ -26,6 +70,11 @@ const MediaGrid = ({
columns = { base: 1, sm: 2, md: 4, lg: 5 },
cardSize = 'md',
}) => {
const rowHeight = useMemo(() => {
const base = CARD_HEIGHT_MAP[cardSize] ?? CARD_HEIGHT_MAP.md;
return base + GRID_SPACING;
}, [cardSize]);
if (loading) {
return (
<Group justify="center" py="xl">
@ -77,17 +126,43 @@ const MediaGrid = ({
}
return (
<SimpleGrid cols={columns} spacing="lg">
{items.map((item) => (
<MediaCard
key={item.id}
item={item}
onClick={onSelect}
onContextMenu={onContextMenu}
size={cardSize}
/>
))}
</SimpleGrid>
<Box
style={{
width: '100%',
height: '70vh',
minHeight: 480,
}}
>
<AutoSizer>
{({ height, width }) => {
if (!width || !height) {
return null;
}
const columnCount = getColumnCount(width, columns);
const rowCount = Math.ceil(items.length / columnCount);
const columnWidth = width / columnCount;
return (
<VirtualGrid
columnCount={columnCount}
columnWidth={columnWidth}
height={height}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
itemData={{
items,
columnCount,
onSelect,
onContextMenu,
cardSize,
}}
>
{VirtualizedCell}
</VirtualGrid>
);
}}
</AutoSizer>
</Box>
);
};

View file

@ -1,34 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Box,
Button,
Divider,
Group,
Paper,
Portal,
Select,
Stack,
Text,
TextInput,
Title,
SegmentedControl,
} from '@mantine/core';
import { ActionIcon, Box, Button, Divider, Group, Paper, Portal, Stack, Text, TextInput, Title, SegmentedControl } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useDebouncedValue } from '@mantine/hooks';
import {
ListChecks,
Play,
Plus,
RefreshCcw,
Search,
Trash2,
} from 'lucide-react';
import { ListChecks, Play, RefreshCcw, Search, Trash2 } from 'lucide-react';
import useLibraryStore from '../store/library';
import useMediaLibraryStore from '../store/mediaLibrary';
import LibraryFormModal from '../components/library/LibraryFormModal';
import MediaDetailModal from '../components/library/MediaDetailModal';
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
import MediaCarousel from '../components/library/MediaCarousel';
@ -71,7 +49,6 @@ const LibraryPage = () => {
const isMovies = normalizedMediaType !== 'shows';
const itemTypeFilter = isMovies ? 'movie' : 'show';
const [formOpen, setFormOpen] = useState(false);
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
const [playbackModalOpen, setPlaybackModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('recommended');
@ -93,14 +70,10 @@ const LibraryPage = () => {
// Library store hooks
const libraries = useLibraryStore((s) => s.libraries);
const librariesLoading = useLibraryStore((s) => s.loading);
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
const createLibrary = useLibraryStore((s) => s.createLibrary);
const triggerScan = useLibraryStore((s) => s.triggerScan);
const upsertScan = useLibraryStore((s) => s.upsertScan);
const removeScan = useLibraryStore((s) => s.removeScan);
const selectedLibraryId = useLibraryStore((s) => s.selectedLibraryId);
const setSelectedLibrary = useLibraryStore((s) => s.setSelectedLibrary);
// Media store hooks
const items = useMediaLibraryStore((s) => s.items);
@ -118,26 +91,16 @@ const LibraryPage = () => {
fetchLibraries();
}, [fetchLibraries]);
// Ensure a library is selected for the current media type
useEffect(() => {
if (!libraries || libraries.length === 0) return;
if (selectedLibraryId) {
const current = libraries.find((lib) => lib.id === selectedLibraryId);
if (current) return;
}
const preferred = libraries.find((lib) =>
isMovies
? lib.library_type === 'movies' || lib.library_type === 'mixed'
: lib.library_type === 'shows' || lib.library_type === 'mixed'
);
if (preferred) {
setSelectedLibrary(preferred.id);
} else if (libraries.length > 0) {
setSelectedLibrary(libraries[0].id);
}
}, [libraries, selectedLibraryId, setSelectedLibrary, isMovies]);
const relevantLibraryIds = useMemo(() => {
if (!libraries || libraries.length === 0) return [];
return libraries
.filter((lib) =>
isMovies
? lib.library_type === 'movies'
: lib.library_type === 'shows'
)
.map((lib) => lib.id);
}, [libraries, isMovies]);
// Sync media filters with current type and search
useEffect(() => {
@ -149,15 +112,15 @@ const LibraryPage = () => {
// Fetch items when library changes or filters update
useEffect(() => {
if (!selectedLibraryId) return;
setSelectedMediaLibrary(selectedLibraryId);
fetchItems(selectedLibraryId);
}, [selectedLibraryId, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]);
const selectedLibrary = useMemo(
() => libraries.find((lib) => lib.id === selectedLibraryId) || null,
[libraries, selectedLibraryId]
);
if (!libraries || libraries.length === 0) {
setSelectedMediaLibrary(null);
fetchItems([]);
return;
}
const ids = relevantLibraryIds;
setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null);
fetchItems(ids);
}, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]);
const filteredItems = useMemo(() => {
const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter);
@ -226,6 +189,29 @@ const LibraryPage = () => {
.slice(0, 12);
}, [genresMap]);
const primaryLibraryId = useMemo(
() => (relevantLibraryIds.length === 1 ? relevantLibraryIds[0] : null),
[relevantLibraryIds]
);
const hasLibraries = relevantLibraryIds.length > 0;
const aggregatedSubtitle = useMemo(() => {
if (!libraries || libraries.length === 0) {
return 'No libraries configured yet.';
}
if (!hasLibraries) {
return isMovies
? 'No movie libraries configured.'
: 'No TV show libraries configured.';
}
const label = isMovies ? 'movie' : 'TV show';
const count = relevantLibraryIds.length;
return `Aggregating ${count} ${label} librar${count === 1 ? 'y' : 'ies'}.`;
}, [libraries, hasLibraries, relevantLibraryIds, isMovies]);
const canManageSingleLibrary = Boolean(primaryLibraryId);
const sortedLibraryItems = useMemo(() => {
switch (sortOption) {
case 'alpha':
@ -264,17 +250,17 @@ const LibraryPage = () => {
}
};
const handleOpenItem = async (item) => {
try {
await openItem(item.id);
setPlaybackModalOpen(true);
} catch (error) {
const handleOpenItem = (item) => {
setPlaybackModalOpen(true);
openItem(item.id).catch((error) => {
console.error('Failed to open media item', error);
setPlaybackModalOpen(false);
notifications.show({
title: 'Error loading media',
message: 'Unable to open media details.',
color: 'red',
});
}
});
};
const refreshItem = async (id) => {
@ -394,13 +380,46 @@ const LibraryPage = () => {
// --- SCAN CONTROLS ---
// Open the drawer only (do NOT start a scan)
const handleOpenScanDrawer = () => setScanDrawerOpen(true);
const handleOpenScanDrawer = () => {
if (!hasLibraries) {
notifications.show({
title: 'No libraries configured',
message: 'Add a library to manage scans.',
color: 'yellow',
});
return;
}
if (!canManageSingleLibrary) {
notifications.show({
title: 'Multiple libraries detected',
message: 'Open the Libraries page to manage individual scans.',
color: 'yellow',
});
return;
}
setScanDrawerOpen(true);
};
// Explicitly start a scan (quick or full)
const handleStartScan = async (full = false) => {
if (!selectedLibraryId) return;
if (!hasLibraries) {
notifications.show({
title: 'Scan unavailable',
message: 'Add a library before starting a scan.',
color: 'yellow',
});
return;
}
if (!primaryLibraryId) {
notifications.show({
title: 'Scan unavailable',
message: 'Choose a specific library from the Libraries page to start a scan.',
color: 'yellow',
});
return;
}
try {
await triggerScan(selectedLibraryId, { full });
await triggerScan(primaryLibraryId, { full });
notifications.show({
title: full ? 'Full scan started' : 'Scan started',
message: full
@ -503,7 +522,7 @@ const LibraryPage = () => {
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
onClick={() => fetchItems(selectedLibraryId)}
onClick={() => fetchItems(relevantLibraryIds)}
>
Refresh
</Button>
@ -571,26 +590,10 @@ const LibraryPage = () => {
<Stack spacing={4}>
<Title order={2}>{isMovies ? 'Movies' : 'TV Shows'}</Title>
<Text c="dimmed" size="sm">
{selectedLibrary ? selectedLibrary.name : 'Select a library to begin.'}
{aggregatedSubtitle}
</Text>
</Stack>
<Group align="center" gap="sm">
<Select
placeholder={librariesLoading ? 'Loading libraries...' : 'Choose library'}
data={libraries.map((library) => ({
value: String(library.id),
label: library.name,
}))}
value={selectedLibraryId ? String(selectedLibraryId) : null}
onChange={(value) => setSelectedLibrary(Number(value))}
w={220}
disabled={librariesLoading || libraries.length === 0}
/>
<Button leftSection={<Plus size={16} />} onClick={() => setFormOpen(true)}>
Add Library
</Button>
{/* Open scan drawer ONLY */}
<ActionIcon
variant="light"
color="blue"
@ -599,8 +602,6 @@ const LibraryPage = () => {
>
<ListChecks size={18} />
</ActionIcon>
{/* Start a scan explicitly */}
<ActionIcon
variant="filled"
color="blue"
@ -625,7 +626,7 @@ const LibraryPage = () => {
</Group>
</Group>
{selectedLibrary ? (
{relevantLibraryIds.length > 0 ? (
<div>
{activeTab === 'recommended' && recommendedView}
{activeTab === 'library' && libraryView}
@ -633,39 +634,15 @@ const LibraryPage = () => {
</div>
) : (
<Stack align="center" py="xl" spacing="md">
<Text c="dimmed">Select or create a media library to get started.</Text>
<Text c="dimmed">Add a media library to begin importing content.</Text>
</Stack>
)}
</Stack>
<LibraryFormModal
opened={formOpen}
onClose={() => setFormOpen(false)}
library={null}
onSubmit={async (payload) => {
try {
const created = await createLibrary(payload);
notifications.show({
title: 'Library created',
message: 'New library added successfully.',
color: 'green',
});
setFormOpen(false);
fetchLibraries();
if (created?.id) {
setSelectedLibrary(created.id);
}
} catch (error) {
console.error('Failed to create library', error);
}
}}
submitting={false}
/>
<LibraryScanDrawer
opened={scanDrawerOpen}
opened={scanDrawerOpen && canManageSingleLibrary}
onClose={() => setScanDrawerOpen(false)}
libraryId={selectedLibraryId}
libraryId={primaryLibraryId}
// NEW: enable controls inside the drawer
onCancelJob={handleCancelScanJob}
onDeleteQueuedJob={handleDeleteQueuedScan}

View file

@ -56,6 +56,7 @@ const resetStateForUser = (state, userId) => {
state.activeItemLoading = false;
state.resumePrompt = null;
state.selectedLibraryId = null;
state.activeLibraryIds = [];
state.filters = { ...initialFilters };
state.ownerUserId = userId ?? null;
};
@ -72,6 +73,7 @@ const useMediaLibraryStore = create(
activeItemLoading: false,
resumePrompt: null,
selectedLibraryId: null,
activeLibraryIds: [],
filters: { ...initialFilters },
applyUserContext: (userId) =>
@ -97,6 +99,7 @@ const useMediaLibraryStore = create(
resetStateForUser(state, userId);
}
state.selectedLibraryId = libraryId;
state.activeLibraryIds = libraryId != null ? [Number(libraryId)] : [];
}),
resetFilters: () =>
@ -119,10 +122,8 @@ const useMediaLibraryStore = create(
return;
}
const selectedLibraryId = get().selectedLibraryId;
if (!selectedLibraryId) {
return;
}
const activeLibraryIds = get().activeLibraryIds || [];
const filterByLibrary = activeLibraryIds.length > 0;
const byId = new Map();
state.items.forEach((item) => {
@ -134,8 +135,9 @@ const useMediaLibraryStore = create(
return;
}
if (
filterByLibrary &&
incoming.library &&
Number(incoming.library) !== Number(selectedLibraryId)
!activeLibraryIds.includes(Number(incoming.library))
) {
return;
}
@ -169,10 +171,10 @@ const useMediaLibraryStore = create(
state.total = state.items.length;
}),
fetchItems: async (libraryId) => {
fetchItems: async (libraryIds) => {
const { isAuthenticated, userId } = getAuthSnapshot();
if (!libraryId || !isAuthenticated || !userId) {
if (!isAuthenticated || !userId) {
set((state) => {
if (state.ownerUserId == null) {
state.ownerUserId = userId ?? null;
@ -180,14 +182,22 @@ const useMediaLibraryStore = create(
resetStateForUser(state, userId);
return;
}
state.items = [];
state.total = 0;
state.loading = false;
state.error = null;
});
return;
}
const normalizedIds = Array.isArray(libraryIds)
? Array.from(
new Set(
libraryIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id))
)
)
: libraryIds != null
? [Number(libraryIds)].filter((id) => Number.isFinite(id))
: [];
set((state) => {
if (state.ownerUserId == null) {
state.ownerUserId = userId;
@ -196,12 +206,14 @@ const useMediaLibraryStore = create(
}
state.loading = true;
state.error = null;
state.activeLibraryIds = normalizedIds;
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
});
try {
const { filters } = get();
const params = new URLSearchParams();
params.append('library', libraryId);
normalizedIds.forEach((id) => params.append('library', id));
if (filters.type !== 'all') {
params.append('item_type', filters.type);
}
@ -221,6 +233,8 @@ const useMediaLibraryStore = create(
if (state.ownerUserId !== userId) {
return;
}
state.activeLibraryIds = normalizedIds;
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
state.items = itemsArray;
state.total = response.count || itemsArray.length || 0;
state.loading = false;