{
+ setDeleteDialogOpen(false);
+ setDeleteTarget(null);
+ }}
+ onConfirm={handleDeleteConfirm}
+ title="Delete library"
+ message={
+ deleteTarget ? (
+
+ {`Delete ${deleteTarget.name}?
+
+This will remove the library and related VOD items.`}
+
+ ) : (
+ 'Delete this library? This will remove the library and related VOD items.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ size="md"
+ />
+
+ setTmdbHelpOpen(false)}
+ title="How to get a TMDB API key"
+ size="lg"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
+ >
+
+
+ Dispatcharr uses TMDB (The Movie Database) for artwork and metadata.
+ You can create a key in a few minutes:
+
+
+
+ Visit{' '}
+
+ themoviedb.org
+ {' '}
+ and sign in or create a free account.
+
+
+ Open your{' '}
+
+ TMDB account settings
+ {' '}
+ and choose API.
+
+
+ Complete the short API application and copy the v3 API key into
+ the field above.
+
+
+
+ TMDB issues separate v3 and v4 keys. Dispatcharr only needs the v3
+ API key for metadata lookups.
+
+
+
+ >
+ );
+});
+
+export default MediaLibrarySettingsForm;
diff --git a/frontend/src/components/library/LibraryScanDrawer.jsx b/frontend/src/components/library/LibraryScanDrawer.jsx
index 5b84d1c6..7f4ec3d7 100644
--- a/frontend/src/components/library/LibraryScanDrawer.jsx
+++ b/frontend/src/components/library/LibraryScanDrawer.jsx
@@ -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 = ({
)}
-
+ {!isDiscoveryRunningUnknown && (
+
+ )}
);
})}
diff --git a/frontend/src/components/library/MediaDetailModal.jsx b/frontend/src/components/library/MediaDetailModal.jsx
index f18e097f..6038b584 100644
--- a/frontend/src/components/library/MediaDetailModal.jsx
+++ b/frontend/src/components/library/MediaDetailModal.jsx
@@ -820,7 +820,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
) : !activeItem ? (
Select a media item to see its details.
) : (
-
+
{posterUrl ? (
@@ -1294,7 +1294,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
) : null}
-
+
)}
{canEditMetadata && activeItem && (
diff --git a/frontend/src/pages/Libraries.jsx b/frontend/src/pages/Libraries.jsx
index 33e8af65..2f5dcf57 100644
--- a/frontend/src/pages/Libraries.jsx
+++ b/frontend/src/pages/Libraries.jsx
@@ -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 = () => {
- {libraries.length === 0 ? (
+ {visibleLibraries.length === 0 ? (
No libraries configured yet.
) : (
- {libraries.map((library) => (
+ {visibleLibraries.map((library) => (
{
onStartScan={() => handleScan(selectedLibraryId, false)}
onStartFullScan={() => handleScan(selectedLibraryId, true)}
/>
+
+ {
+ setDeleteDialogOpen(false);
+ setDeleteTarget(null);
+ }}
+ onConfirm={handleDeleteConfirm}
+ title="Delete library"
+ message={
+ deleteTarget ? (
+
+ {`Delete ${deleteTarget.name}?
+
+This will remove the library and related VOD items.`}
+
+ ) : (
+ 'Delete this library? This will remove the library and related VOD items.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ size="md"
+ />
);
};
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 4ce519a3..abb56764 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -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 && (
<>
+
+ Media Library
+
+
+ }>
+
+
+
+
+
+
DVR