Merge pull request #29 from danthi123/fix/issue-12-pagination-cap

fix: remove 500/1000 item cap on library pagination
This commit is contained in:
Kevin Allen
2025-12-28 14:49:33 -06:00
committed by GitHub
3 changed files with 122 additions and 12 deletions
+109 -3
View File
@@ -25,6 +25,9 @@ import { dataCacheService } from "../services/dataCache";
const router = Router();
// Maximum items per request to prevent DoS attacks while supporting large libraries
const MAX_LIMIT = 10000;
const applyCoverArtCorsHeaders = (res: Response, origin?: string) => {
if (origin) {
res.setHeader("Access-Control-Allow-Origin", origin);
@@ -693,7 +696,7 @@ router.get("/artists", async (req, res) => {
offset: offsetParam = "0",
filter = "owned", // owned (default), discovery, all
} = req.query;
const limit = parseInt(limitParam as string, 10) || 500; // No max cap - support unlimited pagination
const limit = Math.min(parseInt(limitParam as string, 10) || 500, MAX_LIMIT);
const offset = parseInt(offsetParam as string, 10) || 0;
// Build where clause based on filter
@@ -790,6 +793,9 @@ router.get("/artists", async (req, res) => {
},
select: {
id: true,
_count: {
select: { tracks: true },
},
},
},
},
@@ -804,6 +810,11 @@ router.get("/artists", async (req, res) => {
const artistsWithImages = artistsWithAlbums.map((artist) => {
const coverArt = imageMap.get(artist.id) || artist.heroUrl || null;
// Sum up track counts from all albums
const trackCount = artist.albums.reduce(
(sum, album) => sum + (album._count?.tracks || 0),
0
);
return {
id: artist.id,
mbid: artist.mbid,
@@ -811,6 +822,7 @@ router.get("/artists", async (req, res) => {
heroUrl: coverArt,
coverArt, // Alias for frontend consistency
albumCount: artist.albums.length,
trackCount,
};
});
@@ -1577,7 +1589,7 @@ router.get("/albums", async (req, res) => {
offset: offsetParam = "0",
filter = "owned", // owned (default), discovery, all
} = req.query;
const limit = parseInt(limitParam as string, 10) || 500; // No max cap - support unlimited pagination
const limit = Math.min(parseInt(limitParam as string, 10) || 500, MAX_LIMIT);
const offset = parseInt(offsetParam as string, 10) || 0;
let where: any = {
@@ -1725,7 +1737,7 @@ router.get("/albums/:id", async (req, res) => {
router.get("/tracks", async (req, res) => {
try {
const { albumId, limit: limitParam = "100", offset: offsetParam = "0" } = req.query;
const limit = parseInt(limitParam as string, 10) || 100;
const limit = Math.min(parseInt(limitParam as string, 10) || 100, MAX_LIMIT);
const offset = parseInt(offsetParam as string, 10) || 0;
const where: any = {};
@@ -1771,6 +1783,100 @@ router.get("/tracks", async (req, res) => {
}
});
// GET /library/tracks/shuffle?limit=100 - Get random tracks for shuffle play
router.get("/tracks/shuffle", async (req, res) => {
try {
const { limit: limitParam = "100" } = req.query;
const limit = Math.min(parseInt(limitParam as string, 10) || 100, MAX_LIMIT);
// Get total count of tracks
const totalTracks = await prisma.track.count();
if (totalTracks === 0) {
return res.json({ tracks: [], total: 0 });
}
// For small libraries, fetch all and shuffle in memory
// For large libraries, use random offset sampling for better performance
let tracksData;
if (totalTracks <= limit) {
// Fetch all tracks and shuffle
tracksData = await prisma.track.findMany({
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// Fisher-Yates shuffle
for (let i = tracksData.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracksData[i], tracksData[j]] = [tracksData[j], tracksData[i]];
}
} else {
// For large libraries, sample random tracks using multiple random offsets
// This provides good randomization without loading entire library
const sampleSize = Math.min(limit, totalTracks);
const offsets = new Set<number>();
// Generate unique random offsets
while (offsets.size < sampleSize) {
offsets.add(Math.floor(Math.random() * totalTracks));
}
// Fetch tracks at random offsets (batch for efficiency)
const offsetArray = Array.from(offsets);
tracksData = await prisma.track.findMany({
skip: 0,
take: totalTracks, // We'll filter by our offsets
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// Pick tracks at our random indices and shuffle
const selectedTracks = offsetArray.map(idx => tracksData[idx]).filter(Boolean);
tracksData = selectedTracks;
// Fisher-Yates shuffle
for (let i = tracksData.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracksData[i], tracksData[j]] = [tracksData[j], tracksData[i]];
}
}
// Add coverArt field to albums
const tracks = tracksData.slice(0, limit).map((track) => ({
...track,
album: {
...track.album,
coverArt: track.album.coverUrl,
},
}));
res.json({ tracks, total: totalTracks });
} catch (error) {
console.error("Shuffle tracks error:", error);
res.status(500).json({ error: "Failed to shuffle tracks" });
}
});
// GET /library/cover-art/:id?size= or GET /library/cover-art?url=&size=
// Apply lenient image limiter (500 req/min) instead of general API limiter (100 req/15min)
router.get("/cover-art/:id?", imageLimiter, async (req, res) => {
+5 -9
View File
@@ -112,21 +112,17 @@ export default function LibraryPage() {
playTracks(formattedTracks, startIndex);
};
// Shuffle entire library - fetches all tracks for true shuffle
// Shuffle entire library - uses server-side shuffle for large libraries
const handleShuffleLibrary = async () => {
try {
// Fetch a large batch of tracks for shuffling
const { tracks: allTracks } = await api.getTracks({
limit: 10000,
});
// Use server-side shuffle endpoint for better performance with large libraries
const { tracks: shuffledTracks } = await api.getShuffledTracks(500);
if (allTracks.length === 0) {
if (shuffledTracks.length === 0) {
return;
}
// Shuffle the tracks
const shuffled = [...allTracks].sort(() => Math.random() - 0.5);
const formattedTracks = formatTracksForAudio(shuffled);
const formattedTracks = formatTracksForAudio(shuffledTracks);
playTracks(formattedTracks, 0);
} catch (error) {
console.error("Failed to shuffle library:", error);
+8
View File
@@ -402,6 +402,14 @@ class ApiClient {
}>(`/library/tracks?${new URLSearchParams(params as any).toString()}`);
}
async getShuffledTracks(limit?: number) {
const params = limit ? `?limit=${limit}` : "";
return this.request<{
tracks: any[];
total: number;
}>(`/library/tracks/shuffle${params}`);
}
async deleteTrack(trackId: string) {
return this.request<{ message: string }>(`/library/tracks/${trackId}`, {
method: "DELETE",