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:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user