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:
+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:
|
||||
|
||||
Reference in New Issue
Block a user