import { Router, Response } from "express"; import { requireAuth, requireAuthOrToken } from "../middleware/auth"; import { imageLimiter, apiLimiter } from "../middleware/rateLimiter"; import { lastFmService } from "../services/lastfm"; import { prisma } from "../utils/db"; import { getEnrichmentProgress } from "../workers/enrichment"; import { redisClient } from "../utils/redis"; import crypto from "crypto"; import path from "path"; import fs from "fs"; // Static imports for performance (avoid dynamic imports in hot paths) import { config } from "../config"; import { fanartService } from "../services/fanart"; import { deezerService } from "../services/deezer"; import { musicBrainzService } from "../services/musicbrainz"; import { coverArtService } from "../services/coverArt"; import { getSystemSettings } from "../utils/systemSettings"; import { AudioStreamingService } from "../services/audioStreaming"; import { scanQueue } from "../workers/queues"; import { organizeSingles } from "../workers/organizeSingles"; import { enrichSimilarArtist } from "../workers/artistEnrichment"; import { extractColorsFromImage } from "../utils/colorExtractor"; import { dataCacheService } from "../services/dataCache"; const router = Router(); const applyCoverArtCorsHeaders = (res: Response, origin?: string) => { if (origin) { res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Credentials", "true"); } else { res.setHeader("Access-Control-Allow-Origin", "*"); } res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); }; // All routes require auth (session or API key) router.use(requireAuthOrToken); // Apply API rate limiter to routes that need it // Skip rate limiting for high-traffic endpoints (cover-art, streaming) router.use((req, res, next) => { // Skip rate limiting for cover-art endpoint (handled by imageLimiter separately) if (req.path.startsWith("/cover-art")) { return next(); } // Skip rate limiting for streaming endpoints - audio must not be interrupted if (req.path.includes("/stream")) { return next(); } // Apply API rate limiter to all other routes return apiLimiter(req, res, next); }); /** * @openapi * /library/scan: * post: * summary: Start a library scan job * description: Initiates a background job to scan the music directory and index all audio files * tags: [Library] * security: * - sessionAuth: [] * - apiKeyAuth: [] * responses: * 200: * description: Library scan started successfully * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: "Library scan started" * jobId: * type: string * description: Job ID to track progress * example: "123" * musicPath: * type: string * example: "/path/to/music" * 500: * description: Failed to start scan * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ router.post("/scan", async (req, res) => { try { if (!config.music.musicPath) { return res.status(500).json({ error: "Music path not configured. Please set MUSIC_PATH environment variable.", }); } // First, organize any SLSKD downloads from Docker container to music library // This ensures files are moved before the scan finds them try { const { organizeSingles } = await import( "../workers/organizeSingles" ); console.log("[Scan] Organizing SLSKD downloads before scan..."); await organizeSingles(); console.log("[Scan] SLSKD organization complete"); } catch (err: any) { // Not a fatal error - SLSKD might not be running or have no files console.log("[Scan] SLSKD organization skipped:", err.message); } const userId = req.user?.id || "system"; // Add scan job to queue const job = await scanQueue.add("scan", { userId, musicPath: config.music.musicPath, }); res.json({ message: "Library scan started", jobId: job.id, musicPath: config.music.musicPath, }); } catch (error) { console.error("Scan trigger error:", error); res.status(500).json({ error: "Failed to start scan" }); } }); // GET /library/scan/status/:jobId - Check scan job status router.get("/scan/status/:jobId", async (req, res) => { try { const job = await scanQueue.getJob(req.params.jobId); if (!job) { return res.status(404).json({ error: "Job not found" }); } const state = await job.getState(); const progress = job.progress(); const result = job.returnvalue; res.json({ status: state, progress, result, }); } catch (error) { console.error("Get scan status error:", error); res.status(500).json({ error: "Failed to get job status" }); } }); // POST /library/organize - Manually trigger organization script router.post("/organize", async (req, res) => { try { // Run in background organizeSingles().catch((err) => { console.error("Manual organization failed:", err); }); res.json({ message: "Organization started in background" }); } catch (error) { console.error("Organization trigger error:", error); res.status(500).json({ error: "Failed to start organization" }); } }); // POST /library/artists/:id/enrich - Manually enrich artist metadata router.post("/artists/:id/enrich", async (req, res) => { try { const artist = await prisma.artist.findUnique({ where: { id: req.params.id }, }); if (!artist) { return res.status(404).json({ error: "Artist not found" }); } // Use enrichment functions // Run enrichment in background enrichSimilarArtist(artist).catch((err) => { console.error(`Failed to enrich artist ${artist.name}:`, err); }); res.json({ message: "Artist enrichment started in background" }); } catch (error) { console.error("Enrich artist error:", error); res.status(500).json({ error: "Failed to enrich artist" }); } }); // GET /library/enrichment-progress - Get enrichment worker progress router.get("/enrichment-progress", async (req, res) => { try { const progress = await getEnrichmentProgress(); res.json(progress); } catch (error) { console.error("Failed to get enrichment progress:", error); res.status(500).json({ error: "Failed to get enrichment progress" }); } }); // POST /library/re-enrich-all - Re-enrich all artists with missing images (no auth required for convenience) router.post("/re-enrich-all", async (req, res) => { try { // Reset all artists that have no heroUrl to "pending" const result = await prisma.artist.updateMany({ where: { OR: [{ heroUrl: null }, { heroUrl: "" }], }, data: { enrichmentStatus: "pending", lastEnriched: null, }, }); console.log( ` Reset ${result.count} artists with missing images to pending` ); res.json({ message: `Reset ${result.count} artists for re-enrichment`, count: result.count, }); } catch (error) { console.error("Failed to reset artists:", error); res.status(500).json({ error: "Failed to reset artists" }); } }); // GET /library/recently-listened?limit=10 router.get("/recently-listened", async (req, res) => { try { const { limit = "10" } = req.query; const userId = req.user!.id; const limitNum = parseInt(limit as string, 10); const [recentPlays, inProgressAudiobooks, inProgressPodcasts] = await Promise.all([ prisma.play.findMany({ where: { userId, // Exclude pure discovery plays (only show library and kept discovery) source: { in: ["LIBRARY", "DISCOVERY_KEPT"] }, // Also filter by album location to exclude discovery albums track: { album: { location: "LIBRARY", }, }, }, orderBy: { playedAt: "desc" }, take: limitNum * 3, // Get more than needed to account for duplicates include: { track: { include: { album: { include: { artist: { select: { id: true, mbid: true, name: true, heroUrl: true, }, }, }, }, }, }, }, }), prisma.audiobookProgress.findMany({ where: { userId, isFinished: false, currentTime: { gt: 0 }, // Only show if actually started }, orderBy: { lastPlayedAt: "desc" }, take: Math.ceil(limitNum / 3), // Get up to 1/3 for audiobooks }), prisma.podcastProgress.findMany({ where: { userId, isFinished: false, currentTime: { gt: 0 }, // Only show if actually started }, orderBy: { lastPlayedAt: "desc" }, take: limitNum * 2, // Get extra to account for deduplication include: { episode: { include: { podcast: { select: { id: true, title: true, author: true, imageUrl: true, }, }, }, }, }, }), ]); // Deduplicate podcasts - keep only the most recently played episode per podcast const seenPodcasts = new Set(); const uniquePodcasts = inProgressPodcasts .filter((pp) => { const podcastId = pp.episode.podcast.id; if (seenPodcasts.has(podcastId)) { return false; } seenPodcasts.add(podcastId); return true; }) .slice(0, Math.ceil(limitNum / 3)); // Limit to 1/3 after deduplication // Extract unique artists and audiobooks const items: any[] = []; const artistsMap = new Map(); // Add music artists for (const play of recentPlays) { const artist = play.track.album.artist; if (!artistsMap.has(artist.id)) { artistsMap.set(artist.id, { ...artist, type: "artist", lastPlayedAt: play.playedAt, }); } if (items.length >= limitNum) break; } // Combine artists, audiobooks, and podcasts const combined = [ ...Array.from(artistsMap.values()), ...inProgressAudiobooks.map((ab: any) => { // For audiobooks, prefix the path with 'audiobook__' so the frontend knows to use the audiobook endpoint const coverArt = ab.coverUrl && !ab.coverUrl.startsWith("http") ? `audiobook__${ab.coverUrl}` : ab.coverUrl; return { id: ab.audiobookshelfId, name: ab.title, coverArt, type: "audiobook", author: ab.author, progress: ab.duration > 0 ? Math.round((ab.currentTime / ab.duration) * 100) : 0, lastPlayedAt: ab.lastPlayedAt, }; }), ...uniquePodcasts.map((pp: any) => ({ id: pp.episode.podcast.id, episodeId: pp.episodeId, name: pp.episode.podcast.title, coverArt: pp.episode.podcast.imageUrl, type: "podcast", author: pp.episode.podcast.author, progress: pp.duration > 0 ? Math.round((pp.currentTime / pp.duration) * 100) : 0, lastPlayedAt: pp.lastPlayedAt, })), ]; // Sort by lastPlayedAt and limit combined.sort( (a, b) => new Date(b.lastPlayedAt).getTime() - new Date(a.lastPlayedAt).getTime() ); const limitedItems = combined.slice(0, limitNum); // Get album counts for artists const artistIds = limitedItems .filter((item) => item.type === "artist") .map((item) => item.id); const albumCounts = await prisma.ownedAlbum.groupBy({ by: ["artistId"], where: { artistId: { in: artistIds } }, _count: { rgMbid: true }, }); const albumCountMap = new Map( albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid]) ); // Add on-demand image fetching for artists without heroUrl const results = await Promise.all( limitedItems.map(async (item) => { if (item.type === "audiobook" || item.type === "podcast") { return item; } else { let coverArt = item.heroUrl; // Fetch image on-demand if missing if (!coverArt) { console.log( `[IMAGE] Fetching image on-demand for ${item.name}...` ); // Check Redis cache first const cacheKey = `hero-image:${item.id}`; try { const cached = await redisClient.get(cacheKey); if (cached) { coverArt = cached; console.log(` Found cached image`); } } catch (err) { // Redis errors are non-critical } // Try Fanart.tv if we have real MBID if ( !coverArt && item.mbid && !item.mbid.startsWith("temp-") ) { try { coverArt = await fanartService.getArtistImage( item.mbid ); } catch (err) { // Fanart.tv failed, continue to next source } } // Fallback to Deezer if (!coverArt) { try { coverArt = await deezerService.getArtistImage( item.name ); } catch (err) { // Deezer failed, continue to next source } } // Fallback to Last.fm if (!coverArt) { try { const validMbid = item.mbid && !item.mbid.startsWith("temp-") ? item.mbid : undefined; const lastfmInfo = await lastFmService.getArtistInfo( item.name, validMbid ); if ( lastfmInfo.image && lastfmInfo.image.length > 0 ) { const largestImage = lastfmInfo.image.find( (img: any) => img.size === "extralarge" || img.size === "mega" ) || lastfmInfo.image[ lastfmInfo.image.length - 1 ]; if (largestImage && largestImage["#text"]) { coverArt = largestImage["#text"]; console.log(` Found Last.fm image`); } } } catch (err) { // Last.fm failed, leave as null } } // Cache the result for 7 days if (coverArt) { try { await redisClient.setEx( cacheKey, 7 * 24 * 60 * 60, coverArt ); console.log(` Cached image for 7 days`); } catch (err) { // Redis errors are non-critical } } } return { ...item, coverArt, albumCount: albumCountMap.get(item.id) || 0, }; } }) ); res.json({ items: results }); } catch (error) { console.error("Get recently listened error:", error); res.status(500).json({ error: "Failed to fetch recently listened" }); } }); // GET /library/recently-added?limit=10 router.get("/recently-added", async (req, res) => { try { const { limit = "10" } = req.query; const limitNum = parseInt(limit as string, 10); // Get the 20 most recently added LIBRARY albums (by lastSynced timestamp) // This limits "Recently Added" to actual recent additions, not the entire library const recentAlbums = await prisma.album.findMany({ where: { location: "LIBRARY", tracks: { some: {} }, // Only albums with actual tracks }, orderBy: { lastSynced: "desc" }, take: 20, // Hard limit to last 20 albums include: { artist: { select: { id: true, mbid: true, name: true, heroUrl: true, }, }, }, }); // Extract unique artists from recent albums (preserving order of most recent) const artistsMap = new Map(); for (const album of recentAlbums) { if (!artistsMap.has(album.artist.id)) { artistsMap.set(album.artist.id, album.artist); } if (artistsMap.size >= limitNum) break; } // Get album counts for each artist (only LIBRARY albums) const artistIds = Array.from(artistsMap.keys()); const albumCounts = await prisma.album.groupBy({ by: ["artistId"], where: { artistId: { in: artistIds }, location: "LIBRARY", tracks: { some: {} }, }, _count: { id: true }, }); const albumCountMap = new Map( albumCounts.map((ac) => [ac.artistId, ac._count.id]) ); // ========== ON-DEMAND IMAGE FETCHING FOR RECENTLY ADDED ========== // For artists without heroUrl, fetch images on-demand const artistsWithImages = await Promise.all( Array.from(artistsMap.values()).map(async (artist) => { let coverArt = artist.heroUrl; if (!coverArt) { console.log( `[IMAGE] Fetching image on-demand for ${artist.name}...` ); // Check Redis cache first const cacheKey = `hero-image:${artist.id}`; try { const cached = await redisClient.get(cacheKey); if (cached) { coverArt = cached; console.log(` Found cached image`); } } catch (err) { // Redis errors are non-critical } // Try Fanart.tv if we have real MBID if ( !coverArt && artist.mbid && !artist.mbid.startsWith("temp-") ) { try { coverArt = await fanartService.getArtistImage( artist.mbid ); } catch (err) { // Fanart.tv failed, continue to next source } } // Fallback to Deezer if (!coverArt) { try { coverArt = await deezerService.getArtistImage( artist.name ); } catch (err) { // Deezer failed, continue to next source } } // Fallback to Last.fm if (!coverArt) { try { const validMbid = artist.mbid && !artist.mbid.startsWith("temp-") ? artist.mbid : undefined; const lastfmInfo = await lastFmService.getArtistInfo( artist.name, validMbid ); if ( lastfmInfo.image && lastfmInfo.image.length > 0 ) { const largestImage = lastfmInfo.image.find( (img: any) => img.size === "extralarge" || img.size === "mega" ) || lastfmInfo.image[ lastfmInfo.image.length - 1 ]; if (largestImage && largestImage["#text"]) { coverArt = largestImage["#text"]; console.log(` Found Last.fm image`); } } } catch (err) { // Last.fm failed, leave as null } } // Cache the result for 7 days if (coverArt) { try { await redisClient.setEx( cacheKey, 7 * 24 * 60 * 60, coverArt ); console.log(` Cached image for 7 days`); } catch (err) { // Redis errors are non-critical } } } return { ...artist, coverArt, albumCount: albumCountMap.get(artist.id) || 0, }; }) ); res.json({ artists: artistsWithImages }); } catch (error) { console.error("Get recently added error:", error); res.status(500).json({ error: "Failed to fetch recently added" }); } }); // GET /library/artists?query=&limit=&offset=&filter=owned|discovery|all router.get("/artists", async (req, res) => { try { const { query = "", limit: limitParam = "500", 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 offset = parseInt(offsetParam as string, 10) || 0; // Build where clause based on filter let where: any = { albums: { some: { tracks: { some: {} }, // Only artists with albums that have actual tracks }, }, }; if (filter === "owned") { // Artists with at least 1 LIBRARY album OR an OwnedAlbum record (liked discovery) where.OR = [ { albums: { some: { location: "LIBRARY", tracks: { some: {} }, }, }, }, { // Include artists with OwnedAlbum records (includes liked discovery albums) ownedAlbums: { some: {}, }, albums: { some: { tracks: { some: {} }, }, }, }, ]; } else if (filter === "discovery") { // Artists with ONLY DISCOVERY albums (no LIBRARY albums) where = { AND: [ { albums: { some: { location: "DISCOVER", tracks: { some: {} }, }, }, }, { albums: { none: { location: "LIBRARY", }, }, }, ], }; } // filter === "all" uses the default (any albums with tracks) if (query) { if (where.AND) { where.AND.push({ name: { contains: query as string, mode: "insensitive" }, }); } else { where.name = { contains: query as string, mode: "insensitive" }; } } // Determine which album location to count based on filter const albumLocationFilter = filter === "discovery" ? "DISCOVER" : filter === "all" ? undefined : "LIBRARY"; const [artistsWithAlbums, total] = await Promise.all([ prisma.artist.findMany({ where, skip: offset, take: limit, orderBy: { name: "asc" }, select: { id: true, mbid: true, name: true, heroUrl: true, albums: { where: { ...(albumLocationFilter ? { location: albumLocationFilter } : {}), tracks: { some: {} }, }, select: { id: true, }, }, }, }), prisma.artist.count({ where }), ]); // Use DataCacheService for batch image lookup (DB + Redis, no API calls for lists) const imageMap = await dataCacheService.getArtistImagesBatch( artistsWithAlbums.map((a) => ({ id: a.id, heroUrl: a.heroUrl })) ); const artistsWithImages = artistsWithAlbums.map((artist) => { const coverArt = imageMap.get(artist.id) || artist.heroUrl || null; return { id: artist.id, mbid: artist.mbid, name: artist.name, heroUrl: coverArt, coverArt, // Alias for frontend consistency albumCount: artist.albums.length, }; }); res.json({ artists: artistsWithImages, total, offset, limit, }); } catch (error: any) { console.error("[Library] Get artists error:", error?.message || error); console.error("[Library] Stack:", error?.stack); res.status(500).json({ error: "Failed to fetch artists", details: error?.message, }); } }); // GET /library/enrichment-diagnostics - Debug why artist images aren't populating router.get("/enrichment-diagnostics", async (req, res) => { try { // Get enrichment status breakdown const statusCounts = await prisma.artist.groupBy({ by: ["enrichmentStatus"], _count: true, }); // Get artists that completed enrichment but have no heroUrl const completedNoImage = await prisma.artist.count({ where: { enrichmentStatus: "completed", OR: [{ heroUrl: null }, { heroUrl: "" }], }, }); // Get artists with temp MBIDs (can't use Fanart.tv) const tempMbidCount = await prisma.artist.count({ where: { mbid: { startsWith: "temp-" }, }, }); // Sample of artists with issues const problemArtists = await prisma.artist.findMany({ where: { enrichmentStatus: "completed", OR: [{ heroUrl: null }, { heroUrl: "" }], }, select: { id: true, name: true, mbid: true, enrichmentStatus: true, lastEnriched: true, }, take: 10, }); // Sample of failed artists const failedArtists = await prisma.artist.findMany({ where: { enrichmentStatus: "failed", }, select: { id: true, name: true, mbid: true, lastEnriched: true, }, take: 10, }); res.json({ summary: { statusBreakdown: statusCounts.reduce((acc, s) => { acc[s.enrichmentStatus || "unknown"] = s._count; return acc; }, {} as Record), completedWithoutImage: completedNoImage, tempMbidArtists: tempMbidCount, }, problemArtists, failedArtists, suggestions: [ completedNoImage > 0 ? `${completedNoImage} artists completed enrichment but have no image - external APIs may be failing or rate limited` : null, tempMbidCount > 0 ? `${tempMbidCount} artists have temp MBIDs - Fanart.tv won't work for them, relies on Deezer/Last.fm` : null, statusCounts.find((s) => s.enrichmentStatus === "pending") ?._count ? "Enrichment still in progress - check logs" : null, statusCounts.find((s) => s.enrichmentStatus === "failed") ?._count ? "Some artists failed enrichment - may need retry" : null, ].filter(Boolean), }); } catch (error: any) { console.error( "[Library] Enrichment diagnostics error:", error?.message ); res.status(500).json({ error: "Failed to get diagnostics" }); } }); // POST /library/retry-enrichment - Retry failed enrichments router.post("/retry-enrichment", async (req, res) => { try { // Reset failed artists to pending so worker picks them up const result = await prisma.artist.updateMany({ where: { enrichmentStatus: "failed" }, data: { enrichmentStatus: "pending" }, }); res.json({ message: `Reset ${result.count} failed artists to pending`, count: result.count, }); } catch (error: any) { console.error("[Library] Retry enrichment error:", error?.message); res.status(500).json({ error: "Failed to retry enrichment" }); } }); // GET /library/artists/:id router.get("/artists/:id", async (req, res) => { try { const idParam = req.params.id; const artistInclude = { albums: { orderBy: { year: "desc" }, include: { tracks: { orderBy: { trackNo: "asc" }, take: 10, // Top tracks include: { album: { select: { id: true, title: true, coverUrl: true, }, }, }, }, }, }, ownedAlbums: true, // Note: similarFrom (FK-based) is no longer used for display // We now use similarArtistsJson which is fetched by default }; // Try finding by ID first let artist = await prisma.artist.findUnique({ where: { id: idParam }, include: artistInclude, }); // If not found by ID, try by name (for URL-encoded names) if (!artist) { const decodedName = decodeURIComponent(idParam); artist = await prisma.artist.findFirst({ where: { name: { equals: decodedName, mode: "insensitive", }, }, include: artistInclude, }); } // If not found and param looks like an MBID, try looking up by MBID if ( !artist && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( idParam ) ) { artist = await prisma.artist.findFirst({ where: { mbid: idParam }, include: artistInclude, }); } if (!artist) { return res.status(404).json({ error: "Artist not found" }); } // ========== DISCOGRAPHY HANDLING ========== // For enriched artists with ownedAlbums, skip expensive MusicBrainz calls // Only fetch from MusicBrainz if the artist hasn't been enriched yet let albumsWithOwnership = []; const ownedRgMbids = new Set(artist.ownedAlbums.map((o) => o.rgMbid)); const isEnriched = artist.ownedAlbums.length > 0 || artist.heroUrl !== null; // If artist has temp MBID, try to find real MBID by searching MusicBrainz let effectiveMbid = artist.mbid; if (!effectiveMbid || effectiveMbid.startsWith("temp-")) { console.log( ` Artist has temp/no MBID, searching MusicBrainz for ${artist.name}...` ); try { const searchResults = await musicBrainzService.searchArtist( artist.name, 1 ); if (searchResults.length > 0) { effectiveMbid = searchResults[0].id; console.log(` Found MBID: ${effectiveMbid}`); // Update database with real MBID for future use (skip if duplicate) try { await prisma.artist.update({ where: { id: artist.id }, data: { mbid: effectiveMbid }, }); } catch (mbidError: any) { // If MBID already exists for another artist, just log and continue if (mbidError.code === "P2002") { console.log( `MBID ${effectiveMbid} already exists for another artist, skipping update` ); } else { console.error( ` ✗ Failed to update MBID:`, mbidError ); } } } else { console.log( ` ✗ No MusicBrainz match found for ${artist.name}` ); } } catch (error) { console.error(` ✗ MusicBrainz search failed:`, error); } } // ========== ALWAYS include albums from database (actual owned files) ========== // These are albums with actual tracks on disk - they MUST show as owned const dbAlbums = artist.albums.map((album) => ({ ...album, owned: true, // If it's in the database with tracks, user owns it! coverArt: album.coverUrl, source: "database" as const, })); console.log( `[Artist] Found ${dbAlbums.length} albums from database (actual owned files)` ); // ========== Supplement with MusicBrainz discography for "available to download" ========== // Always fetch discography if we have a valid MBID - users need to see what's available const hasDbAlbums = dbAlbums.length > 0; const shouldFetchDiscography = effectiveMbid && !effectiveMbid.startsWith("temp-"); if (shouldFetchDiscography) { try { // Check Redis cache first (cache for 24 hours) const discoCacheKey = `discography:${effectiveMbid}`; let releaseGroups: any[] = []; const cachedDisco = await redisClient.get(discoCacheKey); if (cachedDisco && cachedDisco !== "NOT_FOUND") { releaseGroups = JSON.parse(cachedDisco); console.log( `[Artist] Using cached discography (${releaseGroups.length} albums)` ); } else { console.log( `[Artist] Fetching discography from MusicBrainz...` ); releaseGroups = await musicBrainzService.getReleaseGroups( effectiveMbid, ["album", "ep"], 100 ); // Cache for 24 hours await redisClient.setEx( discoCacheKey, 24 * 60 * 60, JSON.stringify(releaseGroups) ); } console.log( ` Got ${releaseGroups.length} albums from MusicBrainz (before filtering)` ); // Filter out live albums, compilations, soundtracks, remixes, etc. const excludedSecondaryTypes = [ "Live", "Compilation", "Soundtrack", "Remix", "DJ-mix", "Mixtape/Street", "Demo", "Interview", "Audio drama", "Audiobook", "Spokenword", ]; const filteredReleaseGroups = releaseGroups.filter( (rg: any) => { // Keep if no secondary types (pure studio album/EP) if ( !rg["secondary-types"] || rg["secondary-types"].length === 0 ) { return true; } // Exclude if any secondary type matches our exclusion list return !rg["secondary-types"].some((type: string) => excludedSecondaryTypes.includes(type) ); } ); console.log( ` Filtered to ${filteredReleaseGroups.length} studio albums/EPs` ); // Transform MusicBrainz release groups to album format // PERFORMANCE: Only check Redis cache for covers, don't make API calls // This makes artist pages load instantly after the first visit const mbAlbums = await Promise.all( filteredReleaseGroups.map(async (rg: any) => { let coverUrl = null; // Only check Redis cache - don't make external API calls // Covers will be fetched lazily by the frontend or during enrichment const cacheKey = `caa:${rg.id}`; try { const cached = await redisClient.get(cacheKey); if (cached && cached !== "NOT_FOUND") { coverUrl = cached; } } catch (err) { // Redis error, continue without cover } return { id: rg.id, rgMbid: rg.id, title: rg.title, year: rg["first-release-date"] ? parseInt( rg["first-release-date"].substring(0, 4) ) : null, type: rg["primary-type"], coverUrl, coverArt: coverUrl, artistId: artist.id, owned: ownedRgMbids.has(rg.id), trackCount: 0, tracks: [], source: "musicbrainz" as const, }; }) ); // Merge database albums with MusicBrainz albums // Database albums take precedence (they have actual files!) const dbAlbumTitles = new Set( dbAlbums.map((a) => a.title.toLowerCase()) ); const mbAlbumsFiltered = mbAlbums.filter( (a) => !dbAlbumTitles.has(a.title.toLowerCase()) ); albumsWithOwnership = [...dbAlbums, ...mbAlbumsFiltered]; console.log( ` Total albums: ${albumsWithOwnership.length} (${dbAlbums.length} owned from database, ${mbAlbumsFiltered.length} from MusicBrainz)` ); console.log( ` Owned: ${ albumsWithOwnership.filter((a) => a.owned).length }, Available: ${ albumsWithOwnership.filter((a) => !a.owned).length }` ); } catch (error) { console.error( `Failed to fetch MusicBrainz discography:`, error ); // Just use database albums albumsWithOwnership = dbAlbums; } } else { // No valid MBID - just use database albums console.log( `[Artist] No valid MBID, using ${dbAlbums.length} albums from database` ); albumsWithOwnership = dbAlbums; } // Extract top tracks from library first const allTracks = artist.albums.flatMap((a) => a.tracks); let topTracks = allTracks .sort((a, b) => (b.playCount || 0) - (a.playCount || 0)) .slice(0, 10); // Get user play counts for all tracks const userId = req.user!.id; const trackIds = allTracks.map((t) => t.id); const userPlays = await prisma.play.groupBy({ by: ["trackId"], where: { userId, trackId: { in: trackIds }, }, _count: { id: true, }, }); const userPlayCounts = new Map( userPlays.map((p) => [p.trackId, p._count.id]) ); // Fetch Last.fm top tracks (cached for 24 hours) const topTracksCacheKey = `top-tracks:${artist.id}`; try { // Check cache first const cachedTopTracks = await redisClient.get(topTracksCacheKey); let lastfmTopTracks: any[] = []; if (cachedTopTracks && cachedTopTracks !== "NOT_FOUND") { lastfmTopTracks = JSON.parse(cachedTopTracks); console.log( `[Artist] Using cached top tracks (${lastfmTopTracks.length})` ); } else { // Cache miss - fetch from Last.fm const validMbid = effectiveMbid && !effectiveMbid.startsWith("temp-") ? effectiveMbid : ""; lastfmTopTracks = await lastFmService.getArtistTopTracks( validMbid, artist.name, 10 ); // Cache for 24 hours await redisClient.setEx( topTracksCacheKey, 24 * 60 * 60, JSON.stringify(lastfmTopTracks) ); console.log( `[Artist] Cached ${lastfmTopTracks.length} top tracks` ); } // For each Last.fm track, try to match with library track or add as unowned const combinedTracks: any[] = []; for (const lfmTrack of lastfmTopTracks) { // Try to find matching track in library const matchedTrack = allTracks.find( (t) => t.title.toLowerCase() === lfmTrack.name.toLowerCase() ); if (matchedTrack) { // Track exists in library - include user play count combinedTracks.push({ ...matchedTrack, playCount: lfmTrack.playcount ? parseInt(lfmTrack.playcount) : matchedTrack.playCount, listeners: lfmTrack.listeners ? parseInt(lfmTrack.listeners) : 0, userPlayCount: userPlayCounts.get(matchedTrack.id) || 0, album: { ...matchedTrack.album, coverArt: matchedTrack.album.coverUrl, }, }); } else { // Track NOT in library - add as preview-only track combinedTracks.push({ id: `lastfm-${artist.mbid || artist.name}-${ lfmTrack.name }`, title: lfmTrack.name, playCount: lfmTrack.playcount ? parseInt(lfmTrack.playcount) : 0, listeners: lfmTrack.listeners ? parseInt(lfmTrack.listeners) : 0, duration: lfmTrack.duration ? Math.floor(parseInt(lfmTrack.duration) / 1000) : 0, url: lfmTrack.url, album: { title: lfmTrack.album?.["#text"] || "Unknown Album", }, userPlayCount: 0, // NO album.id - this indicates track is not in library }); } } topTracks = combinedTracks.slice(0, 10); } catch (error) { console.error( `Failed to get Last.fm top tracks for ${artist.name}:`, error ); // If Last.fm fails, add user play counts to library tracks topTracks = topTracks.map((t) => ({ ...t, userPlayCount: userPlayCounts.get(t.id) || 0, album: { ...t.album, coverArt: t.album.coverUrl, }, })); } // ========== HERO IMAGE FETCHING ========== // Use DataCacheService: DB -> Redis -> API -> save to both const heroUrl = await dataCacheService.getArtistImage( artist.id, artist.name, effectiveMbid ); // ========== SIMILAR ARTISTS (from enriched JSON or Last.fm API) ========== let similarArtists: any[] = []; const similarCacheKey = `similar-artists:${artist.id}`; // Check if artist has pre-enriched similar artists JSON (full Last.fm data) const enrichedSimilar = artist.similarArtistsJson as Array<{ name: string; mbid: string | null; match: number; }> | null; if (enrichedSimilar && enrichedSimilar.length > 0) { // Use pre-enriched data from database (fast path) console.log( `[Artist] Using ${enrichedSimilar.length} similar artists from enriched JSON` ); // First, batch lookup which similar artists exist in our library const similarNames = enrichedSimilar.slice(0, 10).map((s) => s.name.toLowerCase()); const similarMbids = enrichedSimilar.slice(0, 10).map((s) => s.mbid).filter(Boolean) as string[]; // Find library artists matching by name or mbid const libraryMatches = await prisma.artist.findMany({ where: { OR: [ { normalizedName: { in: similarNames } }, ...(similarMbids.length > 0 ? [{ mbid: { in: similarMbids } }] : []), ], }, select: { id: true, name: true, normalizedName: true, mbid: true, heroUrl: true, _count: { select: { albums: { where: { location: "LIBRARY", tracks: { some: {} } }, }, }, }, }, }); // Create lookup maps for quick matching const libraryByName = new Map(libraryMatches.map((a) => [a.normalizedName?.toLowerCase() || a.name.toLowerCase(), a])); const libraryByMbid = new Map(libraryMatches.filter((a) => a.mbid).map((a) => [a.mbid!, a])); // Fetch images in parallel from Deezer (cached in Redis) const similarWithImages = await Promise.all( enrichedSimilar.slice(0, 10).map(async (s) => { // Check if this artist is in our library const libraryArtist = (s.mbid && libraryByMbid.get(s.mbid)) || libraryByName.get(s.name.toLowerCase()); let image = libraryArtist?.heroUrl || null; // If no library image, try Deezer if (!image) { try { // Check Redis cache first const cacheKey = `deezer-artist-image:${s.name}`; const cached = await redisClient.get(cacheKey); if (cached && cached !== "NOT_FOUND") { image = cached; } else { image = await deezerService.getArtistImage(s.name); if (image) { await redisClient.setEx( cacheKey, 24 * 60 * 60, image ); } } } catch (err) { // Deezer failed, leave null } } return { id: libraryArtist?.id || s.name, name: s.name, mbid: s.mbid || null, coverArt: image, albumCount: 0, // Would require MusicBrainz lookup - skip for performance ownedAlbumCount: libraryArtist?._count?.albums || 0, weight: s.match, inLibrary: !!libraryArtist, }; }) ); similarArtists = similarWithImages; } else { // No enriched data - fetch from Last.fm API with Redis cache const cachedSimilar = await redisClient.get(similarCacheKey); if (cachedSimilar && cachedSimilar !== "NOT_FOUND") { similarArtists = JSON.parse(cachedSimilar); console.log( `[Artist] Using cached similar artists (${similarArtists.length})` ); } else { // Cache miss - fetch from Last.fm console.log( `[Artist] Fetching similar artists from Last.fm...` ); try { const validMbid = effectiveMbid && !effectiveMbid.startsWith("temp-") ? effectiveMbid : undefined; const lastfmSimilar = await lastFmService.getSimilarArtists( validMbid, artist.name, 10 ); // Batch lookup which similar artists exist in our library const similarNames = lastfmSimilar.map((s: any) => s.name.toLowerCase()); const similarMbids = lastfmSimilar.map((s: any) => s.mbid).filter(Boolean) as string[]; const libraryMatches = await prisma.artist.findMany({ where: { OR: [ { normalizedName: { in: similarNames } }, ...(similarMbids.length > 0 ? [{ mbid: { in: similarMbids } }] : []), ], }, select: { id: true, name: true, normalizedName: true, mbid: true, heroUrl: true, _count: { select: { albums: { where: { location: "LIBRARY", tracks: { some: {} } }, }, }, }, }, }); const libraryByName = new Map(libraryMatches.map((a) => [a.normalizedName?.toLowerCase() || a.name.toLowerCase(), a])); const libraryByMbid = new Map(libraryMatches.filter((a) => a.mbid).map((a) => [a.mbid!, a])); // Fetch images in parallel (Deezer only - fastest source) const similarWithImages = await Promise.all( lastfmSimilar.map(async (s: any) => { const libraryArtist = (s.mbid && libraryByMbid.get(s.mbid)) || libraryByName.get(s.name.toLowerCase()); let image = libraryArtist?.heroUrl || null; if (!image) { try { image = await deezerService.getArtistImage( s.name ); } catch (err) { // Deezer failed, leave null } } return { id: libraryArtist?.id || s.name, name: s.name, mbid: s.mbid || null, coverArt: image, albumCount: 0, ownedAlbumCount: libraryArtist?._count?.albums || 0, weight: s.match, inLibrary: !!libraryArtist, }; }) ); similarArtists = similarWithImages; // Cache for 24 hours await redisClient.setEx( similarCacheKey, 24 * 60 * 60, JSON.stringify(similarArtists) ); console.log( `[Artist] Cached ${similarArtists.length} similar artists` ); } catch (error) { console.error( `[Artist] Failed to fetch similar artists:`, error ); similarArtists = []; } } } res.json({ ...artist, coverArt: heroUrl, // Use fetched hero image (falls back to artist.heroUrl) albums: albumsWithOwnership, topTracks, similarArtists, }); } catch (error) { console.error("Get artist error:", error); res.status(500).json({ error: "Failed to fetch artist" }); } }); // GET /library/albums?artistId=&limit=&offset=&filter=owned|discovery|all router.get("/albums", async (req, res) => { try { const { artistId, limit: limitParam = "500", 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 offset = parseInt(offsetParam as string, 10) || 0; let where: any = { tracks: { some: {} }, // Only albums with tracks }; // Apply location filter if (filter === "owned") { // Get all owned album rgMbids (includes liked discovery albums) const ownedAlbumMbids = await prisma.ownedAlbum.findMany({ select: { rgMbid: true }, }); const ownedMbids = ownedAlbumMbids.map((oa) => oa.rgMbid); // Albums with LIBRARY location OR rgMbid in OwnedAlbum where.OR = [ { location: "LIBRARY", tracks: { some: {} } }, { rgMbid: { in: ownedMbids }, tracks: { some: {} } }, ]; } else if (filter === "discovery") { where.location = "DISCOVER"; } // filter === "all" shows all locations // If artistId is provided, filter by artist if (artistId) { if (where.OR) { // If we have OR conditions, wrap with AND where = { AND: [{ OR: where.OR }, { artistId: artistId as string }], }; } else { where.artistId = artistId as string; } } const [albumsData, total] = await Promise.all([ prisma.album.findMany({ where, skip: offset, take: limit, orderBy: { year: "desc" }, include: { artist: { select: { id: true, mbid: true, name: true, }, }, }, }), prisma.album.count({ where }), ]); // Normalize coverArt field for frontend const albums = albumsData.map((album) => ({ ...album, coverArt: album.coverUrl, })); res.json({ albums, total, offset, limit, }); } catch (error: any) { console.error("[Library] Get albums error:", error?.message || error); console.error("[Library] Stack:", error?.stack); res.status(500).json({ error: "Failed to fetch albums", details: error?.message, }); } }); // GET /library/albums/:id router.get("/albums/:id", async (req, res) => { try { const idParam = req.params.id; // Try finding by ID first let album = await prisma.album.findUnique({ where: { id: idParam }, include: { artist: { select: { id: true, mbid: true, name: true, }, }, tracks: { orderBy: { trackNo: "asc" }, }, }, }); // If not found by ID, try by rgMbid (for discovery albums) if (!album) { album = await prisma.album.findFirst({ where: { rgMbid: idParam }, include: { artist: { select: { id: true, mbid: true, name: true, }, }, tracks: { orderBy: { trackNo: "asc" }, }, }, }); } if (!album) { return res.status(404).json({ error: "Album not found" }); } // Check ownership const owned = await prisma.ownedAlbum.findUnique({ where: { artistId_rgMbid: { artistId: album.artistId, rgMbid: album.rgMbid, }, }, }); res.json({ ...album, owned: !!owned, coverArt: album.coverUrl, }); } catch (error) { console.error("Get album error:", error); res.status(500).json({ error: "Failed to fetch album" }); } }); // GET /library/tracks?albumId=&limit=100 router.get("/tracks", async (req, res) => { try { const { albumId, limit = "100" } = req.query; const limitNum = parseInt(limit as string, 10); 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, }, }, }, }, }, }); // Add coverArt field to albums const tracks = tracksData.map((track) => ({ ...track, album: { ...track.album, coverArt: track.album.coverUrl, }, })); res.json({ tracks }); } catch (error) { console.error("Get tracks error:", error); res.status(500).json({ error: "Failed to fetch 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) => { try { const { size, url } = req.query; let coverUrl: string; let isAudiobook = false; // Check if a full URL was provided as a query parameter if (url) { const decodedUrl = decodeURIComponent(url as string); // Check if this is an audiobook cover (prefixed with "audiobook__") if (decodedUrl.startsWith("audiobook__")) { isAudiobook = true; const audiobookPath = decodedUrl.replace("audiobook__", ""); // Get Audiobookshelf settings const settings = await getSystemSettings(); const audiobookshelfUrl = settings?.audiobookshelfUrl || process.env.AUDIOBOOKSHELF_URL || ""; const audiobookshelfApiKey = settings?.audiobookshelfApiKey || process.env.AUDIOBOOKSHELF_API_KEY || ""; const audiobookshelfBaseUrl = audiobookshelfUrl.replace( /\/$/, "" ); coverUrl = `${audiobookshelfBaseUrl}/api/${audiobookPath}`; // Fetch with authentication console.log( `[COVER-ART] Fetching audiobook cover: ${coverUrl.substring( 0, 100 )}...` ); const imageResponse = await fetch(coverUrl, { headers: { Authorization: `Bearer ${audiobookshelfApiKey}`, "User-Agent": "Lidify/1.0", }, }); if (!imageResponse.ok) { console.error( `[COVER-ART] Failed to fetch audiobook cover: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})` ); return res .status(404) .json({ error: "Audiobook cover art not found" }); } const buffer = await imageResponse.arrayBuffer(); const imageBuffer = Buffer.from(buffer); const contentType = imageResponse.headers.get("content-type"); if (contentType) { res.setHeader("Content-Type", contentType); } applyCoverArtCorsHeaders( res, req.headers.origin as string | undefined ); res.setHeader( "Cache-Control", "public, max-age=31536000, immutable" ); return res.send(imageBuffer); } // Check if this is a native cover (prefixed with "native:") if (decodedUrl.startsWith("native:")) { const nativePath = decodedUrl.replace("native:", ""); const coverCachePath = path.join( config.music.transcodeCachePath, "../covers", nativePath ); console.log( `[COVER-ART] Serving native cover: ${coverCachePath}` ); // Check if file exists if (!fs.existsSync(coverCachePath)) { console.error( `[COVER-ART] Native cover not found: ${coverCachePath}` ); return res .status(404) .json({ error: "Cover art not found" }); } // Serve the file directly const requestOrigin = req.headers.origin; const headers: Record = { "Content-Type": "image/jpeg", // Assume JPEG for now "Cache-Control": "public, max-age=31536000, immutable", "Cross-Origin-Resource-Policy": "cross-origin", }; if (requestOrigin) { headers["Access-Control-Allow-Origin"] = requestOrigin; headers["Access-Control-Allow-Credentials"] = "true"; } else { headers["Access-Control-Allow-Origin"] = "*"; } return res.sendFile(coverCachePath, { headers, }); } coverUrl = decodedUrl; } else { // Otherwise use the ID from the path parameter const coverId = req.params.id; if (!coverId) { return res .status(400) .json({ error: "No cover ID or URL provided" }); } const decodedId = decodeURIComponent(coverId); // Check if this is a native cover (prefixed with "native:") if (decodedId.startsWith("native:")) { const nativePath = decodedId.replace("native:", ""); const coverCachePath = path.join( config.music.transcodeCachePath, "../covers", nativePath ); // Check if file exists if (fs.existsSync(coverCachePath)) { // Serve the file directly const requestOrigin = req.headers.origin; const headers: Record = { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", "Cross-Origin-Resource-Policy": "cross-origin", }; if (requestOrigin) { headers["Access-Control-Allow-Origin"] = requestOrigin; headers["Access-Control-Allow-Credentials"] = "true"; } else { headers["Access-Control-Allow-Origin"] = "*"; } return res.sendFile(coverCachePath, { headers, }); } // Native cover file missing - try to find album and fetch from Deezer console.warn( `[COVER-ART] Native cover not found: ${coverCachePath}, trying Deezer fallback` ); // Extract album ID from the path (format: albumId.jpg) const albumId = nativePath.replace(".jpg", ""); try { const album = await prisma.album.findUnique({ where: { id: albumId }, include: { artist: true }, }); if (album && album.artist) { const deezerCover = await deezerService.getAlbumCover( album.artist.name, album.title ); if (deezerCover) { // Update album with Deezer cover await prisma.album.update({ where: { id: albumId }, data: { coverUrl: deezerCover }, }); // Redirect to the Deezer cover return res.redirect(deezerCover); } } } catch (error) { console.error( `[COVER-ART] Failed to fetch Deezer fallback for ${albumId}:`, error ); } return res.status(404).json({ error: "Cover art not found" }); } // Check if this is an audiobook cover (prefixed with "audiobook__") if (decodedId.startsWith("audiobook__")) { isAudiobook = true; const audiobookPath = decodedId.replace("audiobook__", ""); // Get Audiobookshelf settings const settings = await getSystemSettings(); const audiobookshelfUrl = settings?.audiobookshelfUrl || process.env.AUDIOBOOKSHELF_URL || ""; const audiobookshelfApiKey = settings?.audiobookshelfApiKey || process.env.AUDIOBOOKSHELF_API_KEY || ""; const audiobookshelfBaseUrl = audiobookshelfUrl.replace( /\/$/, "" ); coverUrl = `${audiobookshelfBaseUrl}/api/${audiobookPath}`; // Fetch with authentication console.log( `[COVER-ART] Fetching audiobook cover: ${coverUrl.substring( 0, 100 )}...` ); const imageResponse = await fetch(coverUrl, { headers: { Authorization: `Bearer ${audiobookshelfApiKey}`, "User-Agent": "Lidify/1.0", }, }); if (!imageResponse.ok) { console.error( `[COVER-ART] Failed to fetch audiobook cover: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})` ); return res .status(404) .json({ error: "Audiobook cover art not found" }); } const buffer = await imageResponse.arrayBuffer(); const imageBuffer = Buffer.from(buffer); const contentType = imageResponse.headers.get("content-type"); if (contentType) { res.setHeader("Content-Type", contentType); } applyCoverArtCorsHeaders( res, req.headers.origin as string | undefined ); res.setHeader( "Cache-Control", "public, max-age=31536000, immutable" ); return res.send(imageBuffer); } // Check if coverId is already a full URL (from Cover Art Archive or elsewhere) else if ( decodedId.startsWith("http://") || decodedId.startsWith("https://") ) { coverUrl = decodedId; } else { // Invalid cover ID format return res .status(400) .json({ error: "Invalid cover ID format" }); } } // Create cache key from URL + size const cacheKey = `cover-art:${crypto .createHash("md5") .update(`${coverUrl}-${size || "original"}`) .digest("hex")}`; // Try to get from Redis cache first try { const cached = await redisClient.get(cacheKey); if (cached) { const cachedData = JSON.parse(cached); // Check if this is a cached 404 if (cachedData.notFound) { console.log( `[COVER-ART] Cached 404 for ${coverUrl.substring( 0, 60 )}...` ); return res .status(404) .json({ error: "Cover art not found" }); } console.log( `[COVER-ART] Cache HIT for ${coverUrl.substring(0, 60)}...` ); const imageBuffer = Buffer.from(cachedData.data, "base64"); // Check if client has cached version if (req.headers["if-none-match"] === cachedData.etag) { console.log(`[COVER-ART] Client has cached version (304)`); return res.status(304).end(); } // Set headers and send cached image if (cachedData.contentType) { res.setHeader("Content-Type", cachedData.contentType); } applyCoverArtCorsHeaders( res, req.headers.origin as string | undefined ); res.setHeader( "Cache-Control", "public, max-age=31536000, immutable" ); res.setHeader("ETag", cachedData.etag); return res.send(imageBuffer); } else { console.log( `[COVER-ART] ✗ Cache MISS for ${coverUrl.substring( 0, 60 )}...` ); } } catch (cacheError) { console.warn("[COVER-ART] Redis cache read error:", cacheError); } // Fetch the image and proxy it to avoid CORS issues console.log(`[COVER-ART] Fetching: ${coverUrl.substring(0, 100)}...`); const imageResponse = await fetch(coverUrl, { headers: { "User-Agent": "Lidify/1.0", }, }); if (!imageResponse.ok) { console.error( `[COVER-ART] Failed to fetch: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})` ); // Cache 404s for 1 hour to avoid repeatedly trying to fetch missing images if (imageResponse.status === 404) { try { await redisClient.setEx( cacheKey, 60 * 60, // 1 hour JSON.stringify({ notFound: true }) ); console.log(`[COVER-ART] Cached 404 response for 1 hour`); } catch (cacheError) { console.warn( "[COVER-ART] Redis cache write error:", cacheError ); } } return res.status(404).json({ error: "Cover art not found" }); } console.log(`[COVER-ART] Successfully fetched, caching...`); const buffer = await imageResponse.arrayBuffer(); const imageBuffer = Buffer.from(buffer); // Generate ETag from content const etag = crypto.createHash("md5").update(imageBuffer).digest("hex"); // Cache in Redis for 7 days try { const contentType = imageResponse.headers.get("content-type"); await redisClient.setEx( cacheKey, 7 * 24 * 60 * 60, // 7 days JSON.stringify({ etag, contentType, data: imageBuffer.toString("base64"), }) ); } catch (cacheError) { console.warn("Redis cache write error:", cacheError); } // Check if client has cached version if (req.headers["if-none-match"] === etag) { return res.status(304).end(); } // Set appropriate headers const contentType = imageResponse.headers.get("content-type"); if (contentType) { res.setHeader("Content-Type", contentType); } // Set aggressive caching headers applyCoverArtCorsHeaders(res, req.headers.origin as string | undefined); res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); // Cache for 1 year res.setHeader("ETag", etag); // Send the image res.send(imageBuffer); } catch (error) { console.error("Get cover art error:", error); res.status(500).json({ error: "Failed to fetch cover art" }); } }); // GET /library/album-cover/:mbid - Fetch and cache album cover by MBID // This is called lazily by the frontend when an album doesn't have a cached cover router.get("/album-cover/:mbid", imageLimiter, async (req, res) => { try { const { mbid } = req.params; if (!mbid || mbid.startsWith("temp-")) { return res.status(400).json({ error: "Valid MBID required" }); } // Fetch from Cover Art Archive (this uses caching internally) const coverUrl = await coverArtService.getCoverArt(mbid); if (!coverUrl) { // Return 204 No Content instead of 404 to avoid console spam // Cover Art Archive doesn't have covers for all albums return res.status(204).send(); } res.json({ coverUrl }); } catch (error) { console.error("Get album cover error:", error); res.status(500).json({ error: "Failed to fetch cover art" }); } }); // GET /library/cover-art-colors?url= - Extract colors from a cover art URL router.get("/cover-art-colors", imageLimiter, async (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: "URL parameter required" }); } const imageUrl = decodeURIComponent(url as string); // Handle placeholder images - return default fallback colors if ( imageUrl.includes("placeholder") || imageUrl.startsWith("/placeholder") ) { console.log( `[COLORS] Placeholder image detected, returning fallback colors` ); return res.json({ vibrant: "#1db954", darkVibrant: "#121212", lightVibrant: "#181818", muted: "#535353", darkMuted: "#121212", lightMuted: "#b3b3b3", }); } // Create cache key for colors const cacheKey = `colors:${crypto .createHash("md5") .update(imageUrl) .digest("hex")}`; // Try to get from Redis cache first try { const cached = await redisClient.get(cacheKey); if (cached) { console.log( `[COLORS] Cache HIT for ${imageUrl.substring(0, 60)}...` ); return res.json(JSON.parse(cached)); } else { console.log( `[COLORS] ✗ Cache MISS for ${imageUrl.substring(0, 60)}...` ); } } catch (cacheError) { console.warn("[COLORS] Redis cache read error:", cacheError); } // Fetch the image console.log( `[COLORS] Fetching image: ${imageUrl.substring(0, 100)}...` ); const imageResponse = await fetch(imageUrl, { headers: { "User-Agent": "Lidify/1.0", }, }); if (!imageResponse.ok) { console.error( `[COLORS] Failed to fetch image: ${imageUrl} (${imageResponse.status})` ); return res.status(404).json({ error: "Image not found" }); } const buffer = await imageResponse.arrayBuffer(); const imageBuffer = Buffer.from(buffer); // Extract colors using sharp const colors = await extractColorsFromImage(imageBuffer); console.log(`[COLORS] Extracted colors:`, colors); // Cache the result for 30 days try { await redisClient.setEx( cacheKey, 30 * 24 * 60 * 60, // 30 days JSON.stringify(colors) ); console.log(`[COLORS] Cached colors for 30 days`); } catch (cacheError) { console.warn("[COLORS] Redis cache write error:", cacheError); } res.json(colors); } catch (error) { console.error("Extract colors error:", error); res.status(500).json({ error: "Failed to extract colors" }); } }); // GET /library/tracks/:id/stream router.get("/tracks/:id/stream", async (req, res) => { try { console.log("[STREAM] Request received for track:", req.params.id); const { quality } = req.query; const userId = req.user?.id; if (!userId) { console.log("[STREAM] No userId in session - unauthorized"); return res.status(401).json({ error: "Unauthorized" }); } const track = await prisma.track.findUnique({ where: { id: req.params.id }, }); if (!track) { console.log("[STREAM] Track not found"); return res.status(404).json({ error: "Track not found" }); } // Log play start - only if this is a new playback session const recentPlay = await prisma.play.findFirst({ where: { userId, trackId: track.id, playedAt: { gte: new Date(Date.now() - 30 * 1000), }, }, orderBy: { playedAt: "desc" }, }); if (!recentPlay) { await prisma.play.create({ data: { userId, trackId: track.id, }, }); console.log("[STREAM] Logged new play for track:", track.title); } // Get user's quality preference let requestedQuality: string = "medium"; if (quality) { requestedQuality = quality as string; } else { const settings = await prisma.userSettings.findUnique({ where: { userId }, }); requestedQuality = settings?.playbackQuality || "medium"; } const ext = track.filePath ? path.extname(track.filePath).toLowerCase() : ""; console.log( `[STREAM] Quality: requested=${ quality || "default" }, using=${requestedQuality}, format=${ext}` ); // === NATIVE FILE STREAMING === // Check if track has native file path if (track.filePath && track.fileModified) { try { // Initialize streaming service const streamingService = new AudioStreamingService( config.music.musicPath, config.music.transcodeCachePath, config.music.transcodeCacheMaxGb ); // Get absolute path to source file // Normalize path separators for cross-platform compatibility (Windows -> Linux) const normalizedFilePath = track.filePath.replace(/\\/g, "/"); const absolutePath = path.join( config.music.musicPath, normalizedFilePath ); console.log( `[STREAM] Using native file: ${track.filePath} (${requestedQuality})` ); // Get stream file (either original or transcoded) const { filePath, mimeType } = await streamingService.getStreamFilePath( track.id, requestedQuality as any, track.fileModified, absolutePath ); // Stream file with range support console.log( `[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}` ); res.sendFile( filePath, { headers: { "Content-Type": mimeType, "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=31536000", "Access-Control-Allow-Origin": req.headers.origin || "*", "Access-Control-Allow-Credentials": "true", "Cross-Origin-Resource-Policy": "cross-origin", }, }, (err) => { // Always destroy the streaming service to clean up intervals streamingService.destroy(); if (err) { console.error(`[STREAM] sendFile error:`, err); } else { console.log( `[STREAM] File sent successfully: ${path.basename( filePath )}` ); } } ); return; } catch (err: any) { // If FFmpeg not found, try original quality instead if ( err.code === "FFMPEG_NOT_FOUND" && requestedQuality !== "original" ) { console.warn( `[STREAM] FFmpeg not available, falling back to original quality` ); const fallbackFilePath = track.filePath.replace(/\\/g, "/"); const absolutePath = path.join( config.music.musicPath, fallbackFilePath ); const streamingService = new AudioStreamingService( config.music.musicPath, config.music.transcodeCachePath, config.music.transcodeCacheMaxGb ); const { filePath, mimeType } = await streamingService.getStreamFilePath( track.id, "original", track.fileModified, absolutePath ); res.sendFile( filePath, { headers: { "Content-Type": mimeType, "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=31536000", "Access-Control-Allow-Origin": req.headers.origin || "*", "Access-Control-Allow-Credentials": "true", "Cross-Origin-Resource-Policy": "cross-origin", }, }, (err) => { // Always destroy the streaming service to clean up intervals streamingService.destroy(); if (err) { console.error( `[STREAM] sendFile fallback error:`, err ); } } ); return; } console.error("[STREAM] Native streaming failed:", err.message); return res .status(500) .json({ error: "Failed to stream track" }); } } // No file path available console.log("[STREAM] Track has no file path - unavailable"); return res.status(404).json({ error: "Track not available" }); } catch (error) { console.error("Stream track error:", error); res.status(500).json({ error: "Failed to stream track" }); } }); // GET /library/tracks/:id router.get("/tracks/:id", async (req, res) => { try { const track = await prisma.track.findUnique({ where: { id: req.params.id }, include: { album: { include: { artist: { select: { id: true, name: true, }, }, }, }, }, }); if (!track) { return res.status(404).json({ error: "Track not found" }); } // Transform to match frontend Track interface: artist at top level const formattedTrack = { id: track.id, title: track.title, artist: { name: track.album?.artist?.name || "Unknown Artist", id: track.album?.artist?.id, }, album: { title: track.album?.title || "Unknown Album", coverArt: track.album?.coverUrl, id: track.album?.id, }, duration: track.duration, }; res.json(formattedTrack); } catch (error) { console.error("Get track error:", error); res.status(500).json({ error: "Failed to fetch track" }); } }); // DELETE /library/tracks/:id router.delete("/tracks/:id", async (req, res) => { try { const track = await prisma.track.findUnique({ where: { id: req.params.id }, include: { album: { include: { artist: true, }, }, }, }); if (!track) { return res.status(404).json({ error: "Track not found" }); } // Delete file from filesystem if path is available if (track.filePath) { try { const absolutePath = path.join( config.music.musicPath, track.filePath ); if (fs.existsSync(absolutePath)) { fs.unlinkSync(absolutePath); console.log(`[DELETE] Deleted file: ${absolutePath}`); } } catch (err) { console.warn("[DELETE] Could not delete file:", err); // Continue with database deletion even if file deletion fails } } // Delete from database (cascade will handle related records) await prisma.track.delete({ where: { id: track.id }, }); console.log(`[DELETE] Deleted track: ${track.title}`); res.json({ message: "Track deleted successfully" }); } catch (error) { console.error("Delete track error:", error); res.status(500).json({ error: "Failed to delete track" }); } }); // DELETE /library/albums/:id router.delete("/albums/:id", async (req, res) => { try { const album = await prisma.album.findUnique({ where: { id: req.params.id }, include: { artist: true, tracks: { include: { album: true, }, }, }, }); if (!album) { return res.status(404).json({ error: "Album not found" }); } // Delete all track files let deletedFiles = 0; for (const track of album.tracks) { if (track.filePath) { try { const absolutePath = path.join( config.music.musicPath, track.filePath ); if (fs.existsSync(absolutePath)) { fs.unlinkSync(absolutePath); deletedFiles++; } } catch (err) { console.warn("[DELETE] Could not delete file:", err); } } } // Try to delete album folder if empty try { const artistName = album.artist.name; const albumFolder = path.join( config.music.musicPath, artistName, album.title ); if (fs.existsSync(albumFolder)) { const files = fs.readdirSync(albumFolder); if (files.length === 0) { fs.rmdirSync(albumFolder); console.log( `[DELETE] Deleted empty album folder: ${albumFolder}` ); } } } catch (err) { console.warn("[DELETE] Could not delete album folder:", err); } // Delete from database (cascade will delete tracks) await prisma.album.delete({ where: { id: album.id }, }); console.log( `[DELETE] Deleted album: ${album.title} (${deletedFiles} files)` ); res.json({ message: "Album deleted successfully", deletedFiles, }); } catch (error) { console.error("Delete album error:", error); res.status(500).json({ error: "Failed to delete album" }); } }); // DELETE /library/artists/:id router.delete("/artists/:id", async (req, res) => { try { const artist = await prisma.artist.findUnique({ where: { id: req.params.id }, include: { albums: { include: { tracks: true, }, }, }, }); if (!artist) { return res.status(404).json({ error: "Artist not found" }); } // Delete all track files and collect actual artist folders from file paths let deletedFiles = 0; const artistFoldersToDelete = new Set(); for (const album of artist.albums) { for (const track of album.tracks) { if (track.filePath) { try { const absolutePath = path.join( config.music.musicPath, track.filePath ); if (fs.existsSync(absolutePath)) { fs.unlinkSync(absolutePath); deletedFiles++; // Extract actual artist folder from file path // Path format: Soulseek/Artist/Album/Track.mp3 OR Artist/Album/Track.mp3 const pathParts = track.filePath.split(path.sep); if (pathParts.length >= 2) { // If first part is "Soulseek", artist folder is Soulseek/Artist // Otherwise, artist folder is just Artist const actualArtistFolder = pathParts[0].toLowerCase() === "soulseek" ? path.join( config.music.musicPath, pathParts[0], pathParts[1] ) : path.join( config.music.musicPath, pathParts[0] ); artistFoldersToDelete.add(actualArtistFolder); } else if (pathParts.length === 1) { // Single-level path (rare case) const actualArtistFolder = path.join( config.music.musicPath, pathParts[0] ); artistFoldersToDelete.add(actualArtistFolder); } } } catch (err) { console.warn("[DELETE] Could not delete file:", err); } } } } // Delete artist folders based on actual file paths, not database name for (const artistFolder of artistFoldersToDelete) { try { if (fs.existsSync(artistFolder)) { console.log( `[DELETE] Attempting to delete folder: ${artistFolder}` ); // Always try recursive delete with force fs.rmSync(artistFolder, { recursive: true, force: true, }); console.log( `[DELETE] Successfully deleted artist folder: ${artistFolder}` ); } } catch (err: any) { console.error( `[DELETE] Failed to delete artist folder ${artistFolder}:`, err?.message || err ); // Try alternative: delete contents first, then folder try { const files = fs.readdirSync(artistFolder); for (const file of files) { const filePath = path.join(artistFolder, file); try { const stat = fs.statSync(filePath); if (stat.isDirectory()) { fs.rmSync(filePath, { recursive: true, force: true, }); } else { fs.unlinkSync(filePath); } console.log(`[DELETE] Deleted: ${filePath}`); } catch (fileErr: any) { console.error( `[DELETE] Could not delete ${filePath}:`, fileErr?.message ); } } // Try deleting the now-empty folder fs.rmdirSync(artistFolder); console.log( `[DELETE] Deleted artist folder after manual cleanup: ${artistFolder}` ); } catch (cleanupErr: any) { console.error( `[DELETE] Cleanup also failed for ${artistFolder}:`, cleanupErr?.message ); } } } // Also try deleting from common music folder paths (in case tracks weren't indexed) const commonPaths = [ path.join(config.music.musicPath, artist.name), path.join(config.music.musicPath, "Soulseek", artist.name), path.join(config.music.musicPath, "discovery", artist.name), ]; for (const commonPath of commonPaths) { if ( fs.existsSync(commonPath) && !artistFoldersToDelete.has(commonPath) ) { try { fs.rmSync(commonPath, { recursive: true, force: true }); console.log( `[DELETE] Deleted additional artist folder: ${commonPath}` ); } catch (err: any) { console.error( `[DELETE] Could not delete ${commonPath}:`, err?.message ); } } } // Delete from Lidarr if connected and artist has MBID let lidarrDeleted = false; let lidarrError: string | null = null; if (artist.mbid && !artist.mbid.startsWith("temp-")) { try { const { lidarrService } = await import("../services/lidarr"); const lidarrResult = await lidarrService.deleteArtist( artist.mbid, true ); if (lidarrResult.success) { console.log(`[DELETE] Lidarr: ${lidarrResult.message}`); lidarrDeleted = true; } else { console.warn( `[DELETE] Lidarr deletion note: ${lidarrResult.message}` ); lidarrError = lidarrResult.message; } } catch (err: any) { console.warn( "[DELETE] Could not delete from Lidarr:", err?.message || err ); lidarrError = err?.message || "Unknown error"; } } // Explicitly delete OwnedAlbum records first (should cascade, but being safe) try { await prisma.ownedAlbum.deleteMany({ where: { artistId: artist.id }, }); } catch (err) { console.warn("[DELETE] Could not delete OwnedAlbum records:", err); } // Delete from database (cascade will delete albums and tracks) console.log( `[DELETE] Deleting artist from database: ${artist.name} (${artist.id})` ); await prisma.artist.delete({ where: { id: artist.id }, }); console.log( `[DELETE] Successfully deleted artist: ${ artist.name } (${deletedFiles} files${ lidarrDeleted ? ", removed from Lidarr" : "" })` ); res.json({ message: "Artist deleted successfully", deletedFiles, lidarrDeleted, lidarrError, }); } catch (error: any) { console.error("Delete artist error:", error?.message || error); console.error("Delete artist stack:", error?.stack); res.status(500).json({ error: "Failed to delete artist", details: error?.message || "Unknown error", }); } }); /** * GET /library/genres * Get list of genres in the library with track counts */ router.get("/genres", async (req, res) => { try { // Get artist names to filter them out of genres (they sometimes get incorrectly tagged) const artists = await prisma.artist.findMany({ select: { name: true, normalizedName: true }, }); const artistNames = new Set( artists.flatMap((a) => [a.name.toLowerCase(), a.normalizedName?.toLowerCase()].filter( Boolean ) ) ); // Get genres from TrackGenre relation (most accurate) const trackGenres = await prisma.genre.findMany({ include: { _count: { select: { trackGenres: true }, }, }, }); const genreMap = new Map(); // Add track genre counts (excluding artist names) for (const g of trackGenres) { if (g.name && g._count.trackGenres > 0) { const normalized = g.name.trim(); // Skip if it matches an artist name if (normalized && !artistNames.has(normalized.toLowerCase())) { genreMap.set(normalized, g._count.trackGenres); } } } // Fallback: Get genres from Album.genres JSON field if no TrackGenres if (genreMap.size === 0) { const albums = await prisma.album.findMany({ where: { genres: { not: null }, }, select: { genres: true, _count: { select: { tracks: true } }, }, }); for (const album of albums) { const albumGenres = album.genres as string[] | null; if (albumGenres && Array.isArray(albumGenres)) { for (const genre of albumGenres) { const normalized = genre.trim(); // Skip if it matches an artist name if ( normalized && !artistNames.has(normalized.toLowerCase()) ) { genreMap.set( normalized, (genreMap.get(normalized) || 0) + album._count.tracks ); } } } } } // Convert to array and sort by count const genres = Array.from(genreMap.entries()) .map(([genre, count]) => ({ genre, count })) .sort((a, b) => b.count - a.count) .slice(0, 20); // Top 20 genres res.json({ genres }); } catch (error) { console.error("Genres endpoint error:", error); res.status(500).json({ error: "Failed to get genres" }); } }); /** * GET /library/decades * Get available decades in the library with track counts * Returns only decades with enough tracks (15+) */ router.get("/decades", async (req, res) => { try { // Get all albums with year and track count const albums = await prisma.album.findMany({ where: { year: { not: null }, }, select: { year: true, _count: { select: { tracks: true } }, }, }); // Group by decade const decadeMap = new Map(); for (const album of albums) { if (album.year) { // Calculate decade start (e.g., 1987 -> 1980, 2023 -> 2020) const decadeStart = Math.floor(album.year / 10) * 10; decadeMap.set( decadeStart, (decadeMap.get(decadeStart) || 0) + album._count.tracks ); } } // Convert to array, filter by minimum tracks, and sort by decade const decades = Array.from(decadeMap.entries()) .map(([decade, count]) => ({ decade, count })) .filter((d) => d.count >= 15) // Minimum 15 tracks for a radio station .sort((a, b) => b.decade - a.decade); // Newest first res.json({ decades }); } catch (error) { console.error("Decades endpoint error:", error); res.status(500).json({ error: "Failed to get decades" }); } }); /** * GET /library/radio * Get tracks for a library-based radio station * * Query params: * - type: "discovery" | "favorites" | "decade" | "genre" | "mood" * - value: Optional value for decade (e.g., "1990") or genre name * - limit: Number of tracks to return (default 50) */ router.get("/radio", async (req, res) => { try { const { type, value, limit = "50" } = req.query; const limitNum = Math.min(parseInt(limit as string) || 50, 100); const userId = req.user?.id; if (!type) { return res.status(400).json({ error: "Radio type is required" }); } let whereClause: any = {}; let orderBy: any = {}; let trackIds: string[] = []; let vibeSourceFeatures: any = null; // For vibe mode - store source track features switch (type) { case "discovery": // Lesser-played tracks - get tracks the user hasn't played or played least // First, get tracks with NO plays at all (truly undiscovered) const unplayedTracks = await prisma.track.findMany({ where: { plays: { none: {} }, // No plays by anyone }, select: { id: true }, take: limitNum * 2, }); if (unplayedTracks.length >= limitNum) { trackIds = unplayedTracks.map((t) => t.id); } else { // Fallback: get tracks with the fewest plays using raw count const leastPlayedTracks = await prisma.$queryRaw< { id: string }[] >` SELECT t.id FROM "Track" t LEFT JOIN "Play" p ON p."trackId" = t.id GROUP BY t.id ORDER BY COUNT(p.id) ASC LIMIT ${limitNum * 2} `; trackIds = leastPlayedTracks.map((t) => t.id); } break; case "favorites": // Most-played tracks - use raw query for accurate count ordering const mostPlayedTracks = await prisma.$queryRaw< { id: string; play_count: bigint }[] >` SELECT t.id, COUNT(p.id) as play_count FROM "Track" t LEFT JOIN "Play" p ON p."trackId" = t.id GROUP BY t.id HAVING COUNT(p.id) > 0 ORDER BY play_count DESC LIMIT ${limitNum * 2} `; if (mostPlayedTracks.length > 0) { trackIds = mostPlayedTracks.map((t) => t.id); } else { // No play data yet - just get random tracks console.log( "[Radio:favorites] No play data found, returning random tracks" ); const randomTracks = await prisma.track.findMany({ select: { id: true }, take: limitNum * 2, }); trackIds = randomTracks.map((t) => t.id); } break; case "decade": // Filter by decade (e.g., value = "1990" for 90s) const decadeStart = parseInt(value as string) || 2000; const decadeEnd = decadeStart + 9; const decadeTracks = await prisma.track.findMany({ where: { album: { year: { gte: decadeStart, lte: decadeEnd, }, }, }, select: { id: true }, take: limitNum * 3, }); trackIds = decadeTracks.map((t) => t.id); break; case "genre": // Filter by genre (matches against album or track genre tags) const genreValue = ((value as string) || "").toLowerCase(); // Strategy 1: Check trackGenres relation (most reliable) const genreRelationTracks = await prisma.track.findMany({ where: { trackGenres: { some: { genre: { name: { contains: genreValue, mode: "insensitive", }, }, }, }, }, select: { id: true }, take: limitNum * 2, }); trackIds = genreRelationTracks.map((t) => t.id); // Strategy 2: If not enough, check album.genres JSON field with raw query if (trackIds.length < limitNum) { const albumGenreTracks = await prisma.$queryRaw< { id: string }[] >` SELECT t.id FROM "Track" t JOIN "Album" a ON t."albumId" = a.id WHERE a.genres IS NOT NULL AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(a.genres::jsonb) AS g WHERE LOWER(g) LIKE ${"%" + genreValue + "%"} ) LIMIT ${limitNum * 2} `; const newIds = albumGenreTracks .map((t) => t.id) .filter((id) => !trackIds.includes(id)); trackIds = [...trackIds, ...newIds]; } console.log( `[Radio:genre] Found ${trackIds.length} tracks for genre "${genreValue}"` ); break; case "mood": // Mood-based filtering using audio analysis features const moodValue = ((value as string) || "").toLowerCase(); let moodWhere: any = { analysisStatus: "completed" }; switch (moodValue) { case "high-energy": moodWhere = { analysisStatus: "completed", energy: { gte: 0.7 }, bpm: { gte: 120 }, }; break; case "chill": moodWhere = { analysisStatus: "completed", OR: [ { energy: { lte: 0.4 } }, { arousal: { lte: 0.4 } }, ], }; break; case "happy": moodWhere = { analysisStatus: "completed", valence: { gte: 0.6 }, energy: { gte: 0.5 }, }; break; case "melancholy": moodWhere = { analysisStatus: "completed", OR: [ { valence: { lte: 0.4 } }, { keyScale: "minor" }, ], }; break; case "dance": moodWhere = { analysisStatus: "completed", danceability: { gte: 0.7 }, }; break; case "acoustic": moodWhere = { analysisStatus: "completed", acousticness: { gte: 0.6 }, }; break; case "instrumental": moodWhere = { analysisStatus: "completed", instrumentalness: { gte: 0.7 }, }; break; default: // Try Last.fm tags if mood not recognized moodWhere = { lastfmTags: { has: moodValue }, }; } const moodTracks = await prisma.track.findMany({ where: moodWhere, select: { id: true }, take: limitNum * 3, }); trackIds = moodTracks.map((t) => t.id); break; case "workout": // High-energy workout tracks - multiple strategies let workoutTrackIds: string[] = []; // Strategy 1: Audio analysis - high energy AND fast BPM const energyTracks = await prisma.track.findMany({ where: { analysisStatus: "completed", OR: [ // High energy with fast tempo { AND: [ { energy: { gte: 0.65 } }, { bpm: { gte: 115 } }, ], }, // Has workout mood tag { moodTags: { hasSome: ["workout", "energetic", "upbeat"], }, }, ], }, select: { id: true }, take: limitNum * 2, }); workoutTrackIds = energyTracks.map((t) => t.id); console.log( `[Radio:workout] Found ${workoutTrackIds.length} tracks via audio analysis` ); // Strategy 2: Genre-based (if not enough from audio) if (workoutTrackIds.length < limitNum) { const workoutGenreNames = [ "rock", "metal", "hard rock", "alternative rock", "punk", "hip hop", "rap", "trap", "electronic", "edm", "house", "techno", "drum and bass", "dubstep", "hardstyle", "metalcore", "hardcore", "industrial", "nu metal", "pop punk", ]; // Check Genre table const workoutGenres = await prisma.genre.findMany({ where: { name: { in: workoutGenreNames, mode: "insensitive", }, }, include: { trackGenres: { select: { trackId: true }, take: 50, }, }, }); const genreTrackIds = workoutGenres.flatMap((g) => g.trackGenres.map((tg) => tg.trackId) ); workoutTrackIds = [ ...new Set([...workoutTrackIds, ...genreTrackIds]), ]; console.log( `[Radio:workout] After genre check: ${workoutTrackIds.length} tracks` ); // Also check album.genres JSON field if (workoutTrackIds.length < limitNum) { const albumGenreTracks = await prisma.track.findMany({ where: { album: { OR: workoutGenreNames.map((g) => ({ genres: { string_contains: g }, })), }, }, select: { id: true }, take: limitNum, }); workoutTrackIds = [ ...new Set([ ...workoutTrackIds, ...albumGenreTracks.map((t) => t.id), ]), ]; console.log( `[Radio:workout] After album genre check: ${workoutTrackIds.length} tracks` ); } } trackIds = workoutTrackIds; break; case "artist": // Artist Radio - plays tracks from the artist + similar artists in library // Uses hybrid approach: Last.fm similarity (filtered to library) + genre matching + vibe boost const artistId = value as string; if (!artistId) { return res .status(400) .json({ error: "Artist ID required for artist radio" }); } console.log( `[Radio:artist] Starting artist radio for: ${artistId}` ); // 1. Get tracks from this artist (they're in library by definition) const artistTracks = await prisma.track.findMany({ where: { album: { artistId } }, select: { id: true, bpm: true, energy: true, valence: true, danceability: true, }, }); console.log( `[Radio:artist] Found ${artistTracks.length} tracks from artist` ); if (artistTracks.length === 0) { return res.json({ tracks: [] }); } // Calculate artist's average "vibe" for later matching const analyzedTracks = artistTracks.filter( (t) => t.bpm || t.energy || t.valence ); const avgVibe = analyzedTracks.length > 0 ? { bpm: analyzedTracks.reduce( (sum, t) => sum + (t.bpm || 0), 0 ) / analyzedTracks.length, energy: analyzedTracks.reduce( (sum, t) => sum + (t.energy || 0), 0 ) / analyzedTracks.length, valence: analyzedTracks.reduce( (sum, t) => sum + (t.valence || 0), 0 ) / analyzedTracks.length, danceability: analyzedTracks.reduce( (sum, t) => sum + (t.danceability || 0), 0 ) / analyzedTracks.length, } : null; console.log(`[Radio:artist] Artist vibe:`, avgVibe); // 2. Get library artist IDs (artists user actually owns) const ownedArtists = await prisma.ownedAlbum.findMany({ select: { artistId: true }, distinct: ["artistId"], }); const libraryArtistIds = new Set( ownedArtists.map((o) => o.artistId) ); libraryArtistIds.delete(artistId); // Exclude the current artist console.log( `[Radio:artist] Library has ${libraryArtistIds.size} other artists` ); // 3. Try Last.fm similar artists, filtered to library const similarInLibrary = await prisma.similarArtist.findMany({ where: { fromArtistId: artistId, toArtistId: { in: Array.from(libraryArtistIds) }, }, orderBy: { weight: "desc" }, take: 15, }); let similarArtistIds = similarInLibrary.map( (s) => s.toArtistId ); console.log( `[Radio:artist] Found ${similarArtistIds.length} Last.fm similar artists in library` ); // 4. Fallback: genre matching if not enough similar artists if (similarArtistIds.length < 5 && libraryArtistIds.size > 0) { const artist = await prisma.artist.findUnique({ where: { id: artistId }, select: { genres: true }, }); const artistGenres = (artist?.genres as string[]) || []; if (artistGenres.length > 0) { // Find library artists with overlapping genres const genreMatchArtists = await prisma.artist.findMany({ where: { id: { in: Array.from(libraryArtistIds) }, }, select: { id: true, genres: true }, }); // Score artists by genre overlap const scoredArtists = genreMatchArtists .map((a) => { const theirGenres = (a.genres as string[]) || []; const overlap = artistGenres.filter((g) => theirGenres.some( (tg) => tg .toLowerCase() .includes(g.toLowerCase()) || g .toLowerCase() .includes(tg.toLowerCase()) ) ).length; return { id: a.id, score: overlap }; }) .filter((a) => a.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 10); const genreArtistIds = scoredArtists.map((a) => a.id); similarArtistIds = [ ...new Set([ ...similarArtistIds, ...genreArtistIds, ]), ]; console.log( `[Radio:artist] After genre matching: ${similarArtistIds.length} similar artists` ); } } // 5. Get tracks from similar library artists let similarTracks: { id: string; bpm: number | null; energy: number | null; valence: number | null; danceability: number | null; }[] = []; if (similarArtistIds.length > 0) { similarTracks = await prisma.track.findMany({ where: { album: { artistId: { in: similarArtistIds } }, }, select: { id: true, bpm: true, energy: true, valence: true, danceability: true, }, }); console.log( `[Radio:artist] Found ${similarTracks.length} tracks from similar artists` ); } // 6. Apply vibe boost if we have audio analysis data if (avgVibe && similarTracks.length > 0) { // Score each similar track by how close its vibe is to the artist's average similarTracks = similarTracks .map((t) => { if (!t.bpm && !t.energy && !t.valence) return { ...t, vibeScore: 0.5 }; let score = 0; let factors = 0; if (t.bpm && avgVibe.bpm) { // BPM within 20 = good match const bpmDiff = Math.abs(t.bpm - avgVibe.bpm); score += Math.max(0, 1 - bpmDiff / 40); factors++; } if (t.energy !== null && avgVibe.energy) { score += 1 - Math.abs((t.energy || 0) - avgVibe.energy); factors++; } if (t.valence !== null && avgVibe.valence) { score += 1 - Math.abs( (t.valence || 0) - avgVibe.valence ); factors++; } if ( t.danceability !== null && avgVibe.danceability ) { score += 1 - Math.abs( (t.danceability || 0) - avgVibe.danceability ); factors++; } return { ...t, vibeScore: factors > 0 ? score / factors : 0.5, }; }) .sort( (a, b) => (b as any).vibeScore - (a as any).vibeScore ); console.log( `[Radio:artist] Applied vibe boost, top score: ${( similarTracks[0] as any )?.vibeScore?.toFixed(2)}` ); } // 7. Mix: ~40% original artist, ~60% similar (vibe-boosted) const originalCount = Math.min( Math.ceil(limitNum * 0.4), artistTracks.length ); const similarCount = Math.min( limitNum - originalCount, similarTracks.length ); const selectedOriginal = artistTracks .sort(() => Math.random() - 0.5) .slice(0, originalCount); // Take top vibe-matched tracks (already sorted by vibe score), then shuffle slightly const selectedSimilar = similarTracks .slice(0, similarCount * 2) .sort(() => Math.random() - 0.3) // Slight shuffle to add variety .slice(0, similarCount); trackIds = [...selectedOriginal, ...selectedSimilar].map( (t) => t.id ); console.log( `[Radio:artist] Final mix: ${selectedOriginal.length} original + ${selectedSimilar.length} similar = ${trackIds.length} tracks` ); break; case "vibe": // Vibe Match - finds tracks that sound like the given track // Pure audio feature matching with graceful fallbacks const sourceTrackId = value as string; if (!sourceTrackId) { return res .status(400) .json({ error: "Track ID required for vibe matching" }); } console.log( `[Radio:vibe] Starting vibe match for track: ${sourceTrackId}` ); // 1. Get the source track's audio features (including Enhanced mode fields) const sourceTrack = (await prisma.track.findUnique({ where: { id: sourceTrackId }, include: { album: { select: { artistId: true, genres: true, artist: { select: { id: true, name: true } }, }, }, }, })) as any; // Cast to any to include all Track fields if (!sourceTrack) { return res.status(404).json({ error: "Track not found" }); } // Check if track has Enhanced mode analysis const isEnhancedAnalysis = sourceTrack.analysisMode === "enhanced" || (sourceTrack.moodHappy !== null && sourceTrack.moodSad !== null); console.log( `[Radio:vibe] Source: "${sourceTrack.title}" by ${sourceTrack.album.artist.name}` ); console.log( `[Radio:vibe] Analysis mode: ${ isEnhancedAnalysis ? "ENHANCED" : "STANDARD" }` ); console.log( `[Radio:vibe] Source features: BPM=${sourceTrack.bpm}, Energy=${sourceTrack.energy}, Valence=${sourceTrack.valence}` ); if (isEnhancedAnalysis) { console.log( `[Radio:vibe] ML Moods: Happy=${sourceTrack.moodHappy}, Sad=${sourceTrack.moodSad}, Relaxed=${sourceTrack.moodRelaxed}, Aggressive=${sourceTrack.moodAggressive}, Party=${sourceTrack.moodParty}, Acoustic=${sourceTrack.moodAcoustic}, Electronic=${sourceTrack.moodElectronic}` ); } // Store source features for frontend visualization vibeSourceFeatures = { bpm: sourceTrack.bpm, energy: sourceTrack.energy, valence: sourceTrack.valence, arousal: sourceTrack.arousal, danceability: sourceTrack.danceability, keyScale: sourceTrack.keyScale, instrumentalness: sourceTrack.instrumentalness, // Enhanced mode features (all 7 ML mood predictions) moodHappy: sourceTrack.moodHappy, moodSad: sourceTrack.moodSad, moodRelaxed: sourceTrack.moodRelaxed, moodAggressive: sourceTrack.moodAggressive, moodParty: sourceTrack.moodParty, moodAcoustic: sourceTrack.moodAcoustic, moodElectronic: sourceTrack.moodElectronic, analysisMode: isEnhancedAnalysis ? "enhanced" : "standard", }; let vibeMatchedIds: string[] = []; const sourceArtistId = sourceTrack.album.artistId; // 2. Try audio feature matching first (if track is analyzed) const hasAudioData = sourceTrack.bpm || sourceTrack.energy || sourceTrack.valence; if (hasAudioData) { // Get all analyzed tracks (excluding source) - include Enhanced mode fields const analyzedTracks = await prisma.track.findMany({ where: { id: { not: sourceTrackId }, analysisStatus: "completed", }, select: { id: true, bpm: true, energy: true, valence: true, arousal: true, danceability: true, keyScale: true, moodTags: true, lastfmTags: true, essentiaGenres: true, instrumentalness: true, // Enhanced mode fields (all 7 ML mood predictions) moodHappy: true, moodSad: true, moodRelaxed: true, moodAggressive: true, moodParty: true, moodAcoustic: true, moodElectronic: true, danceabilityMl: true, analysisMode: true, }, }); console.log( `[Radio:vibe] Found ${analyzedTracks.length} analyzed tracks to compare` ); if (analyzedTracks.length > 0) { // === COSINE SIMILARITY SCORING === // Industry-standard approach: build feature vectors, compute cosine similarity // Uses ALL 13 features for comprehensive matching // Enhanced valence: mode/tonality + mood + audio features const calculateEnhancedValence = ( track: any ): number => { const happy = track.moodHappy ?? 0.5; const sad = track.moodSad ?? 0.5; const party = (track as any).moodParty ?? 0.5; const isMajor = track.keyScale === "major"; const isMinor = track.keyScale === "minor"; const modeValence = isMajor ? 0.3 : isMinor ? -0.2 : 0; const moodValence = happy * 0.35 + party * 0.25 + (1 - sad) * 0.2; const audioValence = (track.energy ?? 0.5) * 0.1 + (track.danceabilityMl ?? track.danceability ?? 0.5) * 0.1; return Math.max( 0, Math.min( 1, moodValence + modeValence + audioValence ) ); }; // Enhanced arousal: mood + energy + tempo (avoids unreliable "electronic" mood) const calculateEnhancedArousal = ( track: any ): number => { const aggressive = track.moodAggressive ?? 0.5; const party = (track as any).moodParty ?? 0.5; const relaxed = track.moodRelaxed ?? 0.5; const acoustic = (track as any).moodAcoustic ?? 0.5; const energy = track.energy ?? 0.5; const bpm = track.bpm ?? 120; const moodArousal = aggressive * 0.3 + party * 0.2; const energyArousal = energy * 0.25; const tempoArousal = Math.max(0, Math.min(1, (bpm - 60) / 120)) * 0.15; const calmReduction = (1 - relaxed) * 0.05 + (1 - acoustic) * 0.05; return Math.max( 0, Math.min( 1, moodArousal + energyArousal + tempoArousal + calmReduction ) ); }; // OOD detection using Energy-based scoring const detectOOD = (track: any): boolean => { const coreMoods = [ track.moodHappy ?? 0.5, track.moodSad ?? 0.5, track.moodRelaxed ?? 0.5, track.moodAggressive ?? 0.5, ]; const minMood = Math.min(...coreMoods); const maxMood = Math.max(...coreMoods); // Enhanced OOD detection based on research // Flag if all core moods are high (>0.7) with low variance, OR if all are very neutral (~0.5) const allHigh = minMood > 0.7 && maxMood - minMood < 0.3; const allNeutral = Math.abs(maxMood - 0.5) < 0.15 && Math.abs(minMood - 0.5) < 0.15; return allHigh || allNeutral; }; // Octave-aware BPM distance calculation const octaveAwareBPMDistance = ( bpm1: number, bpm2: number ): number => { if (!bpm1 || !bpm2) return 0; // Normalize to standard octave range (77-154 BPM) const normalizeToOctave = (bpm: number): number => { while (bpm < 77) bpm *= 2; while (bpm > 154) bpm /= 2; return bpm; }; const norm1 = normalizeToOctave(bpm1); const norm2 = normalizeToOctave(bpm2); // Calculate distance on logarithmic scale for harmonic equivalence const logDistance = Math.abs( Math.log2(norm1) - Math.log2(norm2) ); return Math.min(logDistance, 1); // Cap at 1 for similarity calculation }; // Helper: Build enhanced weighted feature vector from track const buildFeatureVector = (track: any): number[] => { // Detect OOD and apply normalization if needed const isOOD = detectOOD(track); // Get mood values with OOD normalization const getMoodValue = ( value: number | null, defaultValue: number ): number => { if (!value) return defaultValue; if (!isOOD) return value; // Normalize OOD predictions to spread them out (0.2-0.8 range) return ( 0.2 + Math.max(0, Math.min(0.6, value - 0.2)) ); }; // Use enhanced valence/arousal calculations const enhancedValence = calculateEnhancedValence(track); const enhancedArousal = calculateEnhancedArousal(track); return [ // ML Mood predictions (7 features) - enhanced weighting and OOD handling getMoodValue(track.moodHappy, 0.5) * 1.3, // 1.3x weight for semantic features getMoodValue(track.moodSad, 0.5) * 1.3, getMoodValue(track.moodRelaxed, 0.5) * 1.3, getMoodValue(track.moodAggressive, 0.5) * 1.3, getMoodValue((track as any).moodParty, 0.5) * 1.3, getMoodValue((track as any).moodAcoustic, 0.5) * 1.3, getMoodValue( (track as any).moodElectronic, 0.5 ) * 1.3, // Audio features (5 features) - standard weight track.energy ?? 0.5, enhancedArousal, // Use enhanced arousal track.danceabilityMl ?? track.danceability ?? 0.5, track.instrumentalness ?? 0.5, // Octave-aware BPM normalized to 0-1 1 - octaveAwareBPMDistance( track.bpm ?? 120, 120 ), // Similarity to reference tempo // Enhanced key mode with valence consideration enhancedValence, // Use enhanced valence instead of binary key ]; }; // Helper: Compute cosine similarity between two vectors const cosineSimilarity = ( a: number[], b: number[] ): number => { let dot = 0, magA = 0, magB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; magA += a[i] * a[i]; magB += b[i] * b[i]; } if (magA === 0 || magB === 0) return 0; return dot / (Math.sqrt(magA) * Math.sqrt(magB)); }; // Helper: Compute tag overlap bonus const computeTagBonus = ( sourceTags: string[], sourceGenres: string[], trackTags: string[], trackGenres: string[] ): number => { const sourceSet = new Set( [...sourceTags, ...sourceGenres].map((t) => t.toLowerCase() ) ); const trackSet = new Set( [...trackTags, ...trackGenres].map((t) => t.toLowerCase() ) ); if (sourceSet.size === 0 || trackSet.size === 0) return 0; const overlap = [...sourceSet].filter((tag) => trackSet.has(tag) ).length; // Max 5% bonus for tag overlap return Math.min(0.05, overlap * 0.01); }; // Build source feature vector once const sourceVector = buildFeatureVector(sourceTrack); // Check if source track has Enhanced mode data const bothEnhanced = isEnhancedAnalysis; const scored = analyzedTracks.map((t) => { // Check if target track has Enhanced mode data const targetEnhanced = t.analysisMode === "enhanced" || (t.moodHappy !== null && t.moodSad !== null); const useEnhanced = bothEnhanced && targetEnhanced; // Build target feature vector const targetVector = buildFeatureVector(t as any); // Compute base cosine similarity let score = cosineSimilarity( sourceVector, targetVector ); // Add tag/genre overlap bonus (max 5%) const tagBonus = computeTagBonus( sourceTrack.lastfmTags || [], sourceTrack.essentiaGenres || [], t.lastfmTags || [], t.essentiaGenres || [] ); // Final score: 95% cosine similarity + 5% tag bonus const finalScore = score * 0.95 + tagBonus; return { id: t.id, score: finalScore, enhanced: useEnhanced, }; }); // Filter to good matches and sort by score // Use lower threshold (40%) for Enhanced mode since it's more precise const minThreshold = isEnhancedAnalysis ? 0.4 : 0.5; const goodMatches = scored .filter((t) => t.score > minThreshold) .sort((a, b) => b.score - a.score); vibeMatchedIds = goodMatches.map((t) => t.id); const enhancedCount = goodMatches.filter( (t) => t.enhanced ).length; console.log( `[Radio:vibe] Audio matching found ${ vibeMatchedIds.length } tracks (>${minThreshold * 100}% similarity)` ); console.log( `[Radio:vibe] Enhanced matches: ${enhancedCount}, Standard matches: ${ goodMatches.length - enhancedCount }` ); if (goodMatches.length > 0) { console.log( `[Radio:vibe] Top match score: ${goodMatches[0].score.toFixed( 2 )} (${ goodMatches[0].enhanced ? "enhanced" : "standard" })` ); } } } // 3. Fallback A: Same artist's other tracks if (vibeMatchedIds.length < limitNum) { const artistTracks = await prisma.track.findMany({ where: { album: { artistId: sourceArtistId }, id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, }, select: { id: true }, }); const newIds = artistTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; console.log( `[Radio:vibe] Fallback A (same artist): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` ); } // 4. Fallback B: Similar artists from Last.fm (filtered to library) if (vibeMatchedIds.length < limitNum) { const ownedArtistIds = await prisma.ownedAlbum.findMany({ select: { artistId: true }, distinct: ["artistId"], }); const libraryArtistSet = new Set( ownedArtistIds.map((o) => o.artistId) ); libraryArtistSet.delete(sourceArtistId); const similarArtists = await prisma.similarArtist.findMany({ where: { fromArtistId: sourceArtistId, toArtistId: { in: Array.from(libraryArtistSet) }, }, orderBy: { weight: "desc" }, take: 10, }); if (similarArtists.length > 0) { const similarArtistTracks = await prisma.track.findMany( { where: { album: { artistId: { in: similarArtists.map( (s) => s.toArtistId ), }, }, id: { notIn: [ sourceTrackId, ...vibeMatchedIds, ], }, }, select: { id: true }, } ); const newIds = similarArtistTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; console.log( `[Radio:vibe] Fallback B (similar artists): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` ); } } // 5. Fallback C: Same genre (using TrackGenre relation) const sourceGenres = (sourceTrack.album.genres as string[]) || []; if ( vibeMatchedIds.length < limitNum && sourceGenres.length > 0 ) { // Search using the TrackGenre relation for better accuracy const genreTracks = await prisma.track.findMany({ where: { trackGenres: { some: { genre: { name: { in: sourceGenres, mode: "insensitive", }, }, }, }, id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, }, select: { id: true }, take: limitNum, }); const newIds = genreTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; console.log( `[Radio:vibe] Fallback C (same genre): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` ); } // 6. Fallback D: Random from library if (vibeMatchedIds.length < limitNum) { const randomTracks = await prisma.track.findMany({ where: { id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, }, select: { id: true }, take: limitNum - vibeMatchedIds.length, }); const newIds = randomTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; console.log( `[Radio:vibe] Fallback D (random): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` ); } trackIds = vibeMatchedIds; console.log( `[Radio:vibe] Final vibe queue: ${trackIds.length} tracks` ); break; case "all": default: // Random selection from all tracks in library const allTracks = await prisma.track.findMany({ select: { id: true }, }); trackIds = allTracks.map((t) => t.id); } // For vibe mode, keep the sorted order (by match score) // For other modes, shuffle the results const finalIds = type === "vibe" ? trackIds.slice(0, limitNum) // Already sorted by match score : trackIds.sort(() => Math.random() - 0.5).slice(0, limitNum); if (finalIds.length === 0) { return res.json({ tracks: [] }); } // Fetch full track data (include all analysis fields for logging) const tracks = await prisma.track.findMany({ where: { id: { in: finalIds }, }, include: { album: { include: { artist: { select: { id: true, name: true, }, }, }, }, trackGenres: { include: { genre: { select: { name: true } }, }, }, }, }); // For vibe mode, reorder tracks to match the sorted finalIds order // (Prisma's findMany with IN doesn't preserve order) let orderedTracks = tracks; if (type === "vibe") { const trackMap = new Map(tracks.map((t) => [t.id, t])); orderedTracks = finalIds .map((id) => trackMap.get(id)) .filter((t): t is (typeof tracks)[0] => t !== undefined); } // === VIBE QUEUE LOGGING === // Log detailed info for vibe matching analysis (using ordered tracks) if (type === "vibe" && vibeSourceFeatures) { console.log("\n" + "=".repeat(100)); console.log("VIBE QUEUE ANALYSIS - Source Track"); console.log("=".repeat(100)); // Find source track for logging const srcTrack = await prisma.track.findUnique({ where: { id: value as string }, include: { album: { include: { artist: { select: { name: true } } } }, trackGenres: { include: { genre: { select: { name: true } } }, }, }, }); if (srcTrack) { console.log( `SOURCE: "${srcTrack.title}" by ${srcTrack.album.artist.name}` ); console.log(` Album: ${srcTrack.album.title}`); console.log( ` Analysis Mode: ${ (srcTrack as any).analysisMode || "unknown" }` ); console.log( ` BPM: ${srcTrack.bpm?.toFixed(1) || "N/A"} | Energy: ${ srcTrack.energy?.toFixed(2) || "N/A" } | Valence: ${srcTrack.valence?.toFixed(2) || "N/A"}` ); console.log( ` Danceability: ${ srcTrack.danceability?.toFixed(2) || "N/A" } | Arousal: ${ srcTrack.arousal?.toFixed(2) || "N/A" } | Key: ${srcTrack.keyScale || "N/A"}` ); console.log( ` ML Moods: Happy=${ (srcTrack as any).moodHappy?.toFixed(2) || "N/A" }, Sad=${ (srcTrack as any).moodSad?.toFixed(2) || "N/A" }, Relaxed=${ (srcTrack as any).moodRelaxed?.toFixed(2) || "N/A" }, Aggressive=${ (srcTrack as any).moodAggressive?.toFixed(2) || "N/A" }` ); console.log( ` Genres: ${ srcTrack.trackGenres .map((tg) => tg.genre.name) .join(", ") || "N/A" }` ); console.log( ` Last.fm Tags: ${ ((srcTrack as any).lastfmTags || []).join(", ") || "N/A" }` ); console.log( ` Mood Tags: ${ ((srcTrack as any).moodTags || []).join(", ") || "N/A" }` ); } console.log("\n" + "-".repeat(100)); console.log( `VIBE QUEUE - ${orderedTracks.length} tracks (showing up to 50, SORTED BY MATCH SCORE)` ); console.log("-".repeat(100)); console.log( `${"#".padEnd(3)} | ${"TRACK".padEnd(35)} | ${"ARTIST".padEnd( 20 )} | ${"BPM".padEnd(6)} | ${"ENG".padEnd(5)} | ${"VAL".padEnd( 5 )} | ${"H".padEnd(4)} | ${"S".padEnd(4)} | ${"R".padEnd( 4 )} | ${"A".padEnd(4)} | MODE | GENRES` ); console.log("-".repeat(100)); orderedTracks.slice(0, 50).forEach((track, i) => { const t = track as any; const title = track.title.substring(0, 33).padEnd(35); const artist = track.album.artist.name .substring(0, 18) .padEnd(20); const bpm = track.bpm ? track.bpm.toFixed(0).padEnd(6) : "N/A".padEnd(6); const energy = track.energy !== null ? track.energy.toFixed(2).padEnd(5) : "N/A".padEnd(5); const valence = track.valence !== null ? track.valence.toFixed(2).padEnd(5) : "N/A".padEnd(5); const happy = t.moodHappy !== null ? t.moodHappy.toFixed(2).padEnd(4) : "N/A".padEnd(4); const sad = t.moodSad !== null ? t.moodSad.toFixed(2).padEnd(4) : "N/A".padEnd(4); const relaxed = t.moodRelaxed !== null ? t.moodRelaxed.toFixed(2).padEnd(4) : "N/A".padEnd(4); const aggressive = t.moodAggressive !== null ? t.moodAggressive.toFixed(2).padEnd(4) : "N/A".padEnd(4); const mode = (t.analysisMode || "std") .substring(0, 7) .padEnd(8); const genres = track.trackGenres .slice(0, 3) .map((tg) => tg.genre.name) .join(", "); console.log( `${String(i + 1).padEnd( 3 )} | ${title} | ${artist} | ${bpm} | ${energy} | ${valence} | ${happy} | ${sad} | ${relaxed} | ${aggressive} | ${mode} | ${genres}` ); }); if (orderedTracks.length > 50) { console.log(`... and ${orderedTracks.length - 50} more tracks`); } console.log("=".repeat(100) + "\n"); } // Transform to match frontend Track interface const transformedTracks = orderedTracks.map((track) => ({ id: track.id, title: track.title, duration: track.duration, trackNo: track.trackNo, filePath: track.filePath, artist: { id: track.album.artist.id, name: track.album.artist.name, }, album: { id: track.album.id, title: track.album.title, coverArt: track.album.coverUrl, }, // Include audio features for vibe mode visualization (if available) ...(vibeSourceFeatures && { audioFeatures: { bpm: track.bpm, energy: track.energy, valence: track.valence, arousal: track.arousal, danceability: track.danceability, keyScale: track.keyScale, instrumentalness: track.instrumentalness, analysisMode: track.analysisMode, // ML Mood predictions for enhanced visualization moodHappy: track.moodHappy, moodSad: track.moodSad, moodRelaxed: track.moodRelaxed, moodAggressive: track.moodAggressive, moodParty: track.moodParty, moodAcoustic: track.moodAcoustic, moodElectronic: track.moodElectronic, }, }), })); // For vibe mode, keep sorted order. For other modes, shuffle. const finalTracks = type === "vibe" ? transformedTracks : transformedTracks.sort(() => Math.random() - 0.5); // Include source features if this was a vibe request const response: any = { tracks: finalTracks }; if (vibeSourceFeatures) { response.sourceFeatures = vibeSourceFeatures; } res.json(response); } catch (error) { console.error("Radio endpoint error:", error); res.status(500).json({ error: "Failed to get radio tracks" }); } }); export default router;