v1.0.2: Mood mix optimizations and media player improvements
- Fixed player seek flicker on podcasts (30s skip buttons) - Added dual-layer seek lock mechanism to prevent stale time updates - Optimized cached podcast seeking (direct seek before reload fallback) - Large skips now execute immediately for responsive feel - Mood mix performance optimizations
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AddColumn: Artist.similarArtistsJson
|
||||
-- Stores full Last.fm similar artists data as JSON instead of FK-based SimilarArtist table
|
||||
ALTER TABLE "Artist" ADD COLUMN "similarArtistsJson" JSONB;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Add MoodBucket table for pre-computed mood assignments
|
||||
CREATE TABLE "MoodBucket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"trackId" TEXT NOT NULL,
|
||||
"mood" TEXT NOT NULL,
|
||||
"score" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MoodBucket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Add UserMoodMix table for storing user's active mood mix
|
||||
CREATE TABLE "UserMoodMix" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"mood" TEXT NOT NULL,
|
||||
"trackIds" TEXT[],
|
||||
"coverUrls" TEXT[],
|
||||
"generatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserMoodMix_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Unique constraint: one entry per track+mood combination
|
||||
CREATE UNIQUE INDEX "MoodBucket_trackId_mood_key" ON "MoodBucket"("trackId", "mood");
|
||||
|
||||
-- Index for fast mood lookups sorted by score
|
||||
CREATE INDEX "MoodBucket_mood_score_idx" ON "MoodBucket"("mood", "score" DESC);
|
||||
|
||||
-- Index for track lookups
|
||||
CREATE INDEX "MoodBucket_trackId_idx" ON "MoodBucket"("trackId");
|
||||
|
||||
-- Unique constraint: one mood mix per user
|
||||
CREATE UNIQUE INDEX "UserMoodMix_userId_key" ON "UserMoodMix"("userId");
|
||||
|
||||
-- Index for user lookups
|
||||
CREATE INDEX "UserMoodMix_userId_idx" ON "UserMoodMix"("userId");
|
||||
|
||||
-- Foreign key constraints
|
||||
ALTER TABLE "MoodBucket" ADD CONSTRAINT "MoodBucket_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "UserMoodMix" ADD CONSTRAINT "UserMoodMix_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Add indexes to Track table for mood-related columns (for query optimization)
|
||||
CREATE INDEX IF NOT EXISTS "Track_analysisMode_idx" ON "Track"("analysisMode");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodHappy_idx" ON "Track"("moodHappy");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodSad_idx" ON "Track"("moodSad");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodRelaxed_idx" ON "Track"("moodRelaxed");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodAggressive_idx" ON "Track"("moodAggressive");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodParty_idx" ON "Track"("moodParty");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodAcoustic_idx" ON "Track"("moodAcoustic");
|
||||
CREATE INDEX IF NOT EXISTS "Track_moodElectronic_idx" ON "Track"("moodElectronic");
|
||||
CREATE INDEX IF NOT EXISTS "Track_arousal_idx" ON "Track"("arousal");
|
||||
CREATE INDEX IF NOT EXISTS "Track_acousticness_idx" ON "Track"("acousticness");
|
||||
CREATE INDEX IF NOT EXISTS "Track_instrumentalness_idx" ON "Track"("instrumentalness");
|
||||
@@ -42,6 +42,7 @@ model User {
|
||||
deviceLinkCodes DeviceLinkCode[]
|
||||
hiddenPlaylists HiddenPlaylist[]
|
||||
notifications Notification[]
|
||||
userMoodMix UserMoodMix?
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
@@ -129,17 +130,18 @@ model SystemSettings {
|
||||
}
|
||||
|
||||
model Artist {
|
||||
id String @id @default(cuid())
|
||||
mbid String @unique
|
||||
name String
|
||||
normalizedName String @default("") // Lowercase version for case-insensitive matching
|
||||
summary String? @db.Text
|
||||
heroUrl String?
|
||||
genres Json? // Array of genre strings from Last.fm/MusicBrainz
|
||||
lastSynced DateTime @default(now())
|
||||
lastEnriched DateTime?
|
||||
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
||||
searchVector Unsupported("tsvector")?
|
||||
id String @id @default(cuid())
|
||||
mbid String @unique
|
||||
name String
|
||||
normalizedName String @default("") // Lowercase version for case-insensitive matching
|
||||
summary String? @db.Text
|
||||
heroUrl String?
|
||||
genres Json? // Array of genre strings from Last.fm/MusicBrainz
|
||||
similarArtistsJson Json? // Full Last.fm similar artists data [{name, mbid, match, image}]
|
||||
lastSynced DateTime @default(now())
|
||||
lastEnriched DateTime?
|
||||
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
albums Album[]
|
||||
similarFrom SimilarArtist[] @relation("FromArtist")
|
||||
@@ -248,16 +250,28 @@ model Track {
|
||||
likedBy LikedTrack[]
|
||||
cachedBy CachedTrack[]
|
||||
transcodedFiles TranscodedFile[]
|
||||
moodBuckets MoodBucket[]
|
||||
|
||||
@@index([albumId])
|
||||
@@index([fileModified])
|
||||
@@index([title])
|
||||
@@index([searchVector], type: Gin)
|
||||
@@index([analysisStatus])
|
||||
@@index([analysisMode])
|
||||
@@index([bpm])
|
||||
@@index([energy])
|
||||
@@index([valence])
|
||||
@@index([danceability])
|
||||
@@index([moodHappy])
|
||||
@@index([moodSad])
|
||||
@@index([moodRelaxed])
|
||||
@@index([moodAggressive])
|
||||
@@index([moodParty])
|
||||
@@index([moodAcoustic])
|
||||
@@index([moodElectronic])
|
||||
@@index([arousal])
|
||||
@@index([acousticness])
|
||||
@@index([instrumentalness])
|
||||
}
|
||||
|
||||
// Transcoded file cache for audio streaming
|
||||
@@ -881,6 +895,44 @@ model DeviceLinkCode {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mood Mixer System (Pre-computed Mood Buckets)
|
||||
// ============================================
|
||||
|
||||
// Pre-computed mood assignments for fast mood mix generation
|
||||
// Tracks are assigned to mood buckets during audio analysis
|
||||
model MoodBucket {
|
||||
id String @id @default(cuid())
|
||||
trackId String
|
||||
mood String // happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic
|
||||
score Float // Confidence score 0-1
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([trackId, mood])
|
||||
@@index([mood, score(sort: Desc)])
|
||||
@@index([trackId])
|
||||
}
|
||||
|
||||
// User's active mood mix selection
|
||||
// Stores the last generated mood mix for display on the home page
|
||||
model UserMoodMix {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
mood String // The selected mood (happy, sad, etc.)
|
||||
trackIds String[] // Cached track IDs for the mix
|
||||
coverUrls String[] // Cached cover URLs for display
|
||||
generatedAt DateTime // When this mix was generated
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Enums
|
||||
// ============================================
|
||||
|
||||
+1065
-499
File diff suppressed because it is too large
Load Diff
+327
-21
@@ -1,6 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuthOrToken } from "../middleware/auth";
|
||||
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
|
||||
import {
|
||||
moodBucketService,
|
||||
VALID_MOODS,
|
||||
MoodType,
|
||||
} from "../services/moodBucketService";
|
||||
import { prisma } from "../utils/db";
|
||||
import { redisClient } from "../utils/redis";
|
||||
|
||||
@@ -182,24 +187,43 @@ router.post("/mood", async (req, res) => {
|
||||
// Validate parameters
|
||||
const validKeys = [
|
||||
// Basic audio features
|
||||
'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale',
|
||||
"valence",
|
||||
"energy",
|
||||
"danceability",
|
||||
"acousticness",
|
||||
"instrumentalness",
|
||||
"arousal",
|
||||
"bpm",
|
||||
"keyScale",
|
||||
// ML mood predictions
|
||||
'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic',
|
||||
"moodHappy",
|
||||
"moodSad",
|
||||
"moodRelaxed",
|
||||
"moodAggressive",
|
||||
"moodParty",
|
||||
"moodAcoustic",
|
||||
"moodElectronic",
|
||||
// Other
|
||||
'limit'
|
||||
"limit",
|
||||
];
|
||||
for (const key of Object.keys(params)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
return res.status(400).json({ error: `Invalid parameter: ${key}` });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `Invalid parameter: ${key}` });
|
||||
}
|
||||
}
|
||||
|
||||
const mix = await programmaticPlaylistService.generateMoodOnDemand(userId, params);
|
||||
const mix = await programmaticPlaylistService.generateMoodOnDemand(
|
||||
userId,
|
||||
params
|
||||
);
|
||||
|
||||
if (!mix) {
|
||||
return res.status(400).json({
|
||||
error: "Not enough tracks matching your criteria",
|
||||
suggestion: "Try widening your parameters or wait for more tracks to be analyzed"
|
||||
suggestion:
|
||||
"Try widening your parameters or wait for more tracks to be analyzed",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,7 +252,9 @@ router.post("/mood", async (req, res) => {
|
||||
.map((id: string) => tracks.find((t) => t.id === id))
|
||||
.filter((t: any) => t !== undefined);
|
||||
|
||||
console.log(`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`);
|
||||
console.log(
|
||||
`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`
|
||||
);
|
||||
|
||||
res.json({
|
||||
...mix,
|
||||
@@ -251,73 +277,123 @@ router.get("/mood/presets", async (req, res) => {
|
||||
id: "happy",
|
||||
name: "Happy & Upbeat",
|
||||
color: "from-yellow-400 to-orange-500",
|
||||
params: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 }, energy: { min: 0.4 } },
|
||||
params: {
|
||||
moodHappy: { min: 0.5 },
|
||||
moodSad: { max: 0.4 },
|
||||
energy: { min: 0.4 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sad",
|
||||
name: "Melancholic",
|
||||
color: "from-blue-600 to-indigo-700",
|
||||
params: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
|
||||
params: {
|
||||
moodSad: { min: 0.5 },
|
||||
moodHappy: { max: 0.4 },
|
||||
keyScale: "minor",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chill",
|
||||
name: "Chill & Relaxed",
|
||||
color: "from-teal-400 to-cyan-500",
|
||||
params: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 }, energy: { max: 0.55 } },
|
||||
params: {
|
||||
moodRelaxed: { min: 0.5 },
|
||||
moodAggressive: { max: 0.3 },
|
||||
energy: { max: 0.55 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "energetic",
|
||||
name: "High Energy",
|
||||
color: "from-red-500 to-orange-600",
|
||||
params: { arousal: { min: 0.6 }, energy: { min: 0.65 }, moodRelaxed: { max: 0.4 } },
|
||||
params: {
|
||||
arousal: { min: 0.6 },
|
||||
energy: { min: 0.65 },
|
||||
moodRelaxed: { max: 0.4 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "focus",
|
||||
name: "Focus Mode",
|
||||
color: "from-purple-600 to-violet-700",
|
||||
params: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 }, energy: { min: 0.2, max: 0.6 } },
|
||||
params: {
|
||||
instrumentalness: { min: 0.5 },
|
||||
moodRelaxed: { min: 0.3 },
|
||||
energy: { min: 0.2, max: 0.6 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dance",
|
||||
name: "Dance Party",
|
||||
color: "from-pink-500 to-rose-600",
|
||||
params: { moodParty: { min: 0.5 }, danceability: { min: 0.6 }, energy: { min: 0.5 } },
|
||||
params: {
|
||||
moodParty: { min: 0.5 },
|
||||
danceability: { min: 0.6 },
|
||||
energy: { min: 0.5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "acoustic",
|
||||
name: "Acoustic Vibes",
|
||||
color: "from-amber-500 to-yellow-600",
|
||||
params: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
|
||||
params: {
|
||||
moodAcoustic: { min: 0.5 },
|
||||
moodElectronic: { max: 0.4 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
name: "Dark & Moody",
|
||||
color: "from-gray-700 to-slate-800",
|
||||
params: { moodAggressive: { min: 0.4 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
|
||||
params: {
|
||||
moodAggressive: { min: 0.4 },
|
||||
moodHappy: { max: 0.4 },
|
||||
keyScale: "minor",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "romantic",
|
||||
name: "Romantic",
|
||||
color: "from-rose-500 to-pink-600",
|
||||
params: { moodRelaxed: { min: 0.3 }, moodAggressive: { max: 0.3 }, acousticness: { min: 0.3 }, energy: { max: 0.6 } },
|
||||
params: {
|
||||
moodRelaxed: { min: 0.3 },
|
||||
moodAggressive: { max: 0.3 },
|
||||
acousticness: { min: 0.3 },
|
||||
energy: { max: 0.6 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workout",
|
||||
name: "Workout Beast",
|
||||
color: "from-green-500 to-emerald-600",
|
||||
params: { arousal: { min: 0.6 }, energy: { min: 0.7 }, moodRelaxed: { max: 0.4 }, bpm: { min: 110 } },
|
||||
params: {
|
||||
arousal: { min: 0.6 },
|
||||
energy: { min: 0.7 },
|
||||
moodRelaxed: { max: 0.4 },
|
||||
bpm: { min: 110 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sleepy",
|
||||
name: "Sleep & Unwind",
|
||||
color: "from-indigo-400 to-purple-500",
|
||||
params: { moodRelaxed: { min: 0.5 }, energy: { max: 0.35 }, moodAggressive: { max: 0.2 } },
|
||||
params: {
|
||||
moodRelaxed: { min: 0.5 },
|
||||
energy: { max: 0.35 },
|
||||
moodAggressive: { max: 0.2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "confident",
|
||||
name: "Confidence Boost",
|
||||
color: "from-amber-400 to-orange-500",
|
||||
params: { moodHappy: { min: 0.4 }, moodParty: { min: 0.3 }, energy: { min: 0.5 }, danceability: { min: 0.5 } },
|
||||
params: {
|
||||
moodHappy: { min: 0.4 },
|
||||
moodParty: { min: 0.3 },
|
||||
energy: { min: 0.5 },
|
||||
danceability: { min: 0.5 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -339,13 +415,15 @@ router.post("/mood/save-preferences", async (req, res) => {
|
||||
|
||||
// Validate that at least some params are provided
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return res.status(400).json({ error: "No mood parameters provided" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No mood parameters provided" });
|
||||
}
|
||||
|
||||
// Save to user record
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { moodMixParams: params }
|
||||
data: { moodMixParams: params },
|
||||
});
|
||||
|
||||
// Invalidate mix cache so the new mood mix appears
|
||||
@@ -361,6 +439,234 @@ router.post("/mood/save-preferences", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NEW SIMPLIFIED MOOD BUCKET ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /mixes/mood/buckets/presets:
|
||||
* get:
|
||||
* summary: Get mood presets with track counts
|
||||
* description: Returns available mood categories with how many tracks are available for each
|
||||
* tags: [Mixes]
|
||||
* security:
|
||||
* - sessionAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of mood presets with track counts
|
||||
*/
|
||||
router.get("/mood/buckets/presets", async (req, res) => {
|
||||
try {
|
||||
const presets = await moodBucketService.getMoodPresets();
|
||||
res.json(presets);
|
||||
} catch (error) {
|
||||
console.error("Get mood presets error:", error);
|
||||
res.status(500).json({ error: "Failed to get mood presets" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /mixes/mood/buckets/{mood}:
|
||||
* get:
|
||||
* summary: Get a mood mix for a specific mood
|
||||
* description: Fast lookup from pre-computed mood bucket table
|
||||
* tags: [Mixes]
|
||||
* security:
|
||||
* - sessionAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: mood
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic]
|
||||
* description: Mood category
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Mood mix with track details
|
||||
* 400:
|
||||
* description: Invalid mood or not enough tracks
|
||||
*/
|
||||
router.get("/mood/buckets/:mood", async (req, res) => {
|
||||
try {
|
||||
const mood = req.params.mood as MoodType;
|
||||
|
||||
if (!VALID_MOODS.includes(mood)) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid mood: ${mood}`,
|
||||
validMoods: VALID_MOODS,
|
||||
});
|
||||
}
|
||||
|
||||
const mix = await moodBucketService.getMoodMix(mood);
|
||||
|
||||
if (!mix) {
|
||||
return res.status(400).json({
|
||||
error: `Not enough tracks for mood: ${mood}`,
|
||||
suggestion: "Wait for more tracks to be analyzed",
|
||||
});
|
||||
}
|
||||
|
||||
// Load full track details
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { id: { in: mix.trackIds } },
|
||||
include: {
|
||||
album: {
|
||||
include: {
|
||||
artist: {
|
||||
select: { id: true, name: true, mbid: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve mix order
|
||||
const orderedTracks = mix.trackIds
|
||||
.map((id: string) => tracks.find((t) => t.id === id))
|
||||
.filter((t: any) => t !== undefined);
|
||||
|
||||
res.json({
|
||||
...mix,
|
||||
tracks: orderedTracks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Get mood bucket mix error:", error);
|
||||
res.status(500).json({ error: "Failed to get mood mix" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /mixes/mood/buckets/{mood}/save:
|
||||
* post:
|
||||
* summary: Save a mood as user's active mood mix
|
||||
* description: Generates a mix for the mood and saves it as the user's "Your X Mix" on the home page
|
||||
* tags: [Mixes]
|
||||
* security:
|
||||
* - sessionAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: mood
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Mood mix saved and returned for immediate playback
|
||||
* 400:
|
||||
* description: Invalid mood or not enough tracks
|
||||
*/
|
||||
router.post("/mood/buckets/:mood/save", async (req, res) => {
|
||||
try {
|
||||
const userId = getRequestUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const mood = req.params.mood as MoodType;
|
||||
|
||||
if (!VALID_MOODS.includes(mood)) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid mood: ${mood}`,
|
||||
validMoods: VALID_MOODS,
|
||||
});
|
||||
}
|
||||
|
||||
const savedMix = await moodBucketService.saveUserMoodMix(userId, mood);
|
||||
|
||||
if (!savedMix) {
|
||||
return res.status(400).json({
|
||||
error: `Not enough tracks for mood: ${mood}`,
|
||||
suggestion: "Wait for more tracks to be analyzed",
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate mixes cache so home page refetches
|
||||
const cacheKey = `mixes:${userId}`;
|
||||
await redisClient.del(cacheKey);
|
||||
|
||||
// Load full track details for immediate playback
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { id: { in: savedMix.trackIds } },
|
||||
include: {
|
||||
album: {
|
||||
include: {
|
||||
artist: {
|
||||
select: { id: true, name: true, mbid: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve mix order
|
||||
const orderedTracks = savedMix.trackIds
|
||||
.map((id: string) => tracks.find((t) => t.id === id))
|
||||
.filter((t: any) => t !== undefined);
|
||||
|
||||
console.log(
|
||||
`[MIXES] Saved mood bucket mix for user ${userId}: ${mood} (${savedMix.trackCount} tracks)`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
mix: {
|
||||
...savedMix,
|
||||
tracks: orderedTracks,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Save mood bucket mix error:", error);
|
||||
res.status(500).json({ error: "Failed to save mood mix" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /mixes/mood/buckets/backfill:
|
||||
* post:
|
||||
* summary: Backfill mood buckets for all analyzed tracks
|
||||
* description: Admin endpoint to populate mood buckets for existing tracks
|
||||
* tags: [Mixes]
|
||||
* security:
|
||||
* - sessionAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Backfill completed
|
||||
*/
|
||||
router.post("/mood/buckets/backfill", async (req, res) => {
|
||||
try {
|
||||
const userId = getRequestUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
// TODO: Add admin check
|
||||
console.log(
|
||||
`[MIXES] Starting mood bucket backfill requested by user ${userId}`
|
||||
);
|
||||
|
||||
const result = await moodBucketService.backfillAllTracks();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
processed: result.processed,
|
||||
assigned: result.assigned,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Backfill mood buckets error:", error);
|
||||
res.status(500).json({ error: "Failed to backfill mood buckets" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /mixes/refresh:
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Mood Bucket Service
|
||||
*
|
||||
* Handles pre-computed mood assignments for fast mood mix generation.
|
||||
* Tracks are assigned to mood buckets during audio analysis, enabling
|
||||
* instant mood mix generation through simple database lookups.
|
||||
*/
|
||||
|
||||
import { prisma } from "../utils/db";
|
||||
|
||||
// Mood configuration with scoring rules
|
||||
// Primary = uses ML mood predictions (enhanced mode)
|
||||
// Fallback = uses basic audio features (standard mode)
|
||||
export const MOOD_CONFIG = {
|
||||
happy: {
|
||||
name: "Happy & Upbeat",
|
||||
color: "from-yellow-400 to-orange-500",
|
||||
icon: "Smile",
|
||||
// Primary: ML mood prediction
|
||||
primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } },
|
||||
// Fallback: basic audio features
|
||||
fallback: { valence: { min: 0.6 }, energy: { min: 0.5 } },
|
||||
},
|
||||
sad: {
|
||||
name: "Melancholic",
|
||||
color: "from-blue-600 to-indigo-700",
|
||||
icon: "CloudRain",
|
||||
primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } },
|
||||
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
|
||||
},
|
||||
chill: {
|
||||
name: "Chill & Relaxed",
|
||||
color: "from-teal-400 to-cyan-500",
|
||||
icon: "Wind",
|
||||
primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } },
|
||||
fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } },
|
||||
},
|
||||
energetic: {
|
||||
name: "High Energy",
|
||||
color: "from-red-500 to-orange-600",
|
||||
icon: "Zap",
|
||||
primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } },
|
||||
fallback: { bpm: { min: 120 }, energy: { min: 0.7 } },
|
||||
},
|
||||
party: {
|
||||
name: "Dance Party",
|
||||
color: "from-pink-500 to-rose-600",
|
||||
icon: "PartyPopper",
|
||||
primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } },
|
||||
fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } },
|
||||
},
|
||||
focus: {
|
||||
name: "Focus Mode",
|
||||
color: "from-purple-600 to-violet-700",
|
||||
icon: "Brain",
|
||||
primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } },
|
||||
fallback: {
|
||||
instrumentalness: { min: 0.5 },
|
||||
energy: { min: 0.2, max: 0.6 },
|
||||
},
|
||||
},
|
||||
melancholy: {
|
||||
name: "Deep Feels",
|
||||
color: "from-gray-700 to-slate-800",
|
||||
icon: "Moon",
|
||||
primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } },
|
||||
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
|
||||
},
|
||||
aggressive: {
|
||||
name: "Intense",
|
||||
color: "from-red-700 to-gray-900",
|
||||
icon: "Flame",
|
||||
primary: { moodAggressive: { min: 0.5 } },
|
||||
fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } },
|
||||
},
|
||||
acoustic: {
|
||||
name: "Acoustic Vibes",
|
||||
color: "from-amber-500 to-yellow-600",
|
||||
icon: "Guitar",
|
||||
primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
|
||||
fallback: {
|
||||
acousticness: { min: 0.6 },
|
||||
energy: { min: 0.3, max: 0.6 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MoodType = keyof typeof MOOD_CONFIG;
|
||||
export const VALID_MOODS = Object.keys(MOOD_CONFIG) as MoodType[];
|
||||
|
||||
// Mood gradient colors for mix display
|
||||
const MOOD_GRADIENTS: Record<MoodType, string> = {
|
||||
happy: "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))",
|
||||
sad: "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
chill: "linear-gradient(to bottom, rgba(17, 94, 89, 0.6), rgba(22, 78, 99, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
energetic:
|
||||
"linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))",
|
||||
party: "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))",
|
||||
focus: "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
melancholy:
|
||||
"linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(30, 58, 138, 0.5), rgba(17, 24, 39, 0.4))",
|
||||
aggressive:
|
||||
"linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))",
|
||||
acoustic:
|
||||
"linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))",
|
||||
};
|
||||
|
||||
interface TrackWithAnalysis {
|
||||
id: string;
|
||||
analysisMode: string | null;
|
||||
moodHappy: number | null;
|
||||
moodSad: number | null;
|
||||
moodRelaxed: number | null;
|
||||
moodAggressive: number | null;
|
||||
moodParty: number | null;
|
||||
moodAcoustic: number | null;
|
||||
moodElectronic: number | null;
|
||||
valence: number | null;
|
||||
energy: number | null;
|
||||
arousal: number | null;
|
||||
danceability: number | null;
|
||||
acousticness: number | null;
|
||||
instrumentalness: number | null;
|
||||
bpm: number | null;
|
||||
keyScale: string | null;
|
||||
}
|
||||
|
||||
export class MoodBucketService {
|
||||
/**
|
||||
* Calculate mood scores for a track and assign to appropriate buckets
|
||||
* Called after audio analysis completes
|
||||
* Returns array of mood names the track was assigned to
|
||||
*/
|
||||
async assignTrackToMoods(trackId: string): Promise<string[]> {
|
||||
const track = await prisma.track.findUnique({
|
||||
where: { id: trackId },
|
||||
select: {
|
||||
id: true,
|
||||
analysisStatus: true,
|
||||
analysisMode: true,
|
||||
moodHappy: true,
|
||||
moodSad: true,
|
||||
moodRelaxed: true,
|
||||
moodAggressive: true,
|
||||
moodParty: true,
|
||||
moodAcoustic: true,
|
||||
moodElectronic: true,
|
||||
valence: true,
|
||||
energy: true,
|
||||
arousal: true,
|
||||
danceability: true,
|
||||
acousticness: true,
|
||||
instrumentalness: true,
|
||||
bpm: true,
|
||||
keyScale: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!track || track.analysisStatus !== "completed") {
|
||||
console.log(
|
||||
`[MoodBucket] Track ${trackId} not analyzed yet, skipping`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const moodScores = this.calculateMoodScores(track);
|
||||
|
||||
// Upsert mood bucket entries for each mood with score > 0
|
||||
const upsertPromises = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood, score]) =>
|
||||
prisma.moodBucket.upsert({
|
||||
where: {
|
||||
trackId_mood: { trackId, mood },
|
||||
},
|
||||
create: {
|
||||
trackId,
|
||||
mood,
|
||||
score,
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Also delete mood buckets where score dropped to 0
|
||||
const deletePromises = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score === 0)
|
||||
.map(([mood]) =>
|
||||
prisma.moodBucket.deleteMany({
|
||||
where: { trackId, mood },
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([...upsertPromises, ...deletePromises]);
|
||||
|
||||
const assignedMoods = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood]) => mood);
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Track ${trackId} assigned to moods: ${
|
||||
assignedMoods.join(", ") || "none"
|
||||
}`
|
||||
);
|
||||
|
||||
return assignedMoods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mood scores for a track based on its audio features
|
||||
* Returns a score 0-1 for each mood (0 = not matching, 1 = perfect match)
|
||||
*/
|
||||
calculateMoodScores(track: TrackWithAnalysis): Record<MoodType, number> {
|
||||
const isEnhanced = track.analysisMode === "enhanced";
|
||||
const scores: Record<MoodType, number> = {
|
||||
happy: 0,
|
||||
sad: 0,
|
||||
chill: 0,
|
||||
energetic: 0,
|
||||
party: 0,
|
||||
focus: 0,
|
||||
melancholy: 0,
|
||||
aggressive: 0,
|
||||
acoustic: 0,
|
||||
};
|
||||
|
||||
for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
|
||||
const rules = isEnhanced ? config.primary : config.fallback;
|
||||
const score = this.evaluateMoodRules(track, rules);
|
||||
scores[mood as MoodType] = score;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate mood rules against track features
|
||||
* Returns a score 0-1 based on how well the track matches the rules
|
||||
*/
|
||||
private evaluateMoodRules(
|
||||
track: TrackWithAnalysis,
|
||||
rules: Record<string, any>
|
||||
): number {
|
||||
let totalScore = 0;
|
||||
let ruleCount = 0;
|
||||
|
||||
for (const [field, constraints] of Object.entries(rules)) {
|
||||
const value = track[field as keyof TrackWithAnalysis];
|
||||
|
||||
// Skip if value is null
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ruleCount++;
|
||||
|
||||
// Handle string equality (e.g., keyScale: "minor")
|
||||
if (typeof constraints === "string") {
|
||||
totalScore += value === constraints ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle numeric range constraints
|
||||
const numValue = value as number;
|
||||
const { min, max } = constraints as { min?: number; max?: number };
|
||||
|
||||
// Calculate how well the value matches the constraint
|
||||
let fieldScore = 0;
|
||||
|
||||
if (min !== undefined && max !== undefined) {
|
||||
// Range constraint - value should be between min and max
|
||||
if (numValue >= min && numValue <= max) {
|
||||
// Perfect match in range
|
||||
fieldScore = 1;
|
||||
} else if (numValue < min) {
|
||||
// Below range - linearly decrease score
|
||||
fieldScore = Math.max(0, 1 - (min - numValue) * 2);
|
||||
} else {
|
||||
// Above range - linearly decrease score
|
||||
fieldScore = Math.max(0, 1 - (numValue - max) * 2);
|
||||
}
|
||||
} else if (min !== undefined) {
|
||||
// Minimum constraint - higher is better
|
||||
if (numValue >= min) {
|
||||
// Score increases with value above threshold
|
||||
fieldScore = Math.min(1, 0.5 + (numValue - min) * 0.5);
|
||||
} else {
|
||||
// Below minimum - partial credit
|
||||
fieldScore = Math.max(0, (numValue / min) * 0.5);
|
||||
}
|
||||
} else if (max !== undefined) {
|
||||
// Maximum constraint - lower is better
|
||||
if (numValue <= max) {
|
||||
// Score increases as value decreases below threshold
|
||||
fieldScore = Math.min(1, 0.5 + (max - numValue) * 0.5);
|
||||
} else {
|
||||
// Above maximum - partial credit
|
||||
fieldScore = Math.max(
|
||||
0,
|
||||
((1 - numValue) / (1 - max)) * 0.5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += fieldScore;
|
||||
}
|
||||
|
||||
// No rules matched (missing data)
|
||||
if (ruleCount === 0) return 0;
|
||||
|
||||
// Average score across all rules, with minimum threshold
|
||||
const avgScore = totalScore / ruleCount;
|
||||
|
||||
// Only assign to mood if score is above 0.5 threshold
|
||||
return avgScore >= 0.5 ? avgScore : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mood presets with track counts for the UI
|
||||
*/
|
||||
async getMoodPresets(): Promise<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
trackCount: number;
|
||||
}[]
|
||||
> {
|
||||
// Count tracks per mood in parallel
|
||||
const countPromises = VALID_MOODS.map(async (mood) => {
|
||||
const count = await prisma.moodBucket.count({
|
||||
where: { mood, score: { gte: 0.5 } },
|
||||
});
|
||||
const config = MOOD_CONFIG[mood];
|
||||
return {
|
||||
id: mood,
|
||||
name: config.name,
|
||||
color: config.color,
|
||||
icon: config.icon,
|
||||
trackCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.all(countPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mood mix for a specific mood
|
||||
* Fast lookup from pre-computed MoodBucket table
|
||||
*/
|
||||
async getMoodMix(
|
||||
mood: MoodType,
|
||||
limit: number = 15
|
||||
): Promise<{
|
||||
id: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
} | null> {
|
||||
if (!VALID_MOODS.includes(mood)) {
|
||||
throw new Error(`Invalid mood: ${mood}`);
|
||||
}
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
|
||||
// Get top tracks for this mood, randomly sampled
|
||||
// First get IDs with high scores, then randomly select
|
||||
const moodBuckets = await prisma.moodBucket.findMany({
|
||||
where: { mood, score: { gte: 0.5 } },
|
||||
select: { trackId: true, score: true },
|
||||
orderBy: { score: "desc" },
|
||||
take: 100, // Pool to sample from
|
||||
});
|
||||
|
||||
if (moodBuckets.length < 8) {
|
||||
console.log(
|
||||
`[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Randomly sample from the pool
|
||||
const shuffled = [...moodBuckets].sort(() => Math.random() - 0.5);
|
||||
const selectedIds = shuffled.slice(0, limit).map((b) => b.trackId);
|
||||
|
||||
// Get cover URLs for the selected tracks
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { id: { in: selectedIds } },
|
||||
select: {
|
||||
id: true,
|
||||
album: { select: { coverUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve order of selectedIds
|
||||
const orderedTracks = selectedIds
|
||||
.map((id) => tracks.find((t) => t.id === id))
|
||||
.filter(Boolean);
|
||||
const coverUrls = orderedTracks
|
||||
.filter((t) => t?.album.coverUrl)
|
||||
.slice(0, 4)
|
||||
.map((t) => t!.album.coverUrl!);
|
||||
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
id: `mood-${mood}-${timestamp}`,
|
||||
mood,
|
||||
name: `${config.name} Mix`,
|
||||
description: `Tracks that match your ${config.name.toLowerCase()} vibe`,
|
||||
trackIds: orderedTracks.map((t) => t!.id),
|
||||
coverUrls,
|
||||
trackCount: orderedTracks.length,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a mood mix as the user's active mood mix
|
||||
* Returns the saved mix for immediate UI update
|
||||
*/
|
||||
async saveUserMoodMix(
|
||||
userId: string,
|
||||
mood: MoodType,
|
||||
limit: number = 15
|
||||
): Promise<{
|
||||
id: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
generatedAt: string;
|
||||
} | null> {
|
||||
// Generate a fresh mix
|
||||
const mix = await this.getMoodMix(mood, limit);
|
||||
if (!mix) return null;
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
const generatedAt = new Date();
|
||||
|
||||
// Upsert the user's mood mix
|
||||
await prisma.userMoodMix.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
mood,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
generatedAt,
|
||||
},
|
||||
update: {
|
||||
mood,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
generatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)`
|
||||
);
|
||||
|
||||
// Return with user-specific naming
|
||||
return {
|
||||
id: `your-mood-mix-${generatedAt.getTime()}`,
|
||||
mood,
|
||||
name: `Your ${config.name} Mix`,
|
||||
description: `Based on your ${config.name.toLowerCase()} preferences`,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
trackCount: mix.trackCount,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current saved mood mix for display on home page
|
||||
*/
|
||||
async getUserMoodMix(userId: string): Promise<{
|
||||
id: string;
|
||||
type: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
} | null> {
|
||||
const userMix = await prisma.userMoodMix.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!userMix) return null;
|
||||
|
||||
const mood = userMix.mood as MoodType;
|
||||
if (!VALID_MOODS.includes(mood)) return null;
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
|
||||
return {
|
||||
id: `your-mood-mix-${userMix.generatedAt.getTime()}`,
|
||||
type: "mood",
|
||||
mood,
|
||||
name: `Your ${config.name} Mix`,
|
||||
description: `Based on your ${config.name.toLowerCase()} preferences`,
|
||||
trackIds: userMix.trackIds,
|
||||
coverUrls: userMix.coverUrls,
|
||||
trackCount: userMix.trackIds.length,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill mood buckets for all analyzed tracks
|
||||
* Used for initial population or after schema changes
|
||||
*/
|
||||
async backfillAllTracks(
|
||||
batchSize: number = 100
|
||||
): Promise<{ processed: number; assigned: number }> {
|
||||
let processed = 0;
|
||||
let assigned = 0;
|
||||
let skip = 0;
|
||||
|
||||
console.log("[MoodBucket] Starting backfill of all analyzed tracks...");
|
||||
|
||||
while (true) {
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { analysisStatus: "completed" },
|
||||
select: {
|
||||
id: true,
|
||||
analysisMode: true,
|
||||
moodHappy: true,
|
||||
moodSad: true,
|
||||
moodRelaxed: true,
|
||||
moodAggressive: true,
|
||||
moodParty: true,
|
||||
moodAcoustic: true,
|
||||
moodElectronic: true,
|
||||
valence: true,
|
||||
energy: true,
|
||||
arousal: true,
|
||||
danceability: true,
|
||||
acousticness: true,
|
||||
instrumentalness: true,
|
||||
bpm: true,
|
||||
keyScale: true,
|
||||
},
|
||||
skip,
|
||||
take: batchSize,
|
||||
});
|
||||
|
||||
if (tracks.length === 0) break;
|
||||
|
||||
for (const track of tracks) {
|
||||
const moodScores = this.calculateMoodScores(track);
|
||||
const moodsToAssign = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood, score]) => ({
|
||||
trackId: track.id,
|
||||
mood,
|
||||
score,
|
||||
}));
|
||||
|
||||
if (moodsToAssign.length > 0) {
|
||||
// Use upsert for each mood
|
||||
await Promise.all(
|
||||
moodsToAssign.map((data) =>
|
||||
prisma.moodBucket.upsert({
|
||||
where: {
|
||||
trackId_mood: {
|
||||
trackId: data.trackId,
|
||||
mood: data.mood,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
trackId: data.trackId,
|
||||
mood: data.mood,
|
||||
score: data.score,
|
||||
},
|
||||
update: {
|
||||
score: data.score,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
assigned += moodsToAssign.length;
|
||||
}
|
||||
|
||||
processed++;
|
||||
}
|
||||
|
||||
skip += batchSize;
|
||||
console.log(
|
||||
`[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments`
|
||||
);
|
||||
return { processed, assigned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mood bucket data for a track
|
||||
* Used when a track is re-analyzed
|
||||
*/
|
||||
async clearTrackMoods(trackId: string): Promise<void> {
|
||||
await prisma.moodBucket.deleteMany({
|
||||
where: { trackId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const moodBucketService = new MoodBucketService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
try {
|
||||
// If artist has a temp MBID, try to get the real one from MusicBrainz
|
||||
if (artist.mbid.startsWith("temp-")) {
|
||||
console.log(`${logPrefix} Temp MBID detected, searching MusicBrainz...`);
|
||||
console.log(
|
||||
`${logPrefix} Temp MBID detected, searching MusicBrainz...`
|
||||
);
|
||||
try {
|
||||
const mbResults = await musicBrainzService.searchArtist(
|
||||
artist.name,
|
||||
@@ -40,7 +42,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
);
|
||||
if (mbResults.length > 0 && mbResults[0].id) {
|
||||
const realMbid = mbResults[0].id;
|
||||
console.log(`${logPrefix} MusicBrainz: Found real MBID: ${realMbid}`);
|
||||
console.log(
|
||||
`${logPrefix} MusicBrainz: Found real MBID: ${realMbid}`
|
||||
);
|
||||
|
||||
// Update artist with real MBID
|
||||
await prisma.artist.update({
|
||||
@@ -51,10 +55,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
// Update the local artist object
|
||||
artist.mbid = realMbid;
|
||||
} else {
|
||||
console.log(`${logPrefix} MusicBrainz: No match found, keeping temp MBID`);
|
||||
console.log(
|
||||
`${logPrefix} MusicBrainz: No match found, keeping temp MBID`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} MusicBrainz: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} MusicBrainz: FAILED - ${
|
||||
error?.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +73,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
let heroUrl = null;
|
||||
|
||||
if (!artist.mbid.startsWith("temp-")) {
|
||||
console.log(`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`);
|
||||
console.log(
|
||||
`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`
|
||||
);
|
||||
try {
|
||||
const wikidataInfo = await wikidataService.getArtistInfo(
|
||||
artist.mbid
|
||||
@@ -73,12 +85,18 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
heroUrl = wikidataInfo.image;
|
||||
if (summary) summarySource = "wikidata";
|
||||
if (heroUrl) imageSource = "wikidata";
|
||||
console.log(`${logPrefix} Wikidata: SUCCESS (image: ${heroUrl ? "yes" : "no"}, summary: ${summary ? "yes" : "no"})`);
|
||||
console.log(
|
||||
`${logPrefix} Wikidata: SUCCESS (image: ${
|
||||
heroUrl ? "yes" : "no"
|
||||
}, summary: ${summary ? "yes" : "no"})`
|
||||
);
|
||||
} else {
|
||||
console.log(`${logPrefix} Wikidata: No data returned`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} Wikidata: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} Wikidata: FAILED - ${error?.message || error}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`${logPrefix} Wikidata: Skipped (temp MBID)`);
|
||||
@@ -86,7 +104,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
|
||||
// Fallback to Last.fm if Wikidata didn't work
|
||||
if (!summary || !heroUrl) {
|
||||
console.log(`${logPrefix} Last.fm: Fetching (need summary: ${!summary}, need image: ${!heroUrl})...`);
|
||||
console.log(
|
||||
`${logPrefix} Last.fm: Fetching (need summary: ${!summary}, need image: ${!heroUrl})...`
|
||||
);
|
||||
try {
|
||||
const validMbid = artist.mbid.startsWith("temp-")
|
||||
? undefined
|
||||
@@ -108,37 +128,63 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
|
||||
// Try Fanart.tv for image (only with real MBID)
|
||||
if (!heroUrl && !artist.mbid.startsWith("temp-")) {
|
||||
console.log(`${logPrefix} Fanart.tv: Fetching for MBID ${artist.mbid}...`);
|
||||
console.log(
|
||||
`${logPrefix} Fanart.tv: Fetching for MBID ${artist.mbid}...`
|
||||
);
|
||||
try {
|
||||
heroUrl = await fanartService.getArtistImage(
|
||||
artist.mbid
|
||||
);
|
||||
if (heroUrl) {
|
||||
imageSource = "fanart.tv";
|
||||
console.log(`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(0, 60)}...`);
|
||||
console.log(
|
||||
`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(
|
||||
0,
|
||||
60
|
||||
)}...`
|
||||
);
|
||||
} else {
|
||||
console.log(`${logPrefix} Fanart.tv: No image found`);
|
||||
console.log(
|
||||
`${logPrefix} Fanart.tv: No image found`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} Fanart.tv: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} Fanart.tv: FAILED - ${
|
||||
error?.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Deezer
|
||||
if (!heroUrl) {
|
||||
console.log(`${logPrefix} Deezer: Fetching for "${artist.name}"...`);
|
||||
console.log(
|
||||
`${logPrefix} Deezer: Fetching for "${artist.name}"...`
|
||||
);
|
||||
try {
|
||||
heroUrl = await deezerService.getArtistImage(
|
||||
artist.name
|
||||
);
|
||||
if (heroUrl) {
|
||||
imageSource = "deezer";
|
||||
console.log(`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(0, 60)}...`);
|
||||
console.log(
|
||||
`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(
|
||||
0,
|
||||
60
|
||||
)}...`
|
||||
);
|
||||
} else {
|
||||
console.log(`${logPrefix} Deezer: No image found`);
|
||||
console.log(
|
||||
`${logPrefix} Deezer: No image found`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} Deezer: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} Deezer: FAILED - ${
|
||||
error?.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +211,13 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
) {
|
||||
heroUrl = bestImage;
|
||||
imageSource = "lastfm";
|
||||
console.log(`${logPrefix} Last.fm image: SUCCESS`);
|
||||
console.log(
|
||||
`${logPrefix} Last.fm image: SUCCESS`
|
||||
);
|
||||
} else {
|
||||
console.log(`${logPrefix} Last.fm image: Placeholder/none`);
|
||||
console.log(
|
||||
`${logPrefix} Last.fm image: Placeholder/none`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +225,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
console.log(`${logPrefix} Last.fm: No data returned`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} Last.fm: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} Last.fm: FAILED - ${error?.message || error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +246,33 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
validMbid,
|
||||
artist.name
|
||||
);
|
||||
console.log(`${logPrefix} Similar artists: Found ${similarArtists.length}`);
|
||||
console.log(
|
||||
`${logPrefix} Similar artists: Found ${similarArtists.length}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.log(`${logPrefix} Similar artists: FAILED - ${error?.message || error}`);
|
||||
console.log(
|
||||
`${logPrefix} Similar artists: FAILED - ${
|
||||
error?.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Log enrichment summary
|
||||
console.log(`${logPrefix} SUMMARY: image=${imageSource}, summary=${summarySource}, heroUrl=${heroUrl ? "set" : "null"}`);
|
||||
console.log(
|
||||
`${logPrefix} SUMMARY: image=${imageSource}, summary=${summarySource}, heroUrl=${
|
||||
heroUrl ? "set" : "null"
|
||||
}`
|
||||
);
|
||||
|
||||
// Prepare similar artists JSON for storage (full Last.fm data)
|
||||
const similarArtistsJson =
|
||||
similarArtists.length > 0
|
||||
? similarArtists.map((s) => ({
|
||||
name: s.name,
|
||||
mbid: s.mbid || null,
|
||||
match: s.similarity,
|
||||
}))
|
||||
: null;
|
||||
|
||||
// Update artist with enriched data
|
||||
await prisma.artist.update({
|
||||
@@ -208,6 +280,7 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
data: {
|
||||
summary,
|
||||
heroUrl,
|
||||
similarArtistsJson,
|
||||
lastEnriched: new Date(),
|
||||
enrichmentStatus: "completed",
|
||||
},
|
||||
@@ -264,7 +337,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Stored ${similarArtists.length} similar artist relationships`);
|
||||
console.log(
|
||||
`${logPrefix} Stored ${similarArtists.length} similar artist relationships`
|
||||
);
|
||||
}
|
||||
|
||||
// ========== ALBUM COVER ENRICHMENT ==========
|
||||
@@ -274,13 +349,20 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
// Cache artist image in Redis for faster access
|
||||
if (heroUrl) {
|
||||
try {
|
||||
await redisClient.setEx(`hero:${artist.id}`, 7 * 24 * 60 * 60, heroUrl);
|
||||
await redisClient.setEx(
|
||||
`hero:${artist.id}`,
|
||||
7 * 24 * 60 * 60,
|
||||
heroUrl
|
||||
);
|
||||
} catch (err) {
|
||||
// Redis errors are non-critical
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${logPrefix} ENRICHMENT FAILED:`, error?.message || error);
|
||||
console.error(
|
||||
`${logPrefix} ENRICHMENT FAILED:`,
|
||||
error?.message || error
|
||||
);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.artist.update({
|
||||
@@ -296,16 +378,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
||||
* Enrich album covers for an artist
|
||||
* Fetches covers from Cover Art Archive for albums without covers
|
||||
*/
|
||||
async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null): Promise<void> {
|
||||
async function enrichAlbumCovers(
|
||||
artistId: string,
|
||||
artistHeroUrl: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find albums for this artist that don't have cover art
|
||||
const albumsWithoutCovers = await prisma.album.findMany({
|
||||
where: {
|
||||
artistId,
|
||||
OR: [
|
||||
{ coverUrl: null },
|
||||
{ coverUrl: "" },
|
||||
],
|
||||
OR: [{ coverUrl: null }, { coverUrl: "" }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -319,7 +401,9 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null)
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Fetching covers for ${albumsWithoutCovers.length} albums...`);
|
||||
console.log(
|
||||
` Fetching covers for ${albumsWithoutCovers.length} albums...`
|
||||
);
|
||||
|
||||
let fetchedCount = 0;
|
||||
const BATCH_SIZE = 3; // Limit concurrent requests
|
||||
@@ -327,14 +411,16 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null)
|
||||
// Process in batches to avoid overwhelming Cover Art Archive
|
||||
for (let i = 0; i < albumsWithoutCovers.length; i += BATCH_SIZE) {
|
||||
const batch = albumsWithoutCovers.slice(i, i + BATCH_SIZE);
|
||||
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (album) => {
|
||||
if (!album.rgMbid) return;
|
||||
|
||||
try {
|
||||
const coverUrl = await coverArtService.getCoverArt(album.rgMbid);
|
||||
|
||||
const coverUrl = await coverArtService.getCoverArt(
|
||||
album.rgMbid
|
||||
);
|
||||
|
||||
if (coverUrl) {
|
||||
// Save to database
|
||||
await prisma.album.update({
|
||||
@@ -363,7 +449,9 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` Fetched ${fetchedCount}/${albumsWithoutCovers.length} album covers`);
|
||||
console.log(
|
||||
` Fetched ${fetchedCount}/${albumsWithoutCovers.length} album covers`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(` Failed to enrich album covers:`, error);
|
||||
// Don't throw - album cover failures shouldn't fail the entire enrichment
|
||||
|
||||
@@ -9,6 +9,7 @@ import { processDiscoverWeekly } from "./processors/discoverProcessor";
|
||||
import { processImageOptimization } from "./processors/imageProcessor";
|
||||
import { processValidation } from "./processors/validationProcessor";
|
||||
import { startUnifiedEnrichmentWorker, stopUnifiedEnrichmentWorker } from "./unifiedEnrichment";
|
||||
import { startMoodBucketWorker, stopMoodBucketWorker } from "./moodBucketWorker";
|
||||
import { downloadQueueManager } from "../services/downloadQueue";
|
||||
import { prisma } from "../utils/db";
|
||||
import { startDiscoverWeeklyCron, stopDiscoverWeeklyCron } from "./discoverCron";
|
||||
@@ -77,6 +78,12 @@ startUnifiedEnrichmentWorker().catch((err) => {
|
||||
console.error("Failed to start unified enrichment worker:", err);
|
||||
});
|
||||
|
||||
// Start mood bucket worker
|
||||
// Assigns newly analyzed tracks to mood buckets for fast mood mix generation
|
||||
startMoodBucketWorker().catch((err) => {
|
||||
console.error("Failed to start mood bucket worker:", err);
|
||||
});
|
||||
|
||||
// Event handlers for scan queue
|
||||
scanQueue.on("completed", (job, result) => {
|
||||
console.log(
|
||||
@@ -229,6 +236,9 @@ export async function shutdownWorkers(): Promise<void> {
|
||||
// Stop unified enrichment worker
|
||||
stopUnifiedEnrichmentWorker();
|
||||
|
||||
// Stop mood bucket worker
|
||||
stopMoodBucketWorker();
|
||||
|
||||
// Stop discover weekly cron
|
||||
stopDiscoverWeeklyCron();
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Mood Bucket Worker
|
||||
*
|
||||
* This worker runs in the background and assigns newly analyzed tracks
|
||||
* to mood buckets. It watches for tracks that have:
|
||||
* - analysisStatus = 'completed'
|
||||
* - No existing MoodBucket entries
|
||||
*
|
||||
* This is separate from the Python audio analyzer to keep mood bucket
|
||||
* logic in TypeScript and avoid modifying the Python code.
|
||||
*/
|
||||
|
||||
import { prisma } from "../utils/db";
|
||||
import { moodBucketService } from "../services/moodBucketService";
|
||||
|
||||
// Configuration
|
||||
const BATCH_SIZE = 50;
|
||||
const WORKER_INTERVAL_MS = 30 * 1000; // Run every 30 seconds
|
||||
|
||||
let isRunning = false;
|
||||
let workerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Start the mood bucket worker
|
||||
*/
|
||||
export async function startMoodBucketWorker() {
|
||||
console.log("\n=== Starting Mood Bucket Worker ===");
|
||||
console.log(` Batch size: ${BATCH_SIZE}`);
|
||||
console.log(` Interval: ${WORKER_INTERVAL_MS / 1000}s`);
|
||||
console.log("");
|
||||
|
||||
// Run immediately
|
||||
await processNewlyAnalyzedTracks();
|
||||
|
||||
// Then run at interval
|
||||
workerInterval = setInterval(async () => {
|
||||
await processNewlyAnalyzedTracks();
|
||||
}, WORKER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the mood bucket worker
|
||||
*/
|
||||
export function stopMoodBucketWorker() {
|
||||
if (workerInterval) {
|
||||
clearInterval(workerInterval);
|
||||
workerInterval = null;
|
||||
console.log("[Mood Bucket] Worker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process newly analyzed tracks that don't have mood bucket assignments
|
||||
*/
|
||||
async function processNewlyAnalyzedTracks(): Promise<number> {
|
||||
if (isRunning) return 0;
|
||||
|
||||
try {
|
||||
isRunning = true;
|
||||
|
||||
// Find tracks that are analyzed but not in any mood bucket
|
||||
// We use a subquery to find tracks without mood bucket entries
|
||||
const tracksWithoutBuckets = await prisma.track.findMany({
|
||||
where: {
|
||||
analysisStatus: "completed",
|
||||
moodBuckets: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
take: BATCH_SIZE,
|
||||
orderBy: {
|
||||
analyzedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (tracksWithoutBuckets.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Mood Bucket] Processing ${tracksWithoutBuckets.length} newly analyzed tracks...`
|
||||
);
|
||||
|
||||
let assigned = 0;
|
||||
for (const track of tracksWithoutBuckets) {
|
||||
try {
|
||||
const moods = await moodBucketService.assignTrackToMoods(
|
||||
track.id
|
||||
);
|
||||
if (moods.length > 0) {
|
||||
assigned++;
|
||||
console.log(` ✓ ${track.title}: [${moods.join(", ")}]`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
` ✗ ${track.title}: ${error?.message || error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Mood Bucket] Assigned ${assigned}/${tracksWithoutBuckets.length} tracks to mood buckets`
|
||||
);
|
||||
|
||||
return assigned;
|
||||
} catch (error) {
|
||||
console.error("[Mood Bucket] Worker error:", error);
|
||||
return 0;
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mood bucket assignment progress
|
||||
*/
|
||||
export async function getMoodBucketProgress() {
|
||||
const totalAnalyzed = await prisma.track.count({
|
||||
where: { analysisStatus: "completed" },
|
||||
});
|
||||
|
||||
const withBuckets = await prisma.track.count({
|
||||
where: {
|
||||
analysisStatus: "completed",
|
||||
moodBuckets: {
|
||||
some: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get counts per mood
|
||||
const moodCounts = await prisma.moodBucket.groupBy({
|
||||
by: ["mood"],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const moodDistribution: Record<string, number> = {};
|
||||
for (const mc of moodCounts) {
|
||||
moodDistribution[mc.mood] = mc._count;
|
||||
}
|
||||
|
||||
return {
|
||||
totalAnalyzed,
|
||||
withBuckets,
|
||||
pending: totalAnalyzed - withBuckets,
|
||||
progress:
|
||||
totalAnalyzed > 0
|
||||
? Math.round((withBuckets / totalAnalyzed) * 100)
|
||||
: 0,
|
||||
moodDistribution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger mood bucket assignment for all analyzed tracks
|
||||
* (Used for initial backfill or re-processing)
|
||||
*/
|
||||
export async function backfillMoodBuckets(): Promise<{
|
||||
processed: number;
|
||||
assigned: number;
|
||||
}> {
|
||||
console.log("[Mood Bucket] Starting full backfill...");
|
||||
return moodBucketService.backfillAllTracks();
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Unified Enrichment Worker
|
||||
*
|
||||
*
|
||||
* Handles ALL enrichment in one place:
|
||||
* - Artist metadata (Last.fm, MusicBrainz)
|
||||
* - Track mood tags (Last.fm)
|
||||
* - Audio analysis (triggers Essentia via Redis queue)
|
||||
*
|
||||
*
|
||||
* Two modes:
|
||||
* 1. FULL: Re-enriches everything regardless of status (Settings > Enrich)
|
||||
* 2. INCREMENTAL: Only new material and incomplete items (Sync)
|
||||
@@ -29,27 +29,75 @@ let redis: Redis | null = null;
|
||||
// Mood tags to extract from Last.fm
|
||||
const MOOD_TAGS = new Set([
|
||||
// Energy/Activity
|
||||
"chill", "relax", "relaxing", "calm", "peaceful", "ambient",
|
||||
"energetic", "upbeat", "hype", "party", "dance",
|
||||
"workout", "gym", "running", "exercise", "motivation",
|
||||
"chill",
|
||||
"relax",
|
||||
"relaxing",
|
||||
"calm",
|
||||
"peaceful",
|
||||
"ambient",
|
||||
"energetic",
|
||||
"upbeat",
|
||||
"hype",
|
||||
"party",
|
||||
"dance",
|
||||
"workout",
|
||||
"gym",
|
||||
"running",
|
||||
"exercise",
|
||||
"motivation",
|
||||
// Emotions
|
||||
"sad", "melancholy", "melancholic", "depressing", "heartbreak",
|
||||
"happy", "feel good", "feel-good", "joyful", "uplifting",
|
||||
"angry", "aggressive", "intense",
|
||||
"romantic", "love", "sensual",
|
||||
"sad",
|
||||
"melancholy",
|
||||
"melancholic",
|
||||
"depressing",
|
||||
"heartbreak",
|
||||
"happy",
|
||||
"feel good",
|
||||
"feel-good",
|
||||
"joyful",
|
||||
"uplifting",
|
||||
"angry",
|
||||
"aggressive",
|
||||
"intense",
|
||||
"romantic",
|
||||
"love",
|
||||
"sensual",
|
||||
// Time/Setting
|
||||
"night", "late night", "evening", "morning",
|
||||
"summer", "winter", "rainy", "sunny",
|
||||
"driving", "road trip", "travel",
|
||||
"night",
|
||||
"late night",
|
||||
"evening",
|
||||
"morning",
|
||||
"summer",
|
||||
"winter",
|
||||
"rainy",
|
||||
"sunny",
|
||||
"driving",
|
||||
"road trip",
|
||||
"travel",
|
||||
// Activity
|
||||
"study", "focus", "concentration", "work",
|
||||
"sleep", "sleeping", "bedtime",
|
||||
"study",
|
||||
"focus",
|
||||
"concentration",
|
||||
"work",
|
||||
"sleep",
|
||||
"sleeping",
|
||||
"bedtime",
|
||||
// Vibe
|
||||
"dreamy", "atmospheric", "ethereal", "spacey",
|
||||
"groovy", "funky", "smooth",
|
||||
"dark", "moody", "brooding",
|
||||
"epic", "cinematic", "dramatic",
|
||||
"nostalgic", "throwback",
|
||||
"dreamy",
|
||||
"atmospheric",
|
||||
"ethereal",
|
||||
"spacey",
|
||||
"groovy",
|
||||
"funky",
|
||||
"smooth",
|
||||
"dark",
|
||||
"moody",
|
||||
"brooding",
|
||||
"epic",
|
||||
"cinematic",
|
||||
"dramatic",
|
||||
"nostalgic",
|
||||
"throwback",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -57,8 +105,8 @@ const MOOD_TAGS = new Set([
|
||||
*/
|
||||
function filterMoodTags(tags: string[]): string[] {
|
||||
return tags
|
||||
.map(t => t.toLowerCase().trim())
|
||||
.filter(t => {
|
||||
.map((t) => t.toLowerCase().trim())
|
||||
.filter((t) => {
|
||||
if (MOOD_TAGS.has(t)) return true;
|
||||
for (const mood of MOOD_TAGS) {
|
||||
if (t.includes(mood) || mood.includes(t)) return true;
|
||||
@@ -122,36 +170,36 @@ export async function runFullEnrichment(): Promise<{
|
||||
audioQueued: number;
|
||||
}> {
|
||||
console.log("\n=== FULL ENRICHMENT: Re-enriching everything ===\n");
|
||||
|
||||
|
||||
// Reset all statuses to pending
|
||||
await prisma.artist.updateMany({
|
||||
data: { enrichmentStatus: "pending" }
|
||||
data: { enrichmentStatus: "pending" },
|
||||
});
|
||||
|
||||
|
||||
await prisma.track.updateMany({
|
||||
data: {
|
||||
data: {
|
||||
lastfmTags: [],
|
||||
analysisStatus: "pending"
|
||||
}
|
||||
analysisStatus: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Now run the enrichment cycle
|
||||
const result = await runEnrichmentCycle(true);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main enrichment cycle
|
||||
*
|
||||
*
|
||||
* Flow:
|
||||
* 1. Artist metadata (Last.fm/MusicBrainz) - blocking, required for track enrichment
|
||||
* 2. Track tags (Last.fm mood tags) - blocking, quick API calls
|
||||
* 3. Audio analysis (Essentia) - NON-BLOCKING, queued to Redis for background processing
|
||||
*
|
||||
*
|
||||
* Steps 1 & 2 must complete before enrichment is "done".
|
||||
* Step 3 runs entirely in background via the audio-analyzer Docker container.
|
||||
*
|
||||
*
|
||||
* @param fullMode - If true, processes everything. If false, only pending items.
|
||||
*/
|
||||
async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
||||
@@ -171,25 +219,30 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
||||
try {
|
||||
// Step 1: Enrich artists (blocking - required for step 2)
|
||||
artistsProcessed = await enrichArtistsBatch();
|
||||
|
||||
|
||||
// Step 2: Enrich track tags from Last.fm (blocking - quick API calls)
|
||||
tracksProcessed = await enrichTrackTagsBatch();
|
||||
|
||||
|
||||
// Step 3: Queue audio analysis (NON-BLOCKING)
|
||||
// Just adds to Redis queue - actual processing happens in audio-analyzer container
|
||||
// This is intentionally fire-and-forget so it doesn't slow down enrichment
|
||||
audioQueued = await queueAudioAnalysis();
|
||||
|
||||
|
||||
// Log progress (only if work was done)
|
||||
if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0) {
|
||||
const progress = await getEnrichmentProgress();
|
||||
console.log(`\n[Enrichment Progress]`);
|
||||
console.log(` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)`);
|
||||
console.log(` Track Tags: ${progress.trackTags.enriched}/${progress.trackTags.total} (${progress.trackTags.progress}%)`);
|
||||
console.log(` Audio Analysis: ${progress.audioAnalysis.completed}/${progress.audioAnalysis.total} (${progress.audioAnalysis.progress}%) [background]`);
|
||||
console.log(
|
||||
` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)`
|
||||
);
|
||||
console.log(
|
||||
` Track Tags: ${progress.trackTags.enriched}/${progress.trackTags.total} (${progress.trackTags.progress}%)`
|
||||
);
|
||||
console.log(
|
||||
` Audio Analysis: ${progress.audioAnalysis.completed}/${progress.audioAnalysis.total} (${progress.audioAnalysis.progress}%) [background]`
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Enrichment] Cycle error:", error);
|
||||
} finally {
|
||||
@@ -240,9 +293,13 @@ async function enrichArtistsBatch(): Promise<number> {
|
||||
async function enrichTrackTagsBatch(): Promise<number> {
|
||||
// Note: Nested orderBy on relations doesn't work with isEmpty filtering in Prisma
|
||||
// Track tag enrichment doesn't depend on artist enrichment status, so we just order by recency
|
||||
// Match both empty array AND null (newly scanned tracks have null, not [])
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: {
|
||||
lastfmTags: { equals: [] },
|
||||
OR: [
|
||||
{ lastfmTags: { equals: [] } },
|
||||
{ lastfmTags: { isEmpty: true } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
album: {
|
||||
@@ -262,21 +319,29 @@ async function enrichTrackTagsBatch(): Promise<number> {
|
||||
for (const track of tracks) {
|
||||
try {
|
||||
const artistName = track.album.artist.name;
|
||||
const trackInfo = await lastFmService.getTrackInfo(artistName, track.title);
|
||||
|
||||
const trackInfo = await lastFmService.getTrackInfo(
|
||||
artistName,
|
||||
track.title
|
||||
);
|
||||
|
||||
if (trackInfo?.toptags?.tag) {
|
||||
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
||||
const moodTags = filterMoodTags(allTags);
|
||||
|
||||
|
||||
await prisma.track.update({
|
||||
where: { id: track.id },
|
||||
data: {
|
||||
lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"]
|
||||
data: {
|
||||
lastfmTags:
|
||||
moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (moodTags.length > 0) {
|
||||
console.log(` ✓ ${track.title}: [${moodTags.slice(0, 3).join(", ")}...]`);
|
||||
console.log(
|
||||
` ✓ ${track.title}: [${moodTags
|
||||
.slice(0, 3)
|
||||
.join(", ")}...]`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await prisma.track.update({
|
||||
@@ -284,9 +349,9 @@ async function enrichTrackTagsBatch(): Promise<number> {
|
||||
data: { lastfmTags: ["_not_found"] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Rate limit
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch (error: any) {
|
||||
console.error(` ✗ ${track.title}: ${error?.message || error}`);
|
||||
}
|
||||
@@ -316,7 +381,9 @@ async function queueAudioAnalysis(): Promise<number> {
|
||||
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
console.log(`[Audio Analysis] Queueing ${tracks.length} tracks for Essentia...`);
|
||||
console.log(
|
||||
`[Audio Analysis] Queueing ${tracks.length} tracks for Essentia...`
|
||||
);
|
||||
|
||||
const redis = getRedis();
|
||||
let queued = 0;
|
||||
@@ -331,13 +398,13 @@ async function queueAudioAnalysis(): Promise<number> {
|
||||
filePath: track.filePath,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Mark as queued (processing)
|
||||
await prisma.track.update({
|
||||
where: { id: track.id },
|
||||
data: { analysisStatus: "processing" },
|
||||
});
|
||||
|
||||
|
||||
queued++;
|
||||
} catch (error) {
|
||||
console.error(` Failed to queue ${track.title}:`, error);
|
||||
@@ -353,7 +420,7 @@ async function queueAudioAnalysis(): Promise<number> {
|
||||
|
||||
/**
|
||||
* Get comprehensive enrichment progress
|
||||
*
|
||||
*
|
||||
* Returns separate progress for:
|
||||
* - Artists & Track Tags: "Core" enrichment (must complete before app is fully usable)
|
||||
* - Audio Analysis: "Background" enrichment (runs in separate container, non-blocking)
|
||||
@@ -364,17 +431,20 @@ export async function getEnrichmentProgress() {
|
||||
by: ["enrichmentStatus"],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
|
||||
const artistTotal = artistCounts.reduce((sum, s) => sum + s._count, 0);
|
||||
const artistCompleted = artistCounts.find(s => s.enrichmentStatus === "completed")?._count || 0;
|
||||
const artistPending = artistCounts.find(s => s.enrichmentStatus === "pending")?._count || 0;
|
||||
|
||||
const artistCompleted =
|
||||
artistCounts.find((s) => s.enrichmentStatus === "completed")?._count ||
|
||||
0;
|
||||
const artistPending =
|
||||
artistCounts.find((s) => s.enrichmentStatus === "pending")?._count || 0;
|
||||
|
||||
// Track tag progress
|
||||
const trackTotal = await prisma.track.count();
|
||||
const trackTagsEnriched = await prisma.track.count({
|
||||
where: { NOT: { lastfmTags: { equals: [] } } },
|
||||
});
|
||||
|
||||
|
||||
// Audio analysis progress (background task)
|
||||
const audioCompleted = await prisma.track.count({
|
||||
where: { analysisStatus: "completed" },
|
||||
@@ -391,24 +461,33 @@ export async function getEnrichmentProgress() {
|
||||
|
||||
// Core enrichment is complete when artists and track tags are done
|
||||
// Audio analysis is separate - it runs in background and doesn't block
|
||||
const coreComplete = artistPending === 0 && (trackTotal - trackTagsEnriched) === 0;
|
||||
|
||||
const coreComplete =
|
||||
artistPending === 0 && trackTotal - trackTagsEnriched === 0;
|
||||
|
||||
return {
|
||||
// Core enrichment (blocking)
|
||||
artists: {
|
||||
total: artistTotal,
|
||||
completed: artistCompleted,
|
||||
pending: artistPending,
|
||||
failed: artistCounts.find(s => s.enrichmentStatus === "failed")?._count || 0,
|
||||
progress: artistTotal > 0 ? Math.round((artistCompleted / artistTotal) * 100) : 0,
|
||||
failed:
|
||||
artistCounts.find((s) => s.enrichmentStatus === "failed")
|
||||
?._count || 0,
|
||||
progress:
|
||||
artistTotal > 0
|
||||
? Math.round((artistCompleted / artistTotal) * 100)
|
||||
: 0,
|
||||
},
|
||||
trackTags: {
|
||||
total: trackTotal,
|
||||
enriched: trackTagsEnriched,
|
||||
pending: trackTotal - trackTagsEnriched,
|
||||
progress: trackTotal > 0 ? Math.round((trackTagsEnriched / trackTotal) * 100) : 0,
|
||||
progress:
|
||||
trackTotal > 0
|
||||
? Math.round((trackTagsEnriched / trackTotal) * 100)
|
||||
: 0,
|
||||
},
|
||||
|
||||
|
||||
// Background enrichment (non-blocking, runs in audio-analyzer container)
|
||||
audioAnalysis: {
|
||||
total: trackTotal,
|
||||
@@ -416,13 +495,17 @@ export async function getEnrichmentProgress() {
|
||||
pending: audioPending,
|
||||
processing: audioProcessing,
|
||||
failed: audioFailed,
|
||||
progress: trackTotal > 0 ? Math.round((audioCompleted / trackTotal) * 100) : 0,
|
||||
progress:
|
||||
trackTotal > 0
|
||||
? Math.round((audioCompleted / trackTotal) * 100)
|
||||
: 0,
|
||||
isBackground: true, // Flag to indicate this runs separately
|
||||
},
|
||||
|
||||
|
||||
// Overall status
|
||||
coreComplete, // True when artists + track tags are done
|
||||
isFullyComplete: coreComplete && audioPending === 0 && audioProcessing === 0,
|
||||
isFullyComplete:
|
||||
coreComplete && audioPending === 0 && audioProcessing === 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,9 +516,9 @@ export async function enrichArtistNow(artistId: string) {
|
||||
const artist = await prisma.artist.findUnique({
|
||||
where: { id: artistId },
|
||||
});
|
||||
|
||||
|
||||
if (!artist) return;
|
||||
|
||||
|
||||
console.log(`[Enrichment] Enriching artist: ${artist.name}`);
|
||||
await enrichSimilarArtist(artist);
|
||||
}
|
||||
@@ -468,33 +551,35 @@ export async function enrichAlbumTracksNow(albumId: string) {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Enrichment] Enriching ${tracks.length} tracks for album ${albumId}`);
|
||||
|
||||
|
||||
console.log(
|
||||
`[Enrichment] Enriching ${tracks.length} tracks for album ${albumId}`
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
try {
|
||||
const trackInfo = await lastFmService.getTrackInfo(
|
||||
track.album.artist.name,
|
||||
track.title
|
||||
);
|
||||
|
||||
|
||||
if (trackInfo?.toptags?.tag) {
|
||||
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
||||
const moodTags = filterMoodTags(allTags);
|
||||
|
||||
|
||||
await prisma.track.update({
|
||||
where: { id: track.id },
|
||||
data: {
|
||||
lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
||||
data: {
|
||||
lastfmTags:
|
||||
moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
||||
analysisStatus: "pending", // Queue for audio analysis
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch (error) {
|
||||
console.error(`Failed to enrich track ${track.title}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user