diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index f370173..11ac41f 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -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(); + + // 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) => { diff --git a/frontend/app/library/page.tsx b/frontend/app/library/page.tsx index 383ba17..74ebc16 100644 --- a/frontend/app/library/page.tsx +++ b/frontend/app/library/page.tsx @@ -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); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d73be7a..8b0fbe3 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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",