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:
Kevin O'Neill
2025-12-26 13:06:17 -06:00
parent d8c608cf70
commit f8b464feec
28 changed files with 5328 additions and 1615 deletions
@@ -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");
+63 -11
View File
@@ -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
// ============================================
File diff suppressed because it is too large Load Diff
+327 -21
View File
@@ -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:
+626
View File
@@ -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
+123 -35
View File
@@ -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
+10
View File
@@ -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();
+168
View File
@@ -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();
}
+164 -79
View File
@@ -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);
}
}
}