Updated Media Scanning

Replace delete alerts with confirmation modals and hide libraries immediately while deletion runs (Settings + Libraries).

Improve scan UX by handling unknown discovery totals and counting artwork only for shows/movies.

Tighten scan/metadata behavior with better cancellation handling, optional remote metadata, and stronger NFO parsing/episode matching.

Autosize the media detail modal scroll area for smoother viewing.
This commit is contained in:
Dispatcharr 2026-01-03 21:03:11 -06:00
parent 8be5b4a539
commit cd1ac0daea
8 changed files with 746 additions and 52 deletions

View file

@ -167,6 +167,29 @@ def _safe_xml_text(node: ET.Element | None) -> Optional[str]:
return text or None
def _normalize_xml_tag(tag: Any) -> str:
if not isinstance(tag, str):
return ""
return tag.rsplit("}", 1)[-1].lower()
def _iter_nfo_nodes(root: ET.Element, *tags: str):
tag_set = {tag.lower() for tag in tags if tag}
for node in root.iter():
if _normalize_xml_tag(node.tag) in tag_set:
yield node
def _find_child_text(node: ET.Element, *tags: str) -> Optional[str]:
tag_set = {tag.lower() for tag in tags if tag}
for child in list(node):
if _normalize_xml_tag(child.tag) in tag_set:
value = _safe_xml_text(child)
if value:
return value
return None
def _parse_xml_int(text: str | None) -> Optional[int]:
if not text:
return None
@ -289,15 +312,15 @@ def _nfo_list(root: ET.Element, tag: str) -> list[str]:
def _nfo_cast(root: ET.Element) -> list[dict[str, Any]]:
cast: list[dict[str, Any]] = []
for actor in root.findall("actor"):
name = _safe_xml_text(actor.find("name")) or _safe_xml_text(actor)
for actor in _iter_nfo_nodes(root, "actor"):
name = _find_child_text(actor, "name") or _safe_xml_text(actor)
if not name:
continue
cast.append(
{
"name": name,
"character": _safe_xml_text(actor.find("role")),
"profile_url": _safe_xml_text(actor.find("thumb")),
"character": _find_child_text(actor, "role", "character"),
"profile_url": _find_child_text(actor, "thumb", "profile"),
}
)
return cast
@ -305,12 +328,12 @@ def _nfo_cast(root: ET.Element) -> list[dict[str, Any]]:
def _nfo_crew(root: ET.Element) -> list[dict[str, Any]]:
crew: list[dict[str, Any]] = []
for director in root.findall("director"):
name = _safe_xml_text(director)
for director in _iter_nfo_nodes(root, "director"):
name = _find_child_text(director, "name") or _safe_xml_text(director)
if name:
crew.append({"name": name, "job": "Director", "department": "Directing"})
for writer in root.findall("credits"):
name = _safe_xml_text(writer)
for writer in _iter_nfo_nodes(root, "credits", "writer"):
name = _find_child_text(writer, "name") or _safe_xml_text(writer)
if name:
crew.append({"name": name, "job": "Writer", "department": "Writing"})
return crew
@ -409,6 +432,60 @@ def _nfo_trailer(root: ET.Element) -> Optional[str]:
return _extract_youtube_id(trailer) or trailer
_NFO_ROOT_TAGS = ("movie", "tvshow", "episodedetails", "episode")
def _extract_nfo_root_xml(payload: str) -> Optional[str]:
if not payload:
return None
match = re.search(
r"<(" + "|".join(_NFO_ROOT_TAGS) + r")\b[^>]*>",
payload,
flags=re.IGNORECASE,
)
if not match:
return None
tag = match.group(1).lower()
start = match.start()
payload_lower = payload.lower()
end_tag = f"</{tag}>"
end = payload_lower.rfind(end_tag)
if end == -1:
return None
end += len(end_tag)
return payload[start:end]
def _parse_nfo_root(nfo_path: str) -> tuple[Optional[ET.Element], Optional[str]]:
try:
tree = ET.parse(nfo_path)
except (ET.ParseError, OSError) as exc:
parse_error = exc
else:
root = tree.getroot()
if root is None:
return None, "NFO did not contain metadata."
return root, None
try:
with open(nfo_path, "r", encoding="utf-8-sig", errors="replace") as handle:
payload = handle.read()
except OSError as exc:
return None, f"Failed to parse NFO: {exc}"
extracted = _extract_nfo_root_xml(payload)
if not extracted:
return None, f"Failed to parse NFO: {parse_error}"
try:
root = ET.fromstring(extracted)
except ET.ParseError as exc:
return None, f"Failed to parse NFO: {exc}"
if root is None:
return None, "NFO did not contain metadata."
return root, None
def _find_library_base_path(file_path: str, library) -> Optional[str]:
# Identify which library location contains the file path.
if not file_path:
@ -622,14 +699,9 @@ def fetch_local_nfo_metadata(
if not nfo_path:
return None, "No NFO file found."
try:
tree = ET.parse(nfo_path)
except (ET.ParseError, OSError) as exc:
return None, f"Failed to parse NFO: {exc}"
root = tree.getroot()
if root is None:
return None, "NFO did not contain metadata."
root, error = _parse_nfo_root(nfo_path)
if error:
return None, error
imdb_id, tmdb_id = _nfo_unique_ids(root)
title = _safe_xml_text(root.find("title")) or _safe_xml_text(
@ -1980,7 +2052,12 @@ def _needs_remote_metadata(media_item: MediaItem) -> bool:
return any(_is_empty_value(value) for value in required_fields)
def sync_metadata(media_item: MediaItem, *, force: bool = False) -> Optional[MediaItem]:
def sync_metadata(
media_item: MediaItem,
*,
force: bool = False,
allow_remote: bool = True,
) -> Optional[MediaItem]:
# Orchestrate metadata sources: NFO -> TMDB -> Movie-DB.
prefer_local = CoreSettings.get_prefer_local_metadata()
has_local_metadata = False
@ -1993,9 +2070,12 @@ def sync_metadata(media_item: MediaItem, *, force: bool = False) -> Optional[Med
elif local_error:
logger.debug("Local metadata skipped for %s: %s", media_item, local_error)
if has_local_metadata and not _needs_remote_metadata(media_item):
if has_local_metadata and not allow_remote:
return media_item
if not allow_remote:
return media_item if has_local_metadata else None
tmdb_metadata = None
tmdb_error = None
if _get_tmdb_api_key():

View file

@ -3,6 +3,7 @@ from datetime import timezone as dt_timezone
from dataclasses import dataclass, field
from typing import Callable, Iterable, Optional
from django.db.models import Q
from django.utils import timezone
from apps.media_library.classification import classify_media_entry
@ -106,6 +107,7 @@ def scan_library_files(
file_updates: list[MediaFile] = []
file_creates: list[MediaFile] = []
seen_paths: set[str] = set()
def flush_updates():
nonlocal file_updates, file_creates
@ -186,6 +188,7 @@ def scan_library_files(
cache_key = (parent.id, season_number, episode_number, title)
if cache_key in episode_cache:
return episode_cache[cache_key]
normalized_title = normalize_title(title)
query = MediaItem.objects.filter(
library=library,
parent=parent,
@ -195,8 +198,10 @@ def scan_library_files(
query = query.filter(season_number=season_number)
if episode_number is not None:
query = query.filter(episode_number=episode_number)
if season_number is None and episode_number is None:
query = query.filter(title=title)
if season_number is None or episode_number is None:
query = query.filter(
Q(normalized_title=normalized_title) | Q(title=title)
)
item = query.first()
if not item:
item = MediaItem.objects.create(
@ -205,7 +210,7 @@ def scan_library_files(
item_type=MediaItem.TYPE_EPISODE,
title=title,
sort_title=title,
normalized_title=normalize_title(title),
normalized_title=normalized_title,
season_number=season_number,
episode_number=episode_number,
)
@ -229,12 +234,19 @@ def scan_library_files(
file_name = os.path.basename(full_path)
if not _is_media_file(file_name):
continue
if full_path in seen_paths:
continue
seen_paths.add(full_path)
result.total_files += 1
result.processed_files += 1
processed_since_update += 1
stat = os.stat(full_path)
try:
stat = os.stat(full_path)
except OSError as exc:
result.errors.append({"path": full_path, "error": str(exc)})
continue
modified_at = timezone.datetime.fromtimestamp(
stat.st_mtime, tz=dt_timezone.utc
)

View file

@ -9,6 +9,7 @@ from apps.media_library.metadata import METADATA_CACHE_TIMEOUT, sync_metadata
from apps.media_library.models import Library, LibraryScan, MediaItem
from apps.media_library.scanner import ScanCancelled, scan_library_files
from apps.media_library.vod import sync_vod_for_media_item
from core.models import CoreSettings
logger = logging.getLogger(__name__)
@ -61,6 +62,14 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
stages={},
)
if scan.status == LibraryScan.STATUS_CANCELLED:
scan.finished_at = scan.finished_at or timezone.now()
scan.save(update_fields=["finished_at", "updated_at"])
_update_stage(scan, STAGE_DISCOVERY, status="cancelled")
_update_stage(scan, STAGE_METADATA, status="skipped")
_update_stage(scan, STAGE_ARTWORK, status="skipped")
return
scan.task_id = self.request.id
scan.status = LibraryScan.STATUS_RUNNING
scan.started_at = timezone.now()
@ -77,16 +86,15 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
scan.refresh_from_db(fields=["status"])
return scan.status == LibraryScan.STATUS_CANCELLED
def progress_callback(processed: int, total: int):
def progress_callback(processed: int, _total: int):
scan.processed_files = processed
scan.total_files = total
scan.total_files = processed
scan.save(update_fields=["processed_files", "total_files", "updated_at"])
_update_stage(
scan,
STAGE_DISCOVERY,
status="running",
processed=processed,
total=total,
)
try:
@ -159,6 +167,9 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
metadata_qs = MediaItem.objects.filter(id__in=metadata_ids)
metadata_qs = _filter_metadata_queryset(metadata_qs, force=full)
metadata_total = metadata_qs.count()
artwork_total = metadata_qs.filter(
item_type__in=[MediaItem.TYPE_MOVIE, MediaItem.TYPE_SHOW]
).count()
if not metadata_total:
_update_stage(scan, STAGE_METADATA, status="completed", processed=0, total=0)
_update_stage(scan, STAGE_ARTWORK, status="completed", processed=0, total=0)
@ -170,10 +181,11 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
return
_update_stage(scan, STAGE_METADATA, status="running", processed=0, total=metadata_total)
_update_stage(scan, STAGE_ARTWORK, status="running", processed=0, total=metadata_total)
_update_stage(scan, STAGE_ARTWORK, status="running", processed=0, total=artwork_total)
processed = 0
artwork_processed = 0
allow_remote = not CoreSettings.get_prefer_local_metadata()
for media_item in metadata_qs.iterator():
if cancel_check():
@ -184,13 +196,17 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
_update_stage(scan, STAGE_ARTWORK, status="cancelled")
return
updated = sync_metadata(media_item, force=full)
updated = sync_metadata(media_item, force=full, allow_remote=allow_remote)
try:
sync_vod_for_media_item(media_item)
except Exception:
logger.exception("Failed to sync VOD for media item %s", media_item.id)
processed += 1
if updated and (updated.poster_url or updated.backdrop_url):
if (
media_item.item_type in {MediaItem.TYPE_MOVIE, MediaItem.TYPE_SHOW}
and updated
and (updated.poster_url or updated.backdrop_url)
):
artwork_processed += 1
if processed % 25 == 0 or processed == metadata_total:
@ -206,7 +222,7 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
STAGE_ARTWORK,
status="running",
processed=artwork_processed,
total=metadata_total,
total=artwork_total,
)
_update_stage(
@ -221,7 +237,7 @@ def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | No
STAGE_ARTWORK,
status="completed",
processed=artwork_processed,
total=metadata_total,
total=artwork_total,
)
scan.status = LibraryScan.STATUS_COMPLETED

View file

@ -0,0 +1,478 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Button,
Center,
Divider,
Group,
List,
Modal,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import { Plus } from 'lucide-react';
import useSettingsStore from '../../../store/settings.jsx';
import useLibraryStore from '../../../store/library.jsx';
import {
createSetting,
updateSetting,
} from '../../../utils/pages/SettingsUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import LibraryCard from '../../library/LibraryCard.jsx';
import LibraryFormModal from '../../library/LibraryFormModal.jsx';
import LibraryScanDrawer from '../../library/LibraryScanDrawer.jsx';
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import tmdbLogoUrl from '../../../assets/tmdb-logo-blue.svg?url';
const MediaLibrarySettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const navigate = useNavigate();
const libraries = useLibraryStore((s) => s.libraries);
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
const createLibrary = useLibraryStore((s) => s.createLibrary);
const updateLibrary = useLibraryStore((s) => s.updateLibrary);
const deleteLibrary = useLibraryStore((s) => s.deleteLibrary);
const triggerScan = useLibraryStore((s) => s.triggerScan);
const upsertScan = useLibraryStore((s) => s.upsertScan);
const removeScan = useLibraryStore((s) => s.removeScan);
const cancelLibraryScan = useLibraryStore((s) => s.cancelLibraryScan);
const deleteLibraryScan = useLibraryStore((s) => s.deleteLibraryScan);
const tmdbSetting = settings['tmdb-api-key'];
const preferLocalSetting = settings['prefer-local-metadata'];
const [tmdbKey, setTmdbKey] = useState('');
const [preferLocalMetadata, setPreferLocalMetadata] = useState(false);
const [savingMetadataSettings, setSavingMetadataSettings] = useState(false);
const [tmdbHelpOpen, setTmdbHelpOpen] = useState(false);
const [selectedLibraryId, setSelectedLibraryId] = useState(null);
const [libraryFormOpen, setLibraryFormOpen] = useState(false);
const [editingLibrary, setEditingLibrary] = useState(null);
const [librarySubmitting, setLibrarySubmitting] = useState(false);
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
const [scanLoadingId, setScanLoadingId] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [pendingDeleteIds, setPendingDeleteIds] = useState(() => new Set());
useEffect(() => {
if (active) {
fetchLibraries();
}
}, [active, fetchLibraries]);
useEffect(() => {
const currentKey = tmdbSetting?.value ?? '';
setTmdbKey(currentKey);
const preferValue = preferLocalSetting?.value;
const normalized = String(preferValue ?? '').toLowerCase();
setPreferLocalMetadata(['1', 'true', 'yes', 'on'].includes(normalized));
}, [tmdbSetting?.value, preferLocalSetting?.value]);
useEffect(() => {
setPendingDeleteIds((prev) => {
if (!prev.size) return prev;
const activeIds = new Set(libraries.map((library) => library.id));
let changed = false;
const next = new Set();
prev.forEach((id) => {
if (activeIds.has(id)) {
next.add(id);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [libraries]);
const visibleLibraries = useMemo(
() => libraries.filter((library) => !pendingDeleteIds.has(library.id)),
[libraries, pendingDeleteIds]
);
const handleSaveMetadataSettings = async () => {
setSavingMetadataSettings(true);
try {
const tasks = [];
const preferValue = preferLocalMetadata ? 'true' : 'false';
if (preferLocalSetting?.id) {
tasks.push(
updateSetting({ ...preferLocalSetting, value: preferValue })
);
} else {
tasks.push(
createSetting({
key: 'prefer-local-metadata',
name: 'Prefer Local Metadata',
value: preferValue,
})
);
}
const trimmedKey = (tmdbKey || '').trim();
if (tmdbSetting?.id) {
tasks.push(updateSetting({ ...tmdbSetting, value: trimmedKey }));
} else if (trimmedKey) {
tasks.push(
createSetting({
key: 'tmdb-api-key',
name: 'TMDB API Key',
value: trimmedKey,
})
);
}
const results = await Promise.all(tasks);
if (results.some((result) => !result)) {
throw new Error('Failed to save metadata settings');
}
showNotification({
title: 'Metadata settings saved',
message: 'Metadata preferences updated successfully.',
color: 'green',
});
} catch (error) {
console.error('Failed to save metadata settings', error);
showNotification({
title: 'Error',
message: 'Unable to save metadata settings.',
color: 'red',
});
} finally {
setSavingMetadataSettings(false);
}
};
const openCreateLibraryModal = () => {
setEditingLibrary(null);
setLibraryFormOpen(true);
};
const openEditLibraryModal = (library) => {
setEditingLibrary(library);
setLibraryFormOpen(true);
};
const handleLibrarySubmit = async (payload) => {
setLibrarySubmitting(true);
try {
if (editingLibrary) {
const updated = await updateLibrary(editingLibrary.id, payload);
if (updated) {
showNotification({
title: 'Library updated',
message: `${updated.name} saved successfully.`,
color: 'green',
});
}
} else {
const created = await createLibrary(payload);
if (created) {
showNotification({
title: 'Library created',
message: `${created.name} added.`,
color: 'green',
});
}
}
setLibraryFormOpen(false);
} catch (error) {
console.error(error);
} finally {
setLibrarySubmitting(false);
}
};
const handleLibraryDelete = async (library) => {
setDeleteTarget(library);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteDialogOpen(false);
setDeleteTarget(null);
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.add(target.id);
return next;
});
if (selectedLibraryId === target.id) {
setSelectedLibraryId(null);
setScanDrawerOpen(false);
}
const success = await deleteLibrary(target.id);
if (success) {
showNotification({
title: 'Library deleted',
message: `${target.name} removed.`,
color: 'red',
});
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.delete(target.id);
return next;
});
} else {
showNotification({
title: 'Unable to delete library',
message: `Failed to delete ${target.name}.`,
color: 'red',
});
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.delete(target.id);
return next;
});
}
};
const handleLibraryScan = async (libraryId, full = false) => {
setSelectedLibraryId(libraryId);
setScanLoadingId(libraryId);
try {
const scan = await triggerScan(libraryId, { full });
if (scan) {
upsertScan(scan);
setScanDrawerOpen(true);
showNotification({
title: full ? 'Full scan started' : 'Scan started',
message: 'The library scan has been queued.',
color: 'blue',
});
}
} catch (error) {
console.error(error);
} finally {
setScanLoadingId(null);
}
};
const handleCancelLibraryScan = async (scanId) => {
try {
const updated = await cancelLibraryScan(scanId);
if (updated) {
upsertScan(updated);
}
} catch (error) {
console.error(error);
}
};
const handleDeleteQueuedLibraryScan = async (scanId) => {
try {
const success = await deleteLibraryScan(scanId);
if (success) {
removeScan(scanId);
}
} catch (error) {
console.error(error);
}
};
const handleBrowseLibrary = (library) => {
const target = library.library_type === 'shows' ? 'shows' : 'movies';
setSelectedLibraryId(library.id);
navigate(`/library/${target}`);
};
return (
<>
<Stack gap="xl">
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Title order={4}>Metadata Sources</Title>
<Text size="sm" c="dimmed">
Prefer local NFO metadata, then fill missing fields from TMDB.
</Text>
</Stack>
</Group>
<Switch
label="Prefer local metadata (.nfo files)"
description="Use NFO data first and fill missing fields from TMDB."
checked={preferLocalMetadata}
onChange={(event) =>
setPreferLocalMetadata(event.currentTarget.checked)
}
/>
<TextInput
label="TMDB API Key"
placeholder="Enter TMDB API key"
value={tmdbKey}
onChange={(event) => setTmdbKey(event.currentTarget.value)}
description="Used for metadata and artwork lookups."
/>
<Group justify="space-between" align="center">
<Button
variant="subtle"
size="xs"
onClick={() => setTmdbHelpOpen(true)}
>
Where do I get this?
</Button>
<Button
size="xs"
variant="light"
onClick={handleSaveMetadataSettings}
loading={savingMetadataSettings}
>
Save Metadata Settings
</Button>
</Group>
<Center>
<Anchor
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
>
<img
src={tmdbLogoUrl}
alt="TMDB logo"
style={{
width: 140,
height: 'auto',
display: 'block',
}}
/>
</Anchor>
</Center>
</Stack>
<Divider />
<Group justify="space-between" align="center">
<Stack gap={4}>
<Title order={4}>Libraries</Title>
<Text size="sm" c="dimmed">
Manage your movie and TV show libraries.
</Text>
</Stack>
<Button leftSection={<Plus size={16} />} onClick={openCreateLibraryModal}>
Add Library
</Button>
</Group>
{visibleLibraries.length === 0 ? (
<Text c="dimmed">No libraries configured yet.</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing="lg">
{visibleLibraries.map((library) => (
<LibraryCard
key={library.id}
library={library}
selected={selectedLibraryId === library.id}
onSelect={() => handleBrowseLibrary(library)}
onEdit={openEditLibraryModal}
onDelete={handleLibraryDelete}
onScan={(id) => handleLibraryScan(id, false)}
loadingScan={scanLoadingId === library.id}
/>
))}
</SimpleGrid>
)}
</Stack>
<LibraryFormModal
opened={libraryFormOpen}
onClose={() => setLibraryFormOpen(false)}
library={editingLibrary}
onSubmit={handleLibrarySubmit}
submitting={librarySubmitting}
/>
<LibraryScanDrawer
opened={scanDrawerOpen && Boolean(selectedLibraryId)}
onClose={() => setScanDrawerOpen(false)}
libraryId={selectedLibraryId}
onCancelJob={handleCancelLibraryScan}
onDeleteQueuedJob={handleDeleteQueuedLibraryScan}
onStartScan={() => handleLibraryScan(selectedLibraryId, false)}
onStartFullScan={() => handleLibraryScan(selectedLibraryId, true)}
/>
<ConfirmationDialog
opened={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm}
title="Delete library"
message={
deleteTarget ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Delete ${deleteTarget.name}?
This will remove the library and related VOD items.`}
</div>
) : (
'Delete this library? This will remove the library and related VOD items.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
size="md"
/>
<Modal
opened={tmdbHelpOpen}
onClose={() => setTmdbHelpOpen(false)}
title="How to get a TMDB API key"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
>
<Stack gap="sm">
<Text size="sm">
Dispatcharr uses TMDB (The Movie Database) for artwork and metadata.
You can create a key in a few minutes:
</Text>
<List size="sm" spacing="xs">
<List.Item>
Visit{' '}
<Anchor
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
>
themoviedb.org
</Anchor>{' '}
and sign in or create a free account.
</List.Item>
<List.Item>
Open your{' '}
<Anchor
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noopener noreferrer"
>
TMDB account settings
</Anchor>{' '}
and choose <Text component="span" fw={500}>API</Text>.
</List.Item>
<List.Item>
Complete the short API application and copy the v3 API key into
the field above.
</List.Item>
</List>
<Text size="sm" c="dimmed">
TMDB issues separate v3 and v4 keys. Dispatcharr only needs the v3
API key for metadata lookups.
</Text>
</Stack>
</Modal>
</>
);
});
export default MediaLibrarySettingsForm;

View file

@ -245,14 +245,23 @@ const LibraryScanDrawer = ({
if (!stage) return '0';
const processed = stage.processed ?? 0;
const total = stage.total ?? 0;
const status = stage.status || 'pending';
const isDiscoveryRunningUnknown =
stageKey === 'discovery' &&
status === 'running' &&
(!total || total <= processed);
if (stage.status === 'skipped') {
if (isDiscoveryRunningUnknown) {
return processed > 0 ? `${processed} files scanned` : 'Scanning files...';
}
if (status === 'skipped') {
return 'Not required';
}
if (total > 0 && total >= processed) {
return `${processed} / ${total}`;
}
if (stage.status === 'completed' && processed === 0) {
if (status === 'completed' && processed === 0) {
return 'Done';
}
@ -412,6 +421,10 @@ const LibraryScanDrawer = ({
{stageOrder.map(({ key, label }) => {
const stage = scan.stages?.[key] || EMPTY_STAGE;
const stageStatus = stage.status || 'pending';
const isDiscoveryRunningUnknown =
key === 'discovery' &&
stageStatus === 'running' &&
(!stage.total || stage.total <= stage.processed);
const percent = getStagePercent(stage);
const progressColor = stageColorMap[key] || 'gray';
const badgeColor =
@ -422,7 +435,7 @@ const LibraryScanDrawer = ({
const percentDisplay =
stageStatus === 'completed'
? '100%'
: stageStatus === 'skipped'
: stageStatus === 'skipped' || isDiscoveryRunningUnknown
? null
: `${percent}%`;
return (
@ -445,13 +458,15 @@ const LibraryScanDrawer = ({
</Text>
)}
</Group>
<Progress
value={percent}
size="sm"
striped={animated}
animated={animated}
color={progressColor}
/>
{!isDiscoveryRunningUnknown && (
<Progress
value={percent}
size="sm"
striped={animated}
animated={animated}
color={progressColor}
/>
)}
</Stack>
);
})}

View file

@ -820,7 +820,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
) : !activeItem ? (
<Text c="dimmed">Select a media item to see its details.</Text>
) : (
<ScrollArea h={DETAIL_SCROLL_HEIGHT} offsetScrollbars>
<ScrollArea.Autosize mah={DETAIL_SCROLL_HEIGHT} offsetScrollbars>
<Stack spacing="xl">
<Group align="flex-start" gap="xl" wrap="wrap">
{posterUrl ? (
@ -1294,7 +1294,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
</Stack>
) : null}
</Stack>
</ScrollArea>
</ScrollArea.Autosize>
)}
</Modal>
{canEditMetadata && activeItem && (

View file

@ -8,6 +8,7 @@ import useLibraryStore from '../store/library';
import LibraryCard from '../components/library/LibraryCard';
import LibraryFormModal from '../components/library/LibraryFormModal';
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
import ConfirmationDialog from '../components/ConfirmationDialog';
const LibrariesPage = () => {
const navigate = useNavigate();
@ -29,16 +30,41 @@ const LibrariesPage = () => {
const [submitting, setSubmitting] = useState(false);
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
const [scanLoadingId, setScanLoadingId] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [pendingDeleteIds, setPendingDeleteIds] = useState(() => new Set());
useEffect(() => {
fetchLibraries();
}, [fetchLibraries]);
useEffect(() => {
setPendingDeleteIds((prev) => {
if (!prev.size) return prev;
const activeIds = new Set(libraries.map((library) => library.id));
let changed = false;
const next = new Set();
prev.forEach((id) => {
if (activeIds.has(id)) {
next.add(id);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [libraries]);
const selectedLibrary = useMemo(
() => libraries.find((lib) => lib.id === selectedLibraryId) || null,
[libraries, selectedLibraryId]
);
const visibleLibraries = useMemo(
() => libraries.filter((library) => !pendingDeleteIds.has(library.id)),
[libraries, pendingDeleteIds]
);
const openCreateModal = () => {
setEditingLibrary(null);
setFormOpen(true);
@ -79,20 +105,49 @@ const LibrariesPage = () => {
}
};
const handleDelete = async (library) => {
if (!window.confirm(`Delete ${library.name}? This will remove the library and related VOD items.`)) {
return;
const handleDelete = (library) => {
setDeleteTarget(library);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteDialogOpen(false);
setDeleteTarget(null);
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.add(target.id);
return next;
});
if (selectedLibraryId === target.id) {
setSelectedLibraryId(null);
setScanDrawerOpen(false);
}
const success = await deleteLibrary(library.id);
const success = await deleteLibrary(target.id);
if (success) {
notifications.show({
title: 'Library deleted',
message: `${library.name} removed.`,
message: `${target.name} removed.`,
color: 'red',
});
if (selectedLibraryId === library.id) {
setSelectedLibraryId(null);
}
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.delete(target.id);
return next;
});
} else {
notifications.show({
title: 'Unable to delete library',
message: `Failed to delete ${target.name}.`,
color: 'red',
});
setPendingDeleteIds((prev) => {
const next = new Set(prev);
next.delete(target.id);
return next;
});
}
};
@ -159,11 +214,11 @@ const LibrariesPage = () => {
</Button>
</Group>
{libraries.length === 0 ? (
{visibleLibraries.length === 0 ? (
<Text c="dimmed">No libraries configured yet.</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing="lg">
{libraries.map((library) => (
{visibleLibraries.map((library) => (
<LibraryCard
key={library.id}
library={library}
@ -196,6 +251,30 @@ const LibrariesPage = () => {
onStartScan={() => handleScan(selectedLibraryId, false)}
onStartFullScan={() => handleScan(selectedLibraryId, true)}
/>
<ConfirmationDialog
opened={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm}
title="Delete library"
message={
deleteTarget ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Delete ${deleteTarget.name}?
This will remove the library and related VOD items.`}
</div>
) : (
'Delete this library? This will remove the library and related VOD items.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
size="md"
/>
</Box>
);
};

View file

@ -29,6 +29,8 @@ const DvrSettingsForm = React.lazy(() =>
import('../components/forms/settings/DvrSettingsForm.jsx'));
const SystemSettingsForm = React.lazy(() =>
import('../components/forms/settings/SystemSettingsForm.jsx'));
const MediaLibrarySettingsForm = React.lazy(() =>
import('../components/forms/settings/MediaLibrarySettingsForm.jsx'));
const SettingsPage = () => {
const authUser = useAuthStore((s) => s.user);
@ -54,6 +56,18 @@ const SettingsPage = () => {
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
<AccordionItem value="media-library">
<AccordionControl>Media Library</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<MediaLibrarySettingsForm
active={accordianValue === 'media-library'} />
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="dvr-settings">
<AccordionControl>DVR</AccordionControl>
<AccordionPanel>