From 3751ffefb9b0f75d0d68a33aeeedaf43e1bcbeec Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Wed, 8 Oct 2025 07:55:31 -0500 Subject: [PATCH] Black Mirror --- apps/media_library/api_views.py | 70 ++++++ apps/media_library/tasks.py | 83 +++---- apps/media_library/utils.py | 32 ++- frontend/src/WebSocket.jsx | 15 +- frontend/src/api.js | 17 ++ .../components/library/LibraryFormModal.jsx | 231 +++++++++++++++--- frontend/src/components/library/MediaGrid.jsx | 101 +++++++- frontend/src/pages/Library.jsx | 207 +++++++--------- frontend/src/store/mediaLibrary.jsx | 38 ++- 9 files changed, 573 insertions(+), 221 deletions(-) diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 8d4c7e2c..877fa6b0 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -1,4 +1,6 @@ import logging +import os +from pathlib import Path from django.conf import settings from django.core.signing import TimestampSigner @@ -34,6 +36,74 @@ class LibraryViewSet(viewsets.ModelViewSet): ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"] ordering = ["name"] + @action(detail=False, methods=["get"], url_path="browse") + def browse(self, request): + raw_path = request.query_params.get("path") + + if not raw_path: + if os.name == "nt": + import string + + entries = [] + for letter in string.ascii_uppercase: + drive = Path(f"{letter}:/") + if drive.exists(): + entries.append( + { + "name": f"{letter}:", + "path": str(drive.resolve()), + } + ) + return Response({"path": "", "parent": None, "entries": entries}) + + root = Path("/").resolve() + entries = [] + try: + for child in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if child.is_dir(): + entries.append( + { + "name": child.name or str(child), + "path": str(child), + } + ) + except PermissionError: + entries = [] + + return Response({"path": str(root), "parent": None, "entries": entries}) + + try: + target = Path(raw_path).expanduser() + if not target.exists(): + raise ValidationError({"detail": "Directory not found."}) + if not target.is_dir(): + target = target.parent + target = target.resolve() + except (ValueError, OSError, RuntimeError): + raise ValidationError({"detail": "Invalid path."}) + + entries = [] + try: + for child in sorted(target.iterdir(), key=lambda p: p.name.lower()): + if child.is_dir(): + entries.append( + { + "name": child.name or str(child), + "path": str(child), + } + ) + except PermissionError: + entries = [] + + parent = str(target.parent) if target != target.parent else None + return Response( + { + "path": str(target), + "parent": parent, + "entries": entries, + } + ) + def perform_create(self, serializer): library = serializer.save() if library.auto_scan_enabled: diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index fb508d0c..d241f651 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -207,6 +207,9 @@ def scan_library_task( matched = 0 unmatched = 0 media_item_ids: Set[int] = set() + last_progress_emit = timezone.now() + progress_step = 5 + progress_interval = 0.4 # seconds _send_scan_event( { "status": "started", @@ -244,6 +247,7 @@ def scan_library_task( scanner.mark_missing_files() total_files = len(discoveries) + progress_step = max(1, total_files // 200) for result in discoveries: identify_result = _identify_media_file( @@ -257,25 +261,32 @@ def scan_library_task( parent_media_id = identify_result.get("parent_media_item_id") candidate_ids = { candidate_id - for candidate_id in (media_id, parent_media_id) - if candidate_id - } - for candidate_id in candidate_ids: - is_new_media = candidate_id not in media_item_ids - media_item_ids.add(candidate_id) - if is_new_media: - try: - media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id) - except MediaItem.DoesNotExist: - continue - else: - _send_media_item_update(media_obj, status="progress") + for candidate_id in (media_id, parent_media_id) + if candidate_id + } + for candidate_id in candidate_ids: + is_new_media = candidate_id not in media_item_ids + media_item_ids.add(candidate_id) + if is_new_media: + try: + media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id) + except MediaItem.DoesNotExist: + continue + else: + _send_media_item_update(media_obj, status="progress") - if result.requires_probe: - probe_media_task.delay(result.file_id) + if result.requires_probe: + probe_media_task.delay(result.file_id) - processed += 1 - scan.record_progress(processed=processed, matched=matched, unmatched=unmatched) + processed += 1 + scan.record_progress(processed=processed, matched=matched, unmatched=unmatched) + now = timezone.now() + if ( + processed == total_files + or processed % progress_step == 0 + or (now - last_progress_emit).total_seconds() >= progress_interval + ): + last_progress_emit = now _send_scan_event( { "status": "progress", @@ -289,6 +300,12 @@ def scan_library_task( } ) + summary = ( + f"Processed {scan.total_files} files; " + f"new={scan.new_files}, updated={scan.updated_files}, " + f"removed={scan.removed_files}, matched={matched}, " + f"unmatched={unmatched}" + ) if media_item_ids: metadata_qs = MediaItem.objects.filter(pk__in=media_item_ids).filter( Q(metadata_last_synced_at__isnull=True) @@ -298,12 +315,6 @@ def scan_library_task( for item in metadata_qs: sync_metadata_task.delay(item.id) - summary = ( - f"Processed {scan.total_files} files; " - f"new={scan.new_files}, updated={scan.updated_files}, " - f"removed={scan.removed_files}, matched={matched}, " - f"unmatched={unmatched}" - ) scanner.finalize(matched=matched, unmatched=unmatched, summary=summary) logger.info("Completed scan for library %s", library.name) _send_scan_event( @@ -417,9 +428,6 @@ def _identify_media_file( else: unmatched = 1 - if not file_record.checksum: - compute_checksum_task.delay(file_record.id) - return { "file_id": file_id, "media_item_id": media_item.id if media_item else None, @@ -446,30 +454,15 @@ def _probe_media_file(*, file_id: int) -> None: probe_data = probe_media_file(file_record.absolute_path) apply_probe_metadata(file_record, probe_data) - -@shared_task(name="media_library.probe_media") -def probe_media_task(file_id: int): - _probe_media_file(file_id=file_id) - - -def _compute_checksum(file_id: int) -> None: - try: - file_record = MediaFile.objects.get(pk=file_id) - except MediaFile.DoesNotExist: - return - checksum = file_record.calculate_checksum() - if not checksum: - return - - if file_record.checksum != checksum: + if checksum and checksum != file_record.checksum: file_record.checksum = checksum file_record.save(update_fields=["checksum", "updated_at"]) -@shared_task(name="media_library.compute_checksum") -def compute_checksum_task(file_id: int): - _compute_checksum(file_id) +@shared_task(name="media_library.probe_media") +def probe_media_task(file_id: int): + _probe_media_file(file_id=file_id) def _sync_metadata(media_item_id: int) -> None: diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py index f21fe062..9f4bc32a 100644 --- a/apps/media_library/utils.py +++ b/apps/media_library/utils.py @@ -52,6 +52,32 @@ def _json_safe(value): return str(value) +def _first_numeric(value): + """Extract the first integer-like value from guessit responses.""" + if value is None: + return None + if isinstance(value, (list, tuple, set)): + for item in value: + normalized = _first_numeric(item) + if normalized is not None: + return normalized + return None + if isinstance(value, dict): + # Some guessit versions wrap values (e.g. {"season": 1}) + for key in ("season", "episode", "number"): + if key in value: + normalized = _first_numeric(value[key]) + if normalized is not None: + return normalized + return None + if isinstance(value, (int, float)): + return int(value) + try: + return int(value) + except (TypeError, ValueError): + return None + + @dataclass class DiscoveredFile: file_id: int @@ -290,9 +316,9 @@ def classify_media_file(file_name: str) -> ClassificationResult: classification = ClassificationResult( detected_type=detected_type, title=title, - year=data.get("year"), - season=data.get("season"), - episode=data.get("episode"), + year=_first_numeric(data.get("year")), + season=_first_numeric(data.get("season")), + episode=_first_numeric(data.get("episode")), episode_title=data.get("episode_title"), data=data, ) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 40aa7800..ccb1cf27 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -236,11 +236,16 @@ export const WebsocketProvider = ({ children }) => { if (parsedEvent.data.status === 'completed') { const { library_id: libraryId } = parsedEvent.data; - if ( - libraryId && - useMediaLibraryStore.getState().selectedLibraryId === libraryId - ) { - useMediaLibraryStore.getState().fetchItems(libraryId); + const mediaStore = useMediaLibraryStore.getState(); + const activeLibraryIds = mediaStore.activeLibraryIds || []; + const shouldRefresh = + activeLibraryIds.length === 0 || + (libraryId != null && + activeLibraryIds.includes(Number(libraryId))); + if (shouldRefresh) { + mediaStore.fetchItems( + activeLibraryIds.length > 0 ? activeLibraryIds : undefined + ); } notifications.show({ title: 'Library scan complete', diff --git a/frontend/src/api.js b/frontend/src/api.js index b7a2b8b2..ad169e48 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2432,6 +2432,23 @@ export default class API { } } + static async browseLibraryPath(path = '') { + try { + const params = new URLSearchParams(); + if (path) { + params.append('path', path); + } + const query = params.toString(); + const response = await request( + `${host}/api/media/libraries/browse/${query ? `?${query}` : ''}` + ); + return response; + } catch (e) { + errorNotification('Failed to browse server directories', e); + throw e; + } + } + static async triggerLibraryScan(id, { full = false } = {}) { try { const response = await request( diff --git a/frontend/src/components/library/LibraryFormModal.jsx b/frontend/src/components/library/LibraryFormModal.jsx index c1e95898..5852f9b9 100644 --- a/frontend/src/components/library/LibraryFormModal.jsx +++ b/frontend/src/components/library/LibraryFormModal.jsx @@ -1,8 +1,9 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { ActionIcon, Button, Checkbox, + Loader, Group, Modal, NumberInput, @@ -12,15 +13,15 @@ import { Text, TextInput, Textarea, + ScrollArea, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { Plus, Trash2 } from 'lucide-react'; +import { ArrowUp, FolderOpen, Plus, Trash2 } from 'lucide-react'; +import API from '../../api'; const LIBRARY_TYPES = [ { value: 'movies', label: 'Movies' }, { value: 'shows', label: 'TV Shows' }, - { value: 'mixed', label: 'Mixed' }, - { value: 'other', label: 'Other' }, ]; const defaultLocation = () => ({ @@ -37,7 +38,7 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => initialValues: { name: '', description: '', - library_type: 'mixed', + library_type: 'movies', metadata_language: 'en', metadata_country: 'US', scan_interval_minutes: 1440, @@ -47,12 +48,25 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => }, }); + const [browser, setBrowser] = useState({ + open: false, + index: null, + path: '', + parent: null, + entries: [], + loading: false, + error: null, + }); + useEffect(() => { if (library) { form.setValues({ name: library.name || '', description: library.description || '', - library_type: library.library_type || 'mixed', + library_type: + LIBRARY_TYPES.some((option) => option.value === library.library_type) + ? library.library_type + : 'movies', metadata_language: library.metadata_language || 'en', metadata_country: library.metadata_country || 'US', scan_interval_minutes: library.scan_interval_minutes || 1440, @@ -75,6 +89,12 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => } }, [library, opened]); + useEffect(() => { + if (!opened) { + closeBrowser(); + } + }, [opened]); + const addLocation = () => { form.insertListItem('locations', defaultLocation()); }; @@ -88,6 +108,68 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => form.removeListItem('locations', index); }; + const loadDirectory = async (targetPath) => { + const normalizedPath = targetPath ?? ''; + setBrowser((prev) => ({ ...prev, loading: true, error: null })); + try { + const response = await API.browseLibraryPath(normalizedPath); + setBrowser((prev) => ({ + ...prev, + path: response.path ?? normalizedPath, + parent: response.parent || null, + entries: Array.isArray(response.entries) ? response.entries : [], + loading: false, + })); + } catch (error) { + console.error('Failed to browse directories', error); + setBrowser((prev) => ({ + ...prev, + loading: false, + error: 'Unable to load directories. Check permissions and try again.', + })); + } + }; + + const openDirectoryBrowser = (index) => { + const current = form.values.locations?.[index]?.path || ''; + setBrowser({ + open: true, + index, + path: current, + parent: null, + entries: [], + loading: true, + error: null, + }); + void loadDirectory(current); + }; + + const closeBrowser = () => { + setBrowser({ + open: false, + index: null, + path: '', + parent: null, + entries: [], + loading: false, + error: null, + }); + }; + + const handleSelectDirectory = (path) => { + void loadDirectory(path ?? ''); + }; + + const handleUseDirectory = () => { + if (browser.index == null) { + closeBrowser(); + return; + } + const resolvedPath = browser.path || ''; + form.setFieldValue(`locations.${browser.index}.path`, resolvedPath); + closeBrowser(); + }; + const submit = (values) => { const payload = { ...values, @@ -100,15 +182,16 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => }; return ( - -
+ <> + + - - form.setFieldValue( - `locations.${index}.path`, - event.currentTarget.value - ) - } - /> + + + form.setFieldValue( + `locations.${index}.path`, + event.currentTarget.value + ) + } + style={{ flex: 1 }} + /> + + - -
+ +
+ + + + + {browser.path || '/'} + + + + {browser.error && ( + + {browser.error} + + )} + + {browser.loading ? ( + + + + ) : browser.entries.length === 0 ? ( + + No subdirectories found. + + ) : ( + + {browser.entries.map((entry) => ( + + ))} + + )} + + + + + + + + + + + ); }; diff --git a/frontend/src/components/library/MediaGrid.jsx b/frontend/src/components/library/MediaGrid.jsx index c60a8efd..1f71ba91 100644 --- a/frontend/src/components/library/MediaGrid.jsx +++ b/frontend/src/components/library/MediaGrid.jsx @@ -1,5 +1,7 @@ -import React from 'react'; -import { Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'; +import React, { useMemo } from 'react'; +import { Box, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeGrid as VirtualGrid } from 'react-window'; import MediaCard from './MediaCard'; const groupItemsByLetter = (items) => { @@ -16,6 +18,48 @@ const groupItemsByLetter = (items) => { return map; }; +const GRID_SPACING = 24; + +const getColumnCount = (width, columns) => { + if (!width) return columns.base || 1; + if (width >= 1400 && columns.xl) return columns.xl; + if (width >= 1200 && columns.lg) return columns.lg; + if (width >= 992 && columns.md) return columns.md; + if (width >= 768 && columns.sm) return columns.sm; + return columns.base || 1; +}; + +const CARD_HEIGHT_MAP = { + sm: 220, + md: 260, + lg: 320, +}; + +const VirtualizedCell = ({ columnIndex, rowIndex, style, data }) => { + const { items, columnCount, onSelect, onContextMenu, cardSize } = data; + const index = rowIndex * columnCount + columnIndex; + if (index >= items.length) { + return null; + } + const item = items[index]; + return ( + + + + ); +}; + const MediaGrid = ({ items, loading, @@ -26,6 +70,11 @@ const MediaGrid = ({ columns = { base: 1, sm: 2, md: 4, lg: 5 }, cardSize = 'md', }) => { + const rowHeight = useMemo(() => { + const base = CARD_HEIGHT_MAP[cardSize] ?? CARD_HEIGHT_MAP.md; + return base + GRID_SPACING; + }, [cardSize]); + if (loading) { return ( @@ -77,17 +126,43 @@ const MediaGrid = ({ } return ( - - {items.map((item) => ( - - ))} - + + + {({ height, width }) => { + if (!width || !height) { + return null; + } + const columnCount = getColumnCount(width, columns); + const rowCount = Math.ceil(items.length / columnCount); + const columnWidth = width / columnCount; + return ( + + {VirtualizedCell} + + ); + }} + + ); }; diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index a5edc595..c8cdf843 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -1,34 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { - ActionIcon, - Box, - Button, - Divider, - Group, - Paper, - Portal, - Select, - Stack, - Text, - TextInput, - Title, - SegmentedControl, -} from '@mantine/core'; +import { ActionIcon, Box, Button, Divider, Group, Paper, Portal, Stack, Text, TextInput, Title, SegmentedControl } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useDebouncedValue } from '@mantine/hooks'; -import { - ListChecks, - Play, - Plus, - RefreshCcw, - Search, - Trash2, -} from 'lucide-react'; +import { ListChecks, Play, RefreshCcw, Search, Trash2 } from 'lucide-react'; import useLibraryStore from '../store/library'; import useMediaLibraryStore from '../store/mediaLibrary'; -import LibraryFormModal from '../components/library/LibraryFormModal'; import MediaDetailModal from '../components/library/MediaDetailModal'; import LibraryScanDrawer from '../components/library/LibraryScanDrawer'; import MediaCarousel from '../components/library/MediaCarousel'; @@ -71,7 +49,6 @@ const LibraryPage = () => { const isMovies = normalizedMediaType !== 'shows'; const itemTypeFilter = isMovies ? 'movie' : 'show'; - const [formOpen, setFormOpen] = useState(false); const [scanDrawerOpen, setScanDrawerOpen] = useState(false); const [playbackModalOpen, setPlaybackModalOpen] = useState(false); const [activeTab, setActiveTab] = useState('recommended'); @@ -93,14 +70,10 @@ const LibraryPage = () => { // Library store hooks const libraries = useLibraryStore((s) => s.libraries); - const librariesLoading = useLibraryStore((s) => s.loading); const fetchLibraries = useLibraryStore((s) => s.fetchLibraries); - const createLibrary = useLibraryStore((s) => s.createLibrary); const triggerScan = useLibraryStore((s) => s.triggerScan); const upsertScan = useLibraryStore((s) => s.upsertScan); const removeScan = useLibraryStore((s) => s.removeScan); - const selectedLibraryId = useLibraryStore((s) => s.selectedLibraryId); - const setSelectedLibrary = useLibraryStore((s) => s.setSelectedLibrary); // Media store hooks const items = useMediaLibraryStore((s) => s.items); @@ -118,26 +91,16 @@ const LibraryPage = () => { fetchLibraries(); }, [fetchLibraries]); - // Ensure a library is selected for the current media type - useEffect(() => { - if (!libraries || libraries.length === 0) return; - if (selectedLibraryId) { - const current = libraries.find((lib) => lib.id === selectedLibraryId); - if (current) return; - } - - const preferred = libraries.find((lib) => - isMovies - ? lib.library_type === 'movies' || lib.library_type === 'mixed' - : lib.library_type === 'shows' || lib.library_type === 'mixed' - ); - - if (preferred) { - setSelectedLibrary(preferred.id); - } else if (libraries.length > 0) { - setSelectedLibrary(libraries[0].id); - } - }, [libraries, selectedLibraryId, setSelectedLibrary, isMovies]); + const relevantLibraryIds = useMemo(() => { + if (!libraries || libraries.length === 0) return []; + return libraries + .filter((lib) => + isMovies + ? lib.library_type === 'movies' + : lib.library_type === 'shows' + ) + .map((lib) => lib.id); + }, [libraries, isMovies]); // Sync media filters with current type and search useEffect(() => { @@ -149,15 +112,15 @@ const LibraryPage = () => { // Fetch items when library changes or filters update useEffect(() => { - if (!selectedLibraryId) return; - setSelectedMediaLibrary(selectedLibraryId); - fetchItems(selectedLibraryId); - }, [selectedLibraryId, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]); - - const selectedLibrary = useMemo( - () => libraries.find((lib) => lib.id === selectedLibraryId) || null, - [libraries, selectedLibraryId] - ); + if (!libraries || libraries.length === 0) { + setSelectedMediaLibrary(null); + fetchItems([]); + return; + } + const ids = relevantLibraryIds; + setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null); + fetchItems(ids); + }, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]); const filteredItems = useMemo(() => { const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter); @@ -226,6 +189,29 @@ const LibraryPage = () => { .slice(0, 12); }, [genresMap]); + const primaryLibraryId = useMemo( + () => (relevantLibraryIds.length === 1 ? relevantLibraryIds[0] : null), + [relevantLibraryIds] + ); + + const hasLibraries = relevantLibraryIds.length > 0; + + const aggregatedSubtitle = useMemo(() => { + if (!libraries || libraries.length === 0) { + return 'No libraries configured yet.'; + } + if (!hasLibraries) { + return isMovies + ? 'No movie libraries configured.' + : 'No TV show libraries configured.'; + } + const label = isMovies ? 'movie' : 'TV show'; + const count = relevantLibraryIds.length; + return `Aggregating ${count} ${label} librar${count === 1 ? 'y' : 'ies'}.`; + }, [libraries, hasLibraries, relevantLibraryIds, isMovies]); + + const canManageSingleLibrary = Boolean(primaryLibraryId); + const sortedLibraryItems = useMemo(() => { switch (sortOption) { case 'alpha': @@ -264,17 +250,17 @@ const LibraryPage = () => { } }; - const handleOpenItem = async (item) => { - try { - await openItem(item.id); - setPlaybackModalOpen(true); - } catch (error) { + const handleOpenItem = (item) => { + setPlaybackModalOpen(true); + openItem(item.id).catch((error) => { + console.error('Failed to open media item', error); + setPlaybackModalOpen(false); notifications.show({ title: 'Error loading media', message: 'Unable to open media details.', color: 'red', }); - } + }); }; const refreshItem = async (id) => { @@ -394,13 +380,46 @@ const LibraryPage = () => { // --- SCAN CONTROLS --- // Open the drawer only (do NOT start a scan) - const handleOpenScanDrawer = () => setScanDrawerOpen(true); + const handleOpenScanDrawer = () => { + if (!hasLibraries) { + notifications.show({ + title: 'No libraries configured', + message: 'Add a library to manage scans.', + color: 'yellow', + }); + return; + } + if (!canManageSingleLibrary) { + notifications.show({ + title: 'Multiple libraries detected', + message: 'Open the Libraries page to manage individual scans.', + color: 'yellow', + }); + return; + } + setScanDrawerOpen(true); + }; // Explicitly start a scan (quick or full) const handleStartScan = async (full = false) => { - if (!selectedLibraryId) return; + if (!hasLibraries) { + notifications.show({ + title: 'Scan unavailable', + message: 'Add a library before starting a scan.', + color: 'yellow', + }); + return; + } + if (!primaryLibraryId) { + notifications.show({ + title: 'Scan unavailable', + message: 'Choose a specific library from the Libraries page to start a scan.', + color: 'yellow', + }); + return; + } try { - await triggerScan(selectedLibraryId, { full }); + await triggerScan(primaryLibraryId, { full }); notifications.show({ title: full ? 'Full scan started' : 'Scan started', message: full @@ -503,7 +522,7 @@ const LibraryPage = () => { @@ -571,26 +590,10 @@ const LibraryPage = () => { {isMovies ? 'Movies' : 'TV Shows'} - {selectedLibrary ? selectedLibrary.name : 'Select a library to begin.'} + {aggregatedSubtitle} -