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
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: