mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
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:
parent
8be5b4a539
commit
cd1ac0daea
8 changed files with 746 additions and 52 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue