mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 19:14:00 +00:00
Added limits/offsets
Added limits and offsets to improved loading speeds
This commit is contained in:
parent
8149b98bf5
commit
ea4ee78b69
4 changed files with 195 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue