diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index 6fbd0b74..90f55a78 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -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"" + 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(): diff --git a/apps/media_library/scanner.py b/apps/media_library/scanner.py index 666a8401..63ee60f9 100644 --- a/apps/media_library/scanner.py +++ b/apps/media_library/scanner.py @@ -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 ) diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index 4b5da27b..2ae2dfec 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -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 diff --git a/frontend/src/components/forms/settings/MediaLibrarySettingsForm.jsx b/frontend/src/components/forms/settings/MediaLibrarySettingsForm.jsx new file mode 100644 index 00000000..96e4bec5 --- /dev/null +++ b/frontend/src/components/forms/settings/MediaLibrarySettingsForm.jsx @@ -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 ( + <> + + + + + Metadata Sources + + Prefer local NFO metadata, then fill missing fields from TMDB. + + + + + setPreferLocalMetadata(event.currentTarget.checked) + } + /> + setTmdbKey(event.currentTarget.value)} + description="Used for metadata and artwork lookups." + /> + + + + +
+ + TMDB logo + +
+
+ + + + + + Libraries + + Manage your movie and TV show libraries. + + + + + + {visibleLibraries.length === 0 ? ( + No libraries configured yet. + ) : ( + + {visibleLibraries.map((library) => ( + handleBrowseLibrary(library)} + onEdit={openEditLibraryModal} + onDelete={handleLibraryDelete} + onScan={(id) => handleLibraryScan(id, false)} + loadingScan={scanLoadingId === library.id} + /> + ))} + + )} +
+ + setLibraryFormOpen(false)} + library={editingLibrary} + onSubmit={handleLibrarySubmit} + submitting={librarySubmitting} + /> + + setScanDrawerOpen(false)} + libraryId={selectedLibraryId} + onCancelJob={handleCancelLibraryScan} + onDeleteQueuedJob={handleDeleteQueuedLibraryScan} + onStartScan={() => handleLibraryScan(selectedLibraryId, false)} + onStartFullScan={() => handleLibraryScan(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" + /> + + 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