diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index a2ce581e..2b634a3a 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -336,6 +336,46 @@ class MediaItemViewSet(viewsets.ModelViewSet): ) return queryset + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + total_count = queryset.count() + + limit_param = request.query_params.get("limit") + offset_param = request.query_params.get("offset") + + limit = None + offset = 0 + + try: + if limit_param not in (None, "", "0"): + parsed_limit = int(limit_param) + if parsed_limit > 0: + limit = parsed_limit + if offset_param not in (None, "", "0"): + parsed_offset = int(offset_param) + if parsed_offset >= 0: + offset = parsed_offset + except (TypeError, ValueError): + raise ValidationError({"detail": "Invalid limit or offset value."}) + + sliced_queryset = queryset + if limit is not None or offset: + end = offset + limit if limit is not None else None + sliced_queryset = queryset[offset:end] + + serializer = self.get_serializer(sliced_queryset, many=True) + data = serializer.data + + if limit is None and offset == 0: + return Response(data) + + payload = {"count": total_count, "results": data} + if limit is not None: + payload["limit"] = limit + if offset: + payload["offset"] = offset + return Response(payload) + def get_serializer_context(self): context = super().get_serializer_context() context["request"] = self.request diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index ad629ff7..1a356595 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -1,6 +1,6 @@ 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, Loader, Paper, Portal, Select, Stack, Text, TextInput, Title, SegmentedControl } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useDebouncedValue } from '@mantine/hooks'; import { ListChecks, Play, RefreshCcw, Search, Trash2, XCircle } from 'lucide-react'; @@ -21,13 +21,14 @@ const TABS = [ ]; const SORT_OPTIONS = [ - { label: 'Default', value: 'default' }, { label: 'Name (A-Z)', value: 'alpha' }, { label: 'Release Year', value: 'year' }, { label: 'Recently Added', value: 'recent' }, { label: 'Genre', value: 'genre' }, ]; +const INITIAL_LIBRARY_LIMIT = 60; + const parseDate = (value) => { if (!value) return 0; const timestamp = Date.parse(value); @@ -52,7 +53,7 @@ const LibraryPage = () => { const [scanDrawerOpen, setScanDrawerOpen] = useState(false); const [playbackModalOpen, setPlaybackModalOpen] = useState(false); const [activeTab, setActiveTab] = useState('recommended'); - const [sortOption, setSortOption] = useState('default'); + const [sortOption, setSortOption] = useState('alpha'); const [searchTerm, setSearchTerm] = useState(''); const [contextMenu, setContextMenu] = useState(null); const contextMenuPosition = useMemo(() => { @@ -78,6 +79,7 @@ const LibraryPage = () => { // Media store hooks const items = useMediaLibraryStore((s) => s.items); const itemsLoading = useMediaLibraryStore((s) => s.loading); + const itemsBackgroundLoading = useMediaLibraryStore((s) => s.backgroundLoading); const setItemFilters = useMediaLibraryStore((s) => s.setFilters); const fetchItems = useMediaLibraryStore((s) => s.fetchItems); const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId); @@ -112,14 +114,39 @@ const LibraryPage = () => { // Fetch items when library changes or filters update useEffect(() => { + const ids = relevantLibraryIds; + if (!libraries || libraries.length === 0) { setSelectedMediaLibrary(null); fetchItems([]); return; } - const ids = relevantLibraryIds; + setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null); - fetchItems(ids); + + if (ids.length === 0) { + fetchItems([]); + return; + } + + let cancelled = false; + + const loadItems = async () => { + if (debouncedSearch) { + await fetchItems(ids, { background: false }); + return; + } + + await fetchItems(ids, { limit: INITIAL_LIBRARY_LIMIT, ordering: '-updated_at' }); + if (cancelled) return; + await fetchItems(ids, { background: true }); + }; + + loadItems(); + + return () => { + cancelled = true; + }; }, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]); const filteredItems = useMemo(() => { @@ -205,9 +232,7 @@ const LibraryPage = () => { ? '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'}.`; + return ''; }, [libraries, hasLibraries, relevantLibraryIds, isMovies]); const canManageSingleLibrary = Boolean(primaryLibraryId); @@ -512,13 +537,16 @@ const LibraryPage = () => { - setSortOption(value || 'alpha')} + w={220} + /> + {itemsBackgroundLoading && } + - {sortOption === 'default' ? ( - recommendedView - ) : sortOption === 'genre' ? ( + {sortOption === 'genre' ? ( {genreCarousels.map(({ genre, items: genreItems }) => ( { {isMovies ? 'Movies' : 'TV Shows'} - - {aggregatedSubtitle} - + {aggregatedSubtitle && ( + + {aggregatedSubtitle} + + )} { - await API.deleteMediaLibrary(id); + let removedLibrary = null; + let removedScans = null; + let previousSelectedId = null; set((state) => { - state.libraries = state.libraries.filter((lib) => lib.id !== id); + previousSelectedId = state.selectedLibraryId; + const index = state.libraries.findIndex((lib) => lib.id === id); + if (index >= 0) { + removedLibrary = state.libraries[index]; + state.libraries.splice(index, 1); + } if (state.selectedLibraryId === id) { state.selectedLibraryId = null; } - delete state.scans[id]; + if (state.scans[id]) { + removedScans = state.scans[id]; + delete state.scans[id]; + } }); + try { + await API.deleteMediaLibrary(id); + } catch (error) { + set((state) => { + if (removedLibrary) { + state.libraries.push(removedLibrary); + state.libraries.sort((a, b) => + (a?.name || '').localeCompare(b?.name || '') + ); + } + state.selectedLibraryId = previousSelectedId; + if (removedScans) { + state.scans[id] = removedScans; + } + }); + throw error; + } }, purgeCompletedScans: async (options = {}) => { diff --git a/frontend/src/store/mediaLibrary.jsx b/frontend/src/store/mediaLibrary.jsx index 2fcf7d2b..4eed9c23 100644 --- a/frontend/src/store/mediaLibrary.jsx +++ b/frontend/src/store/mediaLibrary.jsx @@ -79,6 +79,7 @@ const useMediaLibraryStore = create( ownerUserId: null, items: [], loading: false, + backgroundLoading: false, error: null, total: 0, activeItem: null, @@ -185,7 +186,14 @@ const useMediaLibraryStore = create( state.total = state.items.length; }), - fetchItems: async (libraryIds) => { + fetchItems: async (libraryIds, options = {}) => { + const { + limit = null, + offset = 0, + append = false, + background = false, + ordering = null, + } = options; const { isAuthenticated, userId } = getAuthSnapshot(); if (!isAuthenticated || !userId) { @@ -212,13 +220,36 @@ const useMediaLibraryStore = create( ? [Number(libraryIds)].filter((id) => Number.isFinite(id)) : []; + if (normalizedIds.length === 0) { + set((state) => { + if (state.ownerUserId == null) { + state.ownerUserId = userId; + } else if (state.ownerUserId !== userId) { + resetStateForUser(state, userId); + state.ownerUserId = userId; + } + state.items = []; + state.total = 0; + state.loading = false; + state.backgroundLoading = false; + state.error = null; + state.activeLibraryIds = []; + state.selectedLibraryId = null; + }); + return; + } + set((state) => { if (state.ownerUserId == null) { state.ownerUserId = userId; } else if (state.ownerUserId !== userId) { resetStateForUser(state, userId); } - state.loading = true; + if (background) { + state.backgroundLoading = true; + } else { + state.loading = true; + } state.error = null; state.activeLibraryIds = normalizedIds; state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null; @@ -240,18 +271,50 @@ const useMediaLibraryStore = create( if (filters.search) { params.append('search', filters.search); } - const response = await API.getMediaItems(params); - const results = response.results || response; - const itemsArray = Array.isArray(results) ? results : []; + if (limit !== null) { + params.append('limit', limit); + } + if (offset) { + params.append('offset', offset); + } + if (ordering) { + params.append('ordering', ordering); + } + const rawResponse = await API.getMediaItems(params); + const responseResults = Array.isArray(rawResponse) + ? rawResponse + : rawResponse?.results ?? rawResponse; + const itemsArray = Array.isArray(responseResults) ? responseResults : []; + const totalItems = Array.isArray(rawResponse) + ? itemsArray.length + : rawResponse?.count ?? itemsArray.length ?? 0; + set((state) => { if (state.ownerUserId !== userId) { return; } state.activeLibraryIds = normalizedIds; state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null; - state.items = itemsArray; - state.total = response.count || itemsArray.length || 0; - state.loading = false; + if (append && state.items.length > 0) { + const existingById = new Map( + state.items.map((item) => [item.id, item]) + ); + itemsArray.forEach((item) => { + if (item?.id != null) { + existingById.set(item.id, item); + } + }); + state.items = Array.from(existingById.values()); + state.total = Math.max(state.items.length, totalItems); + } else { + state.items = itemsArray; + state.total = totalItems; + } + if (background) { + state.backgroundLoading = false; + } else { + state.loading = false; + } }); queueMetadataRefresh(itemsArray); } catch (error) { @@ -261,7 +324,11 @@ const useMediaLibraryStore = create( return; } state.error = 'Failed to load media items'; - state.loading = false; + if (background) { + state.backgroundLoading = false; + } else { + state.loading = false; + } }); } },