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:
Kevin Allen
2025-12-27 11:44:34 -06:00
committed by GitHub
3 changed files with 166 additions and 149 deletions
+24 -19
View File
@@ -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
View File
@@ -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
+110 -11
View File
@@ -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,
},
};
}