Files
lidify/backend/src/routes/library.ts
T
Kevin O'Neill f8b464feec v1.0.2: Mood mix optimizations and media player improvements
- Fixed player seek flicker on podcasts (30s skip buttons)
- Added dual-layer seek lock mechanism to prevent stale time updates
- Optimized cached podcast seeking (direct seek before reload fallback)
- Large skips now execute immediately for responsive feel
- Mood mix performance optimizations
2025-12-26 13:06:17 -06:00

4419 lines
172 KiB
TypeScript

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<string, number>),
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<string, string> = {
"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<string, string> = {
"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<string>();
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<string, number>();
// 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<number, number>();
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;