Added limits/offsets

Added limits and offsets to improved loading speeds
This commit is contained in:
Dispatcharr 2025-10-12 13:10:30 -05:00
parent 8149b98bf5
commit ea4ee78b69
4 changed files with 195 additions and 33 deletions

View file

@ -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

View file

@ -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 = () => {
<Box style={{ position: 'relative' }}>
<Stack spacing="lg">
<Group justify="space-between" align="center">
<Select
label="Sort by"
data={SORT_OPTIONS}
value={sortOption}
onChange={(value) => setSortOption(value || 'default')}
w={220}
/>
<Group align="flex-end" gap="sm">
<Select
label="Sort by"
data={SORT_OPTIONS}
value={sortOption}
onChange={(value) => setSortOption(value || 'alpha')}
w={220}
/>
{itemsBackgroundLoading && <Loader size="xs" />}
</Group>
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
@ -527,9 +555,7 @@ const LibraryPage = () => {
Refresh
</Button>
</Group>
{sortOption === 'default' ? (
recommendedView
) : sortOption === 'genre' ? (
{sortOption === 'genre' ? (
<Stack spacing="xl">
{genreCarousels.map(({ genre, items: genreItems }) => (
<MediaCarousel
@ -589,9 +615,11 @@ const LibraryPage = () => {
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack spacing={4}>
<Title order={2}>{isMovies ? 'Movies' : 'TV Shows'}</Title>
<Text c="dimmed" size="sm">
{aggregatedSubtitle}
</Text>
{aggregatedSubtitle && (
<Text c="dimmed" size="sm">
{aggregatedSubtitle}
</Text>
)}
</Stack>
<Group align="center" gap="sm">
<ActionIcon

View file

@ -145,14 +145,41 @@ const useLibraryStore = create(
},
deleteLibrary: async (id) => {
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 = {}) => {

View file

@ -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;
}
});
}
},