Merge pull request #15 from danthi123/fix/issue-12-pagination-cap
Remove Math.min(..., 1000) hard cap on /library/artists endpoint Remove Math.min(..., 1000) hard cap on /library/albums endpoint Add offset parameter and total count to /library/tracks endpoint Rewrite useLibraryData hook for true server-side pagination Update library page to fetch pages on demand instead of client-side slicing Disable pagination buttons during loading to prevent race conditions This allows libraries of any size to be fully browsable. Previously, users with 10,000+ songs were capped at seeing only 500 items. Fixes #12
This commit is contained in:
@@ -693,7 +693,7 @@ router.get("/artists", async (req, res) => {
|
||||
offset: offsetParam = "0",
|
||||
filter = "owned", // owned (default), discovery, all
|
||||
} = req.query;
|
||||
const limit = Math.min(parseInt(limitParam as string, 10) || 500, 1000); // Max 1000
|
||||
const limit = parseInt(limitParam as string, 10) || 500; // No max cap - support unlimited pagination
|
||||
const offset = parseInt(offsetParam as string, 10) || 0;
|
||||
|
||||
// Build where clause based on filter
|
||||
@@ -1577,7 +1577,7 @@ router.get("/albums", async (req, res) => {
|
||||
offset: offsetParam = "0",
|
||||
filter = "owned", // owned (default), discovery, all
|
||||
} = req.query;
|
||||
const limit = Math.min(parseInt(limitParam as string, 10) || 500, 1000); // Max 1000
|
||||
const limit = parseInt(limitParam as string, 10) || 500; // No max cap - support unlimited pagination
|
||||
const offset = parseInt(offsetParam as string, 10) || 0;
|
||||
|
||||
let where: any = {
|
||||
@@ -1721,34 +1721,39 @@ router.get("/albums/:id", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /library/tracks?albumId=&limit=100
|
||||
// GET /library/tracks?albumId=&limit=100&offset=0
|
||||
router.get("/tracks", async (req, res) => {
|
||||
try {
|
||||
const { albumId, limit = "100" } = req.query;
|
||||
const limitNum = parseInt(limit as string, 10);
|
||||
const { albumId, limit: limitParam = "100", offset: offsetParam = "0" } = req.query;
|
||||
const limit = parseInt(limitParam as string, 10) || 100;
|
||||
const offset = parseInt(offsetParam as string, 10) || 0;
|
||||
|
||||
const where: any = {};
|
||||
if (albumId) {
|
||||
where.albumId = albumId as string;
|
||||
}
|
||||
|
||||
const tracksData = await prisma.track.findMany({
|
||||
where,
|
||||
take: limitNum,
|
||||
orderBy: albumId ? { trackNo: "asc" } : { id: "desc" },
|
||||
include: {
|
||||
album: {
|
||||
include: {
|
||||
artist: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
const [tracksData, total] = await Promise.all([
|
||||
prisma.track.findMany({
|
||||
where,
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: albumId ? { trackNo: "asc" } : { id: "desc" },
|
||||
include: {
|
||||
album: {
|
||||
include: {
|
||||
artist: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.track.count({ where }),
|
||||
]);
|
||||
|
||||
// Add coverArt field to albums
|
||||
const tracks = tracksData.map((track) => ({
|
||||
@@ -1759,7 +1764,7 @@ router.get("/tracks", async (req, res) => {
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ tracks });
|
||||
res.json({ tracks, total, offset, limit });
|
||||
} catch (error) {
|
||||
console.error("Get tracks error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch tracks" });
|
||||
|
||||
+32
-119
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
@@ -8,6 +8,7 @@ import { Tab, DeleteDialogState } from "@/features/library/types";
|
||||
import {
|
||||
useLibraryData,
|
||||
LibraryFilter,
|
||||
SortOption,
|
||||
} from "@/features/library/hooks/useLibraryData";
|
||||
import { api } from "@/lib/api";
|
||||
import { useLibraryActions } from "@/features/library/hooks/useLibraryActions";
|
||||
@@ -18,8 +19,6 @@ import { AlbumsGrid } from "@/features/library/components/AlbumsGrid";
|
||||
import { TracksList } from "@/features/library/components/TracksList";
|
||||
import { Shuffle, ListFilter } from "lucide-react";
|
||||
|
||||
type SortOption = "name" | "name-desc" | "recent" | "tracks";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -37,10 +36,13 @@ export default function LibraryPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Use custom hooks
|
||||
const { artists, albums, tracks, isLoading, reloadData } = useLibraryData({
|
||||
// Use custom hooks with server-side pagination
|
||||
const { artists, albums, tracks, isLoading, reloadData, pagination } = useLibraryData({
|
||||
activeTab,
|
||||
filter,
|
||||
sortBy,
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
});
|
||||
const {
|
||||
playArtist,
|
||||
@@ -61,84 +63,14 @@ export default function LibraryPage() {
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Sort and paginate data
|
||||
const sortedArtists = useMemo(() => {
|
||||
const sorted = [...artists];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "name-desc":
|
||||
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "tracks":
|
||||
sorted.sort(
|
||||
(a, b) => (b.trackCount || 0) - (a.trackCount || 0)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return sorted;
|
||||
}, [artists, sortBy]);
|
||||
// Reset page when filter or sort changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filter, sortBy, itemsPerPage]);
|
||||
|
||||
const sortedAlbums = useMemo(() => {
|
||||
const sorted = [...albums];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case "name-desc":
|
||||
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
case "recent":
|
||||
sorted.sort((a, b) => (b.year || 0) - (a.year || 0));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return sorted;
|
||||
}, [albums, sortBy]);
|
||||
|
||||
const sortedTracks = useMemo(() => {
|
||||
const sorted = [...tracks];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case "name-desc":
|
||||
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return sorted;
|
||||
}, [tracks, sortBy]);
|
||||
|
||||
// Paginate
|
||||
const paginatedArtists = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedArtists.slice(start, start + itemsPerPage);
|
||||
}, [sortedArtists, currentPage, itemsPerPage]);
|
||||
|
||||
const paginatedAlbums = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedAlbums.slice(start, start + itemsPerPage);
|
||||
}, [sortedAlbums, currentPage, itemsPerPage]);
|
||||
|
||||
const paginatedTracks = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedTracks.slice(start, start + itemsPerPage);
|
||||
}, [sortedTracks, currentPage, itemsPerPage]);
|
||||
|
||||
// Total counts and pages
|
||||
const totalItems =
|
||||
activeTab === "artists"
|
||||
? artists.length
|
||||
: activeTab === "albums"
|
||||
? albums.length
|
||||
: tracks.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
// Get total items and pages from pagination
|
||||
const totalItems = pagination.total;
|
||||
const totalPages = pagination.totalPages;
|
||||
|
||||
// Delete confirmation dialog state
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteDialogState>({
|
||||
@@ -180,17 +112,13 @@ export default function LibraryPage() {
|
||||
playTracks(formattedTracks, startIndex);
|
||||
};
|
||||
|
||||
// Shuffle entire library
|
||||
// Shuffle entire library - fetches all tracks for true shuffle
|
||||
const handleShuffleLibrary = async () => {
|
||||
try {
|
||||
// Get all tracks if not already loaded
|
||||
let allTracks = tracks;
|
||||
if (activeTab !== "tracks" || tracks.length === 0) {
|
||||
const { tracks: fetchedTracks } = await api.getTracks({
|
||||
limit: 1000,
|
||||
});
|
||||
allTracks = fetchedTracks;
|
||||
}
|
||||
// Fetch a large batch of tracks for shuffling
|
||||
const { tracks: allTracks } = await api.getTracks({
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
if (allTracks.length === 0) {
|
||||
return;
|
||||
@@ -271,7 +199,7 @@ export default function LibraryPage() {
|
||||
|
||||
{/* Item Count */}
|
||||
<span className="text-sm text-gray-400 ml-2">
|
||||
{totalItems}{" "}
|
||||
{totalItems.toLocaleString()}{" "}
|
||||
{activeTab === "artists"
|
||||
? "artists"
|
||||
: activeTab === "albums"
|
||||
@@ -289,10 +217,7 @@ export default function LibraryPage() {
|
||||
activeTab === "albums") && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("owned");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onClick={() => setFilter("owned")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
|
||||
filter === "owned"
|
||||
? "bg-[#ecb200] text-black"
|
||||
@@ -302,10 +227,7 @@ export default function LibraryPage() {
|
||||
Owned
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("discovery");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onClick={() => setFilter("discovery")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
|
||||
filter === "discovery"
|
||||
? "bg-purple-500 text-white"
|
||||
@@ -315,10 +237,7 @@ export default function LibraryPage() {
|
||||
Discovery
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("all");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onClick={() => setFilter("all")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
|
||||
filter === "all"
|
||||
? "bg-white/20 text-white"
|
||||
@@ -333,10 +252,7 @@ export default function LibraryPage() {
|
||||
{/* Sort Dropdown */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortOption);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
|
||||
>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
@@ -352,10 +268,7 @@ export default function LibraryPage() {
|
||||
{/* Items per page */}
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
|
||||
>
|
||||
<option value={25}>25 per page</option>
|
||||
@@ -368,7 +281,7 @@ export default function LibraryPage() {
|
||||
|
||||
{activeTab === "artists" && (
|
||||
<ArtistsGrid
|
||||
artists={paginatedArtists}
|
||||
artists={artists}
|
||||
isLoading={isLoading}
|
||||
onPlay={playArtist}
|
||||
onDelete={(id, name) =>
|
||||
@@ -384,7 +297,7 @@ export default function LibraryPage() {
|
||||
|
||||
{activeTab === "albums" && (
|
||||
<AlbumsGrid
|
||||
albums={paginatedAlbums}
|
||||
albums={albums}
|
||||
isLoading={isLoading}
|
||||
onPlay={playAlbum}
|
||||
onDelete={(id, title) =>
|
||||
@@ -400,7 +313,7 @@ export default function LibraryPage() {
|
||||
|
||||
{activeTab === "tracks" && (
|
||||
<TracksList
|
||||
tracks={paginatedTracks}
|
||||
tracks={tracks}
|
||||
isLoading={isLoading}
|
||||
currentTrackId={currentTrack?.id}
|
||||
onPlay={handlePlayTracks}
|
||||
@@ -422,7 +335,7 @@ export default function LibraryPage() {
|
||||
<div className="flex items-center justify-center gap-2 mt-8 pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
First
|
||||
@@ -431,7 +344,7 @@ export default function LibraryPage() {
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={currentPage === 1}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Prev
|
||||
@@ -445,14 +358,14 @@ export default function LibraryPage() {
|
||||
Math.min(totalPages, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Last
|
||||
|
||||
@@ -1,50 +1,141 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Artist, Album, Track, Tab } from "../types";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export type LibraryFilter = "owned" | "discovery" | "all";
|
||||
export type SortOption = "name" | "name-desc" | "recent" | "tracks";
|
||||
|
||||
interface UseLibraryDataProps {
|
||||
activeTab: Tab;
|
||||
filter?: LibraryFilter;
|
||||
sortBy?: SortOption;
|
||||
itemsPerPage?: number;
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
interface PaginationState {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function useLibraryData({
|
||||
activeTab,
|
||||
filter = "owned",
|
||||
sortBy = "name",
|
||||
itemsPerPage = 50,
|
||||
currentPage = 1,
|
||||
}: UseLibraryDataProps) {
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// Track the current request to avoid race conditions
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const currentRequestId = ++requestIdRef.current;
|
||||
const offset = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (activeTab === "artists") {
|
||||
const { artists } = await api.getArtists({
|
||||
limit: 500,
|
||||
const response = await api.getArtists({
|
||||
limit: itemsPerPage,
|
||||
offset,
|
||||
filter,
|
||||
});
|
||||
setArtists(artists);
|
||||
// Only update if this is still the current request
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
// Sort client-side since backend sorts by name asc only
|
||||
let sortedArtists = [...response.artists];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sortedArtists.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "name-desc":
|
||||
sortedArtists.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "tracks":
|
||||
sortedArtists.sort((a, b) => (b.trackCount || 0) - (a.trackCount || 0));
|
||||
break;
|
||||
}
|
||||
setArtists(sortedArtists);
|
||||
setPagination({
|
||||
total: response.total,
|
||||
offset: response.offset,
|
||||
limit: response.limit,
|
||||
});
|
||||
}
|
||||
} else if (activeTab === "albums") {
|
||||
const { albums } = await api.getAlbums({ limit: 500, filter });
|
||||
setAlbums(albums);
|
||||
const response = await api.getAlbums({
|
||||
limit: itemsPerPage,
|
||||
offset,
|
||||
filter,
|
||||
});
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
// Sort client-side since backend sorts by year desc only
|
||||
let sortedAlbums = [...response.albums];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sortedAlbums.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case "name-desc":
|
||||
sortedAlbums.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
case "recent":
|
||||
sortedAlbums.sort((a, b) => (b.year || 0) - (a.year || 0));
|
||||
break;
|
||||
}
|
||||
setAlbums(sortedAlbums);
|
||||
setPagination({
|
||||
total: response.total,
|
||||
offset: response.offset,
|
||||
limit: response.limit,
|
||||
});
|
||||
}
|
||||
} else if (activeTab === "tracks") {
|
||||
// Tracks filter could be added later if needed
|
||||
const { tracks } = await api.getTracks({ limit: 500 });
|
||||
setTracks(tracks);
|
||||
const response = await api.getTracks({
|
||||
limit: itemsPerPage,
|
||||
offset,
|
||||
});
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
// Sort client-side
|
||||
let sortedTracks = [...response.tracks];
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
sortedTracks.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case "name-desc":
|
||||
sortedTracks.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
}
|
||||
setTracks(sortedTracks);
|
||||
setPagination({
|
||||
total: response.total,
|
||||
offset: response.offset,
|
||||
limit: response.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load library data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeTab, filter, isAuthenticated]);
|
||||
}, [activeTab, filter, sortBy, itemsPerPage, currentPage, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -54,11 +145,19 @@ export function useLibraryData({
|
||||
loadData();
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(pagination.total / itemsPerPage);
|
||||
|
||||
return {
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
isLoading,
|
||||
reloadData,
|
||||
pagination: {
|
||||
...pagination,
|
||||
totalPages,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user