f8b464feec
- 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
4419 lines
172 KiB
TypeScript
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;
|