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
+30 -24
View File
@@ -14,7 +14,7 @@ Lidify is built for music lovers who want the convenience of streaming services
## A Note on Native Apps
Lidify's web app and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now.
I got a little and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now.
Thanks for your patience while I work through this.
@@ -312,40 +312,40 @@ ALLOWED_ORIGINS=http://localhost:3030,https://lidify.yourdomain.com
Lidify uses several sensitive environment variables. Never commit your `.env` file.
| Variable | Purpose | Required |
| ------------------------- | ------------------------------ | ----------------- |
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
| `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Recommended |
| `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek |
| `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek |
| `LIDARR_API_KEY` | Lidarr integration | If using Lidarr |
| Variable | Purpose | Required |
| ------------------------ | ------------------------------ | --------------- |
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
| `SETTINGS_ENCRYPTION_KEY`| Encrypts stored credentials | Recommended |
| `SOULSEEK_USERNAME` | Soulseek login | If u sing Soulseek |
| `SOULSEEK_PASSWORD`- | Soulseek password - | If using S-oulseek |
| `LIDARR_AP I_KEY` | Lidarr integration | If using L idarr |
| `OPENAI_API_KEY` | AI features | Optional |
| `LASTFM_API_KEY` | Artist recommendations | Optional |
| `FANART_API_KEY` | Artist images | Optional |
| `LASTFM_API_KEY ` | Artist recommendations | Optional |
| `FANART_API_KEY` | Artist images | Optional |
### VPN Configuration (Optional)
### VPN Configurati on (Optional)
If using Mullvad VPN for Soulseek:
- Place WireGuard config in `backend/mullvad/` (gitignored)
- Never commit VPN credentials or private keys
- The `*.conf` and `key.txt` patterns are already in .gitignore
- Place Wi reGuard config in `ba ckend/mullvad/` (gitignored)
- Never commit VPN cred entials or private keys
- The `*.conf` and `key.txt` patterns are already in .git ignore
### Generating Secrets
```bash
```bas h
# Generate a secure session secret
openssl rand -base64 32
openss l rand - base64 32
# Generate encryption key
openssl rand -hex 32
```
### Network Security
### Network
Sec urity
- Lidify is designed for self-hosted LAN use
- For external access, use a reverse proxy with HTTPS
- Configure `ALLOWED_ORIGINS` for your domain
- Lidify is designed for self-hosted LAN use
- For exte rnal access, use a reverse proxy with HTTPS
- C o nfigure `ALLOWED_ORIGINS` for your domain
---
@@ -355,12 +355,12 @@ Lidify works beautifully on its own, but it becomes even more powerful when conn
### Lidarr
Connect Lidify to your Lidarr instance to request and download new music directly from the app.
Connect Lidify to your Lidarr instance to request and downloa d new music directly from the app.
**What you get:**
**What you get:**
- Browse artists and albums you don't own
- Request downloads with a single click
- Request downloads with a single click
- Discover Weekly playlists that automatically download new recommendations
- Automatic library sync when Lidarr finishes importing
@@ -666,6 +666,12 @@ Lidify wouldn't be possible without these services and projects:
- [Fanart.tv](https://fanart.tv/) - Artist images and artwork
- [Lidarr](https://lidarr.audio/) - Music collection management
- [Audiobookshelf](https://www.audiobookshelf.org/) - Audiobook and podcast server
- [Soulseek](https://www.slsknet.org/) - Peer-to-peer music sharing network
### AI Development Tools
- [Anthropic Claude](https://www.anthropic.com/) - AI pair programming via [OpenCode](https://github.com/sst/opencode) + [GitHub Copilot](https://github.com/features/copilot)
- [Qwen 3B](https://huggingface.co/Qwen) - Local AI assistance via [Roo Code](https://roocode.com/)
---
@@ -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);
}
}
}
+186
View File
@@ -0,0 +1,186 @@
# Lidify Bug Handoff Guide
**Date:** 2025-12-26
**Project:** Lidify Music Server
---
## Overview
This document provides context for continuing work on several bugs in the Lidify application. The previous agent made partial fixes but issues remain.
---
## Bug 1: Mood Mixer - Multiple Issues
### Current State
- **Generation is slow** - Takes too long to generate mood mixes
- **UI not updating** - The playlist card on home page doesn't refresh when generating new mood mixes
- **Stale playlist persists** - User created "Happy" mix first, then replaced it 3 times with other emotions, but the original "Happy" card still shows on the home page even though the actual music changes
### Root Cause Analysis Needed
1. **Slow generation**: Check `backend/src/services/programmaticPlaylists.ts` - the `generateMoodOnDemand()` function (line ~3104) may be doing expensive database queries
2. **UI not updating**: The frontend uses React Query. Check:
- `frontend/components/MoodMixer.tsx` - dispatches `window.dispatchEvent(new CustomEvent("mix-generated"))` and `"mixes-updated"` events
- The home page component needs to listen for these events and invalidate/refetch
3. **Stale card**: The mix ID or cache key may not be changing. Look at:
- How mood mix preferences are saved: `POST /mixes/mood/save-preferences`
- How the home page fetches the mix to display
### Files to Investigate
- `backend/src/services/programmaticPlaylists.ts` - Main playlist generation logic
- `backend/src/routes/mixes.ts` - API endpoints for mood mixes
- `frontend/components/MoodMixer.tsx` - UI component
- `frontend/app/page.tsx` - Home page that displays mix cards
- `frontend/hooks/useQueries.ts` - React Query hooks
### Previous Fix Attempt
The previous agent added fallback logic in `generateMoodOnDemand()` (lines 3126-3183) to handle cases where enhanced audio analysis isn't available. The fix converts ML mood params to basic audio features as a fallback. This may have introduced complexity or bugs.
### Suggested Approach
1. **Check database indexes** - The query in `generateMoodOnDemand` filters by `analysisStatus`, `analysisMode`, and various audio features. Ensure proper indexes exist.
2. **Simplify the query** - Consider limiting the initial pool of tracks rather than applying all filters at once
3. **Fix cache invalidation** - The home page likely caches the mix data. Need to ensure proper invalidation when a new mix is generated
4. **User said "might need complete overhaul"** - Consider refactoring the mood mixer to be simpler
---
## Bug 2: Podcast Seeking - Still Broken
### Current Symptoms
1. **Slow press required** - User has to slowly press ±30s buttons, otherwise nothing happens
2. **Spam causes stuck state** - If user presses button rapidly, playback gets stuck in "perpetual play state" where fast forward/rewind stops working entirely
### Root Cause
The podcast seeking logic is complex and involves:
1. Checking cache status via API
2. Reloading the Howler audio engine
3. Seeking to position after reload
4. Multiple timeout-based checks
### Previous Fix Attempts
The previous agent added:
1. **Seek operation ID tracking** (`seekOperationIdRef`) to abort stale seeks
2. **Debouncing** (150ms) for podcast seeks via `seekDebounceRef`
3. **Better cleanup** of listeners and timeouts
These fixes were incomplete or introduced new issues.
### Files to Investigate
- `frontend/components/player/HowlerAudioElement.tsx` - Main seek handling logic (lines 672-853)
- The seek handler is in `useEffect` starting around line 672
- Uses `seekDebounceRef` and `pendingSeekTimeRef` for debouncing
- Uses `seekOperationIdRef` to track/abort stale operations
- `frontend/lib/howler-engine.ts` - Low-level audio engine wrapper
- `reload()` method (line ~293) - calls cleanup then load
- `cleanup()` method (line ~438) - handles Howl instance teardown
- The `cleanup()` has a delayed unload for playing audio which may cause race conditions
### Key Code Sections
**HowlerAudioElement.tsx - Seek Handler (lines 672-853):**
```typescript
useEffect(() => {
const handleSeek = async (time: number) => {
seekOperationIdRef.current += 1;
const thisSeekId = seekOperationIdRef.current;
// For podcasts: debounce, check cache, reload if cached, etc.
// Complex async logic with multiple timeouts
};
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
return unsubscribe;
}, [...deps]);
```
**howler-engine.ts - Cleanup (lines ~438-480):**
```typescript
private cleanup(): void {
// Has delayed unload when wasPlaying (12ms fade + timeout)
// This may race with new loads
}
```
### Suggested Approach
1. **Remove or increase debounce** - 150ms might be too short or the logic is wrong
2. **Fix the stuck state** - Likely caused by event listeners not being properly cleaned up
3. **Simplify the reload logic** - The cache check → reload → seek → play chain is fragile
4. **Consider immediate cleanup** - Skip the fade delay for podcast seeks to prevent race conditions
5. **Add loading/disabled state** - Disable seek buttons while a seek is in progress
---
## Bug 3: Similar Artists ("Fans Also Like") - Partially Fixed
### Current State
The previous agent made fixes to show library artists in the "Fans Also Like" section. The fix:
- Checks if similar artists exist in the user's library
- Returns `inLibrary` flag and `ownedAlbumCount`
- Shows a library badge icon
### Files Modified
- `backend/src/routes/library.ts` - Enhanced similar artists response (lines 1369-1533)
- `frontend/features/artist/types.ts` - Added `inLibrary?: boolean` to SimilarArtist type
- `frontend/features/artist/components/SimilarArtists.tsx` - Updated UI and navigation logic
### Status
Believed to be working but needs verification.
---
## Development Environment
### Key Commands
```bash
# Backend
cd backend
npm run dev # Dev server with hot reload
npm run build # Production build
npx tsc --noEmit # Type check only
# Frontend
cd frontend
npm run dev # Dev server
npm run build # Production build
```
### Architecture
- **Backend**: Express.js + Prisma + PostgreSQL + Redis
- **Frontend**: Next.js 14 (App Router) + React Query + TailwindCSS
- **Audio**: Howler.js for playback
- **Analysis**: Essentia Docker container for audio feature extraction
### Database
- **Prisma** ORM with PostgreSQL
- Key models: Track, Album, Artist, User
- Tracks have `analysisStatus` and `analysisMode` fields for audio analysis state
---
## Priority Order
1. **Podcast seeking** - Most user-facing, core functionality broken
2. **Mood mixer UI not updating** - Confusing UX
3. **Mood mixer slow** - Performance issue
---
## Quick Reference - Key Files
| Feature | Backend | Frontend |
|---------|---------|----------|
| Mood Mixer | `services/programmaticPlaylists.ts`, `routes/mixes.ts` | `components/MoodMixer.tsx`, `app/page.tsx` |
| Podcast Seek | `routes/podcasts.ts` | `components/player/HowlerAudioElement.tsx`, `lib/howler-engine.ts` |
| Similar Artists | `routes/library.ts` (lines 1369-1533) | `features/artist/components/SimilarArtists.tsx` |
---
## Notes from Previous Agent
1. The mood mixer presets all use ML mood params (`moodHappy`, `moodSad`, etc.) which require `analysisMode: "enhanced"`. A fallback to basic audio features was added but may be causing issues.
2. The podcast seek debounce was added at 150ms with a `pendingSeekTimeRef` to coalesce rapid seeks. This approach may be flawed.
3. The Howler engine's `cleanup()` method has an intentional 12ms fade delay before unloading to reduce audio pops. This delay may cause race conditions with rapid reloads.
4. The `seekOperationIdRef` counter was intended to abort stale seek operations but the logic may have bugs in how it's checked across async boundaries.
+20 -3
View File
@@ -32,7 +32,10 @@ const MixCard = memo(
{mix.coverUrls.length > 0 ? (
<div className="grid grid-cols-2 gap-0 w-full h-full">
{mix.coverUrls.slice(0, 4).map((url, idx) => {
const proxiedUrl = api.getCoverArtUrl(url, 300);
const proxiedUrl = api.getCoverArtUrl(
url,
300
);
return (
<div
key={idx}
@@ -51,7 +54,10 @@ const MixCard = memo(
})}
{/* Fill remaining cells if less than 4 covers */}
{Array.from({
length: Math.max(0, 4 - mix.coverUrls.length),
length: Math.max(
0,
4 - mix.coverUrls.length
),
}).map((_, idx) => (
<div
key={`empty-${idx}`}
@@ -79,7 +85,18 @@ const MixCard = memo(
);
},
(prevProps, nextProps) => {
return prevProps.mix.id === nextProps.mix.id;
// Compare id, name, description, trackCount, and coverUrls to detect content changes
// This ensures the card re-renders when mood mix content changes even if ID is the same
return (
prevProps.mix.id === nextProps.mix.id &&
prevProps.mix.name === nextProps.mix.name &&
prevProps.mix.description === nextProps.mix.description &&
prevProps.mix.trackCount === nextProps.mix.trackCount &&
prevProps.mix.coverUrls.length === nextProps.mix.coverUrls.length &&
prevProps.mix.coverUrls.every(
(url, i) => url === nextProps.mix.coverUrls[i]
)
);
}
);
+197 -504
View File
@@ -1,10 +1,25 @@
"use client";
import { useState, useEffect } from "react";
import { api, MoodPreset, MoodMixParams } from "@/lib/api";
import { api, MoodType, MoodBucketPreset } from "@/lib/api";
import { useAudioControls } from "@/lib/audio-controls-context";
import { Track } from "@/lib/audio-state-context";
import { Play, Loader2, AudioWaveform, Sliders, X, ChevronDown, ChevronUp } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
Play,
Loader2,
AudioWaveform,
X,
Smile,
Frown,
Coffee,
Zap,
PartyPopper,
Brain,
CloudRain,
Flame,
Guitar,
} from "lucide-react";
import { toast } from "sonner";
interface MoodMixerProps {
@@ -12,46 +27,92 @@ interface MoodMixerProps {
onClose: () => void;
}
// Mood configuration with icons and colors
const MOOD_CONFIG: Record<
MoodType,
{
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
description: string;
}
> = {
happy: {
icon: Smile,
color: "from-yellow-500 to-orange-500",
label: "Happy",
description: "Uplifting & joyful",
},
sad: {
icon: Frown,
color: "from-blue-600 to-indigo-700",
label: "Sad",
description: "Melancholic & emotional",
},
chill: {
icon: Coffee,
color: "from-teal-500 to-cyan-600",
label: "Chill",
description: "Relaxed & mellow",
},
energetic: {
icon: Zap,
color: "from-orange-500 to-red-500",
label: "Energetic",
description: "High energy & pumped",
},
party: {
icon: PartyPopper,
color: "from-pink-500 to-purple-600",
label: "Party",
description: "Dance & celebrate",
},
focus: {
icon: Brain,
color: "from-emerald-500 to-green-600",
label: "Focus",
description: "Concentration & flow",
},
melancholy: {
icon: CloudRain,
color: "from-slate-500 to-gray-600",
label: "Melancholy",
description: "Bittersweet & reflective",
},
aggressive: {
icon: Flame,
color: "from-red-600 to-rose-700",
label: "Aggressive",
description: "Intense & powerful",
},
acoustic: {
icon: Guitar,
color: "from-amber-600 to-yellow-700",
label: "Acoustic",
description: "Organic & unplugged",
},
};
// Order for display in 3x3 grid
const MOOD_ORDER: MoodType[] = [
"happy",
"energetic",
"party",
"chill",
"focus",
"acoustic",
"melancholy",
"sad",
"aggressive",
];
export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
const { playTracks } = useAudioControls();
const [presets, setPresets] = useState<MoodPreset[]>([]);
const queryClient = useQueryClient();
const [presets, setPresets] = useState<MoodBucketPreset[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [showCustom, setShowCustom] = useState(false);
const [generating, setGenerating] = useState<MoodType | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Custom sliders state - basic audio features
const [customParams, setCustomParams] = useState<{
valence: [number, number];
energy: [number, number];
danceability: [number, number];
bpm: [number, number];
}>({
valence: [0, 100],
energy: [0, 100],
danceability: [0, 100],
bpm: [60, 180],
});
// ML mood sliders state (Advanced mode)
const [mlMoods, setMlMoods] = useState<{
moodHappy: [number, number];
moodSad: [number, number];
moodRelaxed: [number, number];
moodAggressive: [number, number];
moodParty: [number, number];
moodAcoustic: [number, number];
moodElectronic: [number, number];
}>({
moodHappy: [0, 100],
moodSad: [0, 100],
moodRelaxed: [0, 100],
moodAggressive: [0, 100],
moodParty: [0, 100],
moodAcoustic: [0, 100],
moodElectronic: [0, 100],
});
// Handle visibility animation
useEffect(() => {
@@ -67,7 +128,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
const loadPresets = async () => {
try {
const data = await api.getMoodPresets();
const data = await api.getMoodBucketPresets();
setPresets(data);
} catch (error) {
console.error("Failed to load mood presets:", error);
@@ -77,16 +138,16 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
}
};
const generateMix = async (preset: MoodPreset) => {
setGenerating(preset.id);
const generateMix = async (mood: MoodType) => {
const config = MOOD_CONFIG[mood];
setGenerating(mood);
try {
const mix = await api.generateMoodMix({
...preset.params,
limit: 15,
});
// Get the mix from pre-computed bucket (instant!)
const mix = await api.getMoodBucketMix(mood);
if (mix.tracks && mix.tracks.length > 0) {
const tracks: Track[] = mix.tracks.map((t: any) => ({
const tracks: Track[] = mix.tracks.map((t) => ({
id: t.id,
title: t.title,
artist: {
@@ -101,153 +162,61 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
duration: t.duration,
}));
// Start playback
playTracks(tracks, 0);
toast.success(`Your ${preset.name} Mix`, {
// Save as user's active mood mix
await api.saveMoodBucketMix(mood);
toast.success(`${config.label} Mix`, {
description: `Playing ${tracks.length} tracks`,
});
// Save these params as user's mood mix preferences (include preset name for mix title)
try {
await api.post('/mixes/mood/save-preferences', {
...preset.params,
limit: 15,
presetName: preset.name
});
} catch (err) {
console.error("Failed to save mood preferences:", err);
}
// Force immediate refetch of mixes on home page
// Using refetchQueries instead of invalidateQueries for immediate update
await queryClient.refetchQueries({ queryKey: ["mixes"] });
// Notify other components to refresh mixes
// Also dispatch events for any other listeners
window.dispatchEvent(new CustomEvent("mix-generated"));
window.dispatchEvent(new CustomEvent("mixes-updated"));
onClose();
} else {
toast.error("Not enough matching tracks", {
toast.error("Not enough tracks for this mood", {
description:
"Try a different mood or wait for more analysis",
"Try analyzing more music or choose a different mood",
});
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to generate mood mix:", error);
toast.error(error?.error || "Failed to generate mix");
const errorMessage =
error instanceof Error
? error.message
: "Failed to generate mix";
toast.error(errorMessage);
} finally {
setGenerating(null);
}
};
const generateCustomMix = async () => {
setGenerating("custom");
try {
const params: MoodMixParams = {
valence: {
min: customParams.valence[0] / 100,
max: customParams.valence[1] / 100,
},
energy: {
min: customParams.energy[0] / 100,
max: customParams.energy[1] / 100,
},
danceability: {
min: customParams.danceability[0] / 100,
max: customParams.danceability[1] / 100,
},
bpm: { min: customParams.bpm[0], max: customParams.bpm[1] },
limit: 15,
};
// Add ML mood params if advanced mode is enabled
if (showAdvanced) {
params.moodHappy = {
min: mlMoods.moodHappy[0] / 100,
max: mlMoods.moodHappy[1] / 100,
};
params.moodSad = {
min: mlMoods.moodSad[0] / 100,
max: mlMoods.moodSad[1] / 100,
};
params.moodRelaxed = {
min: mlMoods.moodRelaxed[0] / 100,
max: mlMoods.moodRelaxed[1] / 100,
};
params.moodAggressive = {
min: mlMoods.moodAggressive[0] / 100,
max: mlMoods.moodAggressive[1] / 100,
};
params.moodParty = {
min: mlMoods.moodParty[0] / 100,
max: mlMoods.moodParty[1] / 100,
};
params.moodAcoustic = {
min: mlMoods.moodAcoustic[0] / 100,
max: mlMoods.moodAcoustic[1] / 100,
};
params.moodElectronic = {
min: mlMoods.moodElectronic[0] / 100,
max: mlMoods.moodElectronic[1] / 100,
};
}
const mix = await api.generateMoodMix(params);
if (mix.tracks && mix.tracks.length > 0) {
const tracks: Track[] = mix.tracks.map((t: any) => ({
id: t.id,
title: t.title,
artist: {
name: t.album?.artist?.name || "Unknown Artist",
id: t.album?.artist?.id,
},
album: {
title: t.album?.title || "Unknown Album",
coverArt: t.album?.coverUrl,
id: t.albumId,
},
duration: t.duration,
}));
playTracks(tracks, 0);
toast.success("Your Custom Mix", {
description: `Playing ${tracks.length} tracks`,
});
// Save these params as user's mood mix preferences
try {
await api.post('/mixes/mood/save-preferences', {
...params,
presetName: "Custom"
});
} catch (err) {
console.error("Failed to save mood preferences:", err);
}
// Notify other components to refresh mixes
window.dispatchEvent(new CustomEvent("mix-generated"));
window.dispatchEvent(new CustomEvent("mixes-updated"));
onClose();
} else {
toast.error("Not enough matching tracks", {
description: "Try widening your parameters",
});
}
} catch (error: any) {
console.error("Failed to generate custom mix:", error);
toast.error(error?.error || "Failed to generate mix");
} finally {
setGenerating(null);
}
// Get track count for a mood
const getTrackCount = (mood: MoodType): number => {
// MoodBucketPreset uses 'id' as the mood identifier
const preset = presets.find((p) => p.id === mood);
return preset?.trackCount || 0;
};
if (!isVisible && !isOpen) return null;
return (
<div
className={`fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 transition-opacity duration-200 ${
className={`fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 transition-opacity duration-200 ${
isOpen ? "opacity-100" : "opacity-0"
}`}
onClick={onClose}
>
<div
className={`bg-gradient-to-b from-[#1a1a1a] to-[#0a0a0a] rounded-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden border border-white/10 shadow-2xl transition-all duration-200 ${
className={`bg-gradient-to-b from-[#1a1a1a] to-[#0a0a0a] rounded-2xl max-w-lg w-full max-h-[85vh] overflow-hidden border border-white/10 shadow-2xl transition-all duration-200 ${
isOpen ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
onClick={(e) => e.stopPropagation()}
@@ -263,7 +232,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
Mood Mixer
</h2>
<p className="text-sm text-gray-400">
Generate a mix based on your vibe
Pick your vibe
</p>
</div>
</div>
@@ -282,354 +251,78 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
<Loader2 className="w-8 h-8 animate-spin text-[#ecb200]" />
</div>
) : (
<>
{/* Toggle between presets and custom */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setShowCustom(false)}
className={`flex-1 py-2.5 px-4 rounded-lg font-medium text-sm transition-all ${
!showCustom
? "bg-[#ecb200] text-black"
: "bg-white/5 text-white/70 hover:bg-white/10"
}`}
>
<AudioWaveform className="w-4 h-4 inline-block mr-2" />
Quick Moods
</button>
<button
onClick={() => setShowCustom(true)}
className={`flex-1 py-2.5 px-4 rounded-lg font-medium text-sm transition-all ${
showCustom
? "bg-[#ecb200] text-black"
: "bg-white/5 text-white/70 hover:bg-white/10"
}`}
>
<Sliders className="w-4 h-4 inline-block mr-2" />
Custom Mix
</button>
</div>
/* 3x3 Mood Grid */
<div className="grid grid-cols-3 gap-3">
{MOOD_ORDER.map((mood) => {
const config = MOOD_CONFIG[mood];
const Icon = config.icon;
const trackCount = getTrackCount(mood);
const isDisabled = trackCount < 5;
const isGenerating = generating === mood;
{!showCustom ? (
/* Preset Grid */
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{presets.map((preset) => (
<button
key={preset.id}
onClick={() => generateMix(preset)}
disabled={generating !== null}
className={`
relative group p-4 rounded-xl overflow-hidden
bg-gradient-to-br ${preset.color}
border border-white/10 hover:border-white/20
transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed
text-left
`}
>
<div className="relative z-10 flex flex-col justify-end h-full">
<h3 className="font-semibold text-white text-sm">
{preset.name}
</h3>
</div>
{/* Play overlay */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{generating === preset.id ? (
<Loader2 className="w-8 h-8 text-white animate-spin" />
) : (
<div className="w-12 h-12 rounded-full bg-[#ecb200] flex items-center justify-center shadow-lg">
<Play
className="w-6 h-6 text-black ml-0.5"
fill="currentColor"
/>
</div>
)}
</div>
</button>
))}
</div>
) : (
/* Custom Sliders */
<div className="space-y-6">
<SliderControl
label="Happiness"
value={customParams.valence}
onChange={(v) =>
setCustomParams((p) => ({
...p,
valence: v,
}))
}
min={0}
max={100}
lowLabel="Sad"
highLabel="Happy"
/>
<SliderControl
label="Energy"
value={customParams.energy}
onChange={(v) =>
setCustomParams((p) => ({
...p,
energy: v,
}))
}
min={0}
max={100}
lowLabel="Calm"
highLabel="Energetic"
/>
<SliderControl
label="Danceability"
value={customParams.danceability}
onChange={(v) =>
setCustomParams((p) => ({
...p,
danceability: v,
}))
}
min={0}
max={100}
lowLabel="Static"
highLabel="Groovy"
/>
<SliderControl
label="Tempo (BPM)"
value={customParams.bpm}
onChange={(v) =>
setCustomParams((p) => ({
...p,
bpm: v,
}))
}
min={60}
max={180}
lowLabel="Slow"
highLabel="Fast"
showValues
/>
{/* Advanced Mode Toggle */}
return (
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full py-2 px-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors flex items-center justify-between text-sm text-white/70"
key={mood}
onClick={() => generateMix(mood)}
disabled={
generating !== null || isDisabled
}
className={`
relative group aspect-square rounded-xl overflow-hidden
bg-gradient-to-br ${config.color}
border border-white/10 hover:border-white/30
transition-all duration-200 hover:scale-[1.03] active:scale-[0.97]
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:scale-100
flex flex-col items-center justify-center gap-2 p-3
`}
title={
isDisabled
? `Need at least 5 tracks (have ${trackCount})`
: config.description
}
>
<span className="flex items-center gap-2">
<Sliders className="w-4 h-4" />
ML Mood Controls
</span>
{showAdvanced ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* ML Mood Sliders (Advanced Mode) */}
{showAdvanced && (
<div className="space-y-4 p-4 bg-white/5 rounded-lg border border-white/10">
<p className="text-xs text-gray-400 mb-2">
Fine-tune using ML-detected mood predictions
</p>
<SliderControl
label="Happy"
value={mlMoods.moodHappy}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodHappy: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Sad"
value={mlMoods.moodSad}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodSad: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Relaxed"
value={mlMoods.moodRelaxed}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodRelaxed: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Aggressive"
value={mlMoods.moodAggressive}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodAggressive: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Party"
value={mlMoods.moodParty}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodParty: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Acoustic"
value={mlMoods.moodAcoustic}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodAcoustic: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
<SliderControl
label="Electronic"
value={mlMoods.moodElectronic}
onChange={(v) =>
setMlMoods((p) => ({ ...p, moodElectronic: v }))
}
min={0}
max={100}
lowLabel="Low"
highLabel="High"
/>
{/* Icon */}
<div className="relative z-10">
{isGenerating ? (
<Loader2 className="w-8 h-8 text-white animate-spin" />
) : (
<Icon className="w-8 h-8 text-white drop-shadow-lg" />
)}
</div>
)}
<button
onClick={generateCustomMix}
disabled={generating !== null}
className="w-full py-3 px-4 rounded-lg bg-[#ecb200] text-black font-semibold hover:bg-[#d4a000] transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{generating === "custom" ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Play
className="w-5 h-5"
fill="currentColor"
/>
Generate Mix
</>
)}
{/* Label */}
<span className="relative z-10 text-sm font-semibold text-white drop-shadow-lg">
{config.label}
</span>
{/* Track count badge */}
<span className="absolute top-2 right-2 text-[10px] font-medium text-white/70 bg-black/30 px-1.5 py-0.5 rounded-full">
{trackCount}
</span>
{/* Hover overlay with play icon */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{!isGenerating && !isDisabled && (
<div className="w-12 h-12 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Play
className="w-6 h-6 text-white ml-0.5"
fill="currentColor"
/>
</div>
)}
</div>
</button>
</div>
)}
</>
);
})}
</div>
)}
{/* Help text */}
<p className="text-center text-xs text-gray-500 mt-4">
Moods are based on audio analysis of your library
</p>
</div>
</div>
</div>
);
}
interface SliderControlProps {
label: string;
value: [number, number];
onChange: (value: [number, number]) => void;
min: number;
max: number;
lowLabel: string;
highLabel: string;
showValues?: boolean;
}
function SliderControl({
label,
value,
onChange,
min,
max,
lowLabel,
highLabel,
showValues,
}: SliderControlProps) {
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMin = Math.min(Number(e.target.value), value[1] - 1);
onChange([newMin, value[1]]);
};
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMax = Math.max(Number(e.target.value), value[0] + 1);
onChange([value[0], newMax]);
};
const percentage = ((value[0] - min) / (max - min)) * 100;
const width = ((value[1] - value[0]) / (max - min)) * 100;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{label}</span>
{showValues && (
<span className="text-xs text-gray-400">
{value[0]} - {value[1]}
</span>
)}
</div>
<div className="relative h-2">
{/* Track background - pointer-events-none so inputs receive clicks */}
<div className="absolute inset-0 bg-white/10 rounded-full pointer-events-none" />
{/* Active range - pointer-events-none so inputs receive clicks */}
<div
className="absolute h-full bg-gradient-to-r from-[#ecb200] to-amber-500 rounded-full pointer-events-none"
style={{ left: `${percentage}%`, width: `${width}%` }}
/>
{/* Min slider */}
<input
type="range"
min={min}
max={max}
value={value[0]}
onChange={handleMinChange}
className="absolute inset-0 w-full opacity-0 cursor-pointer z-10"
style={{ pointerEvents: "auto" }}
/>
{/* Max slider */}
<input
type="range"
min={min}
max={max}
value={value[1]}
onChange={handleMaxChange}
className="absolute inset-0 w-full opacity-0 cursor-pointer z-20"
style={{ pointerEvents: "auto" }}
/>
{/* Thumb indicators */}
<div
className="absolute w-4 h-4 bg-white rounded-full shadow-lg transform -translate-y-1/4 pointer-events-none border-2 border-[#ecb200]"
style={{ left: `calc(${percentage}% - 8px)` }}
/>
<div
className="absolute w-4 h-4 bg-white rounded-full shadow-lg transform -translate-y-1/4 pointer-events-none border-2 border-[#ecb200]"
style={{ left: `calc(${percentage + width}% - 8px)` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{lowLabel}</span>
<span>{highLabel}</span>
</div>
</div>
);
}
@@ -113,7 +113,7 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
<main
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
style={{
marginTop: "52px",
marginTop: "58px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
+1 -1
View File
@@ -139,7 +139,7 @@ export function TopBar() {
return (
<header
className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50"
style={{ height: isMobileOrTablet ? "52px" : "64px" }}
style={{ height: isMobileOrTablet ? "58px" : "64px" }}
>
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */}
{isMobileOrTablet ? (
+281 -84
View File
@@ -6,7 +6,14 @@ import { useAudioControls } from "@/lib/audio-controls-context";
import { api } from "@/lib/api";
import { howlerEngine } from "@/lib/howler-engine";
import { audioSeekEmitter } from "@/lib/audio-seek-emitter";
import { useEffect, useLayoutEffect, useRef, memo, useCallback, useMemo } from "react";
import {
useEffect,
useLayoutEffect,
useRef,
memo,
useCallback,
useMemo,
} from "react";
function podcastDebugEnabled(): boolean {
try {
@@ -51,6 +58,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
const {
isPlaying,
setCurrentTime,
setCurrentTimeFromEngine,
setDuration,
setIsPlaying,
isBuffering,
@@ -59,6 +67,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
canSeek,
setCanSeek,
setDownloadProgress,
lockSeek,
} = useAudioPlayback();
// Controls context
@@ -83,6 +92,11 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
const loadListenerRef = useRef<(() => void) | null>(null);
const loadErrorListenerRef = useRef<(() => void) | null>(null);
const cachePollingLoadListenerRef = useRef<(() => void) | null>(null);
// Counter to track seek operations and abort stale ones
const seekOperationIdRef = useRef<number>(0);
// Debounce timer for rapid podcast seeks
const seekDebounceRef = useRef<NodeJS.Timeout | null>(null);
const pendingSeekTimeRef = useRef<number | null>(null);
// Reset duration when nothing is playing
useEffect(() => {
@@ -94,7 +108,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
// Subscribe to Howler events
useEffect(() => {
const handleTimeUpdate = (data: { time: number }) => {
setCurrentTime(data.time);
// Use setCurrentTimeFromEngine to respect seek lock
// This prevents stale timeupdate events from overwriting optimistic seek updates
setCurrentTimeFromEngine(data.time);
};
const handleLoad = (data: { duration: number }) => {
@@ -131,15 +147,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
console.error("[HowlerAudioElement] Playback error:", data.error);
setIsPlaying(false);
isUserInitiatedRef.current = false;
if (playbackType === "track") {
if (queue.length > 1) {
console.log("[HowlerAudioElement] Track failed, trying next in queue");
console.log(
"[HowlerAudioElement] Track failed, trying next in queue"
);
lastTrackIdRef.current = null;
isLoadingRef.current = false;
next();
} else {
console.log("[HowlerAudioElement] Track failed, no more in queue - clearing");
console.log(
"[HowlerAudioElement] Track failed, no more in queue - clearing"
);
lastTrackIdRef.current = null;
isLoadingRef.current = false;
setCurrentTrack(null);
@@ -164,7 +184,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
const handlePause = () => {
if (isLoadingRef.current) return;
if (seekReloadInProgressRef.current) return;
if (!isUserInitiatedRef.current) {
setIsPlaying(false);
}
@@ -188,7 +208,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
howlerEngine.off("play", handlePlay);
howlerEngine.off("pause", handlePause);
};
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTime, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
// Save audiobook progress
const saveAudiobookProgress = useCallback(
@@ -287,10 +307,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
if (isSeekingRef.current) {
return;
}
const shouldPlay = lastPlayingStateRef.current || isPlaying;
const isCurrentlyPlaying = howlerEngine.isPlaying();
if (shouldPlay && !isCurrentlyPlaying) {
howlerEngine.seek(0);
howlerEngine.play();
@@ -330,7 +350,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
if (streamUrl) {
const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying();
const fallbackDuration =
currentTrack?.duration ||
currentAudiobook?.duration ||
@@ -386,7 +406,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
});
}
const shouldAutoPlay = lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad;
const shouldAutoPlay =
lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad;
if (shouldAutoPlay) {
howlerEngine.play();
@@ -522,6 +543,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
// Poll for podcast cache and reload when ready
const startCachePolling = useCallback(
(podcastId: string, episodeId: string, targetTime: number) => {
// Capture the current seek operation ID
const pollingSeekId = seekOperationIdRef.current;
if (cachePollingRef.current) {
clearInterval(cachePollingRef.current);
}
@@ -530,6 +554,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
const maxPolls = 60;
cachePollingRef.current = setInterval(async () => {
// Check if a newer seek operation has started
if (seekOperationIdRef.current !== pollingSeekId) {
if (cachePollingRef.current) {
clearInterval(cachePollingRef.current);
cachePollingRef.current = null;
}
podcastDebugLog("cache polling aborted (stale)", {
pollingSeekId,
currentId: seekOperationIdRef.current,
});
return;
}
pollCount++;
try {
@@ -537,6 +574,16 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
podcastId,
episodeId
);
// Re-check after async operation
if (seekOperationIdRef.current !== pollingSeekId) {
if (cachePollingRef.current) {
clearInterval(cachePollingRef.current);
cachePollingRef.current = null;
}
return;
}
podcastDebugLog("cache poll", {
podcastId,
episodeId,
@@ -553,14 +600,20 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
cachePollingRef.current = null;
}
podcastDebugLog("cache ready -> howlerEngine.reload()", {
podcastId,
episodeId,
targetTime,
});
podcastDebugLog(
"cache ready -> howlerEngine.reload()",
{
podcastId,
episodeId,
targetTime,
}
);
// Clean up any previous cache polling load listener
if (cachePollingLoadListenerRef.current) {
howlerEngine.off("load", cachePollingLoadListenerRef.current);
howlerEngine.off(
"load",
cachePollingLoadListenerRef.current
);
cachePollingLoadListenerRef.current = null;
}
@@ -570,6 +623,15 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
howlerEngine.off("load", onLoad);
cachePollingLoadListenerRef.current = null;
// Check if still current before acting
if (seekOperationIdRef.current !== pollingSeekId) {
podcastDebugLog(
"cache polling load callback aborted (stale)",
{ pollingSeekId }
);
return;
}
howlerEngine.seek(targetTime);
setCurrentTime(targetTime);
howlerEngine.play();
@@ -594,12 +656,17 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
cachePollingRef.current = null;
}
console.warn("[HowlerAudioElement] Cache polling timeout");
console.warn(
"[HowlerAudioElement] Cache polling timeout"
);
setIsBuffering(false);
setTargetSeekPosition(null);
}
} catch (error) {
console.error("[HowlerAudioElement] Cache polling error:", error);
console.error(
"[HowlerAudioElement] Cache polling error:",
error
);
}
}, 2000);
},
@@ -608,93 +675,219 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
// Handle seeking via event emitter
useEffect(() => {
// Store previous time to detect large skips vs fine scrubbing
let previousTime = howlerEngine.getCurrentTime();
const handleSeek = async (time: number) => {
// Increment seek operation ID to track this specific seek
seekOperationIdRef.current += 1;
const thisSeekId = seekOperationIdRef.current;
const wasPlayingAtSeekStart = howlerEngine.isPlaying();
setCurrentTime(time);
// Detect if this is a large skip (like 30s buttons) vs fine scrubbing
const timeDelta = Math.abs(time - previousTime);
const isLargeSkip = timeDelta >= 10; // 10+ seconds = large skip (30s, 15s buttons)
previousTime = time;
// DON'T set currentTime here for podcasts - the seek() in audio-controls-context
// already did it optimistically. Setting it again causes a race condition.
// We only update it after the seek actually completes.
if (playbackType === "podcast" && currentPodcast) {
// Cancel any previous seek-related operations
if (seekCheckTimeoutRef.current) {
clearTimeout(seekCheckTimeoutRef.current);
seekCheckTimeoutRef.current = null;
}
// Cancel any pending cache polling from previous seek
if (cachePollingRef.current) {
clearInterval(cachePollingRef.current);
cachePollingRef.current = null;
}
// Cancel previous reload listener
if (seekReloadListenerRef.current) {
howlerEngine.off("load", seekReloadListenerRef.current);
seekReloadListenerRef.current = null;
}
// Cancel previous cache polling load listener
if (cachePollingLoadListenerRef.current) {
howlerEngine.off(
"load",
cachePollingLoadListenerRef.current
);
cachePollingLoadListenerRef.current = null;
}
// Cancel any pending debounced seek
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
// Store the pending seek time - debounce will use the latest value
pendingSeekTimeRef.current = time;
const [podcastId, episodeId] = currentPodcast.id.split(":");
try {
const status = await api.getPodcastEpisodeCacheStatus(
podcastId,
episodeId
);
if (status.cached) {
podcastDebugLog("seek: cached=true, using reload+seek pattern", {
time,
podcastId,
episodeId,
});
if (seekReloadListenerRef.current) {
howlerEngine.off("load", seekReloadListenerRef.current);
seekReloadListenerRef.current = null;
}
seekReloadInProgressRef.current = true;
howlerEngine.reload();
const onLoad = () => {
howlerEngine.off("load", onLoad);
seekReloadListenerRef.current = null;
seekReloadInProgressRef.current = false;
howlerEngine.seek(time);
setCurrentTime(time);
if (wasPlayingAtSeekStart) {
howlerEngine.play();
setIsPlaying(true);
}
};
seekReloadListenerRef.current = onLoad;
howlerEngine.on("load", onLoad);
// Execute the seek logic - immediately for large skips, debounced for fine scrubbing
const executeSeek = async () => {
const seekTime = pendingSeekTimeRef.current ?? time;
pendingSeekTimeRef.current = null;
// Check if this seek is still current
if (seekOperationIdRef.current !== thisSeekId) {
return;
}
} catch (e) {
console.warn("[HowlerAudioElement] Could not check cache status:", e);
}
howlerEngine.seek(time);
seekCheckTimeoutRef.current = setTimeout(() => {
try {
const actualPos = howlerEngine.getActualCurrentTime();
const seekFailed = time > 30 && actualPos < 30;
podcastDebugLog("seek check", {
time,
actualPos,
seekFailed,
const status = await api.getPodcastEpisodeCacheStatus(
podcastId,
episodeId,
});
episodeId
);
if (seekFailed) {
howlerEngine.pause();
setIsBuffering(true);
setTargetSeekPosition(time);
setIsPlaying(false);
startCachePolling(podcastId, episodeId, time);
// Check if this seek operation is still current
if (seekOperationIdRef.current !== thisSeekId) {
podcastDebugLog("seek: aborted (stale operation)", {
thisSeekId,
currentId: seekOperationIdRef.current,
});
return;
}
if (status.cached) {
// For cached podcasts, try direct seek first (faster than reload)
podcastDebugLog(
"seek: cached=true, trying direct seek first",
{
time: seekTime,
podcastId,
episodeId,
}
);
// Direct seek - howlerEngine now handles seek locking internally
howlerEngine.seek(seekTime);
// Verify seek succeeded after a short delay
setTimeout(() => {
if (seekOperationIdRef.current !== thisSeekId) {
return;
}
const actualPos = howlerEngine.getActualCurrentTime();
const seekSucceeded = Math.abs(actualPos - seekTime) < 5; // Within 5 seconds
podcastDebugLog("seek: direct seek result", {
seekTime,
actualPos,
seekSucceeded,
});
if (!seekSucceeded) {
// Direct seek failed, fall back to reload pattern
podcastDebugLog("seek: direct seek failed, falling back to reload");
seekReloadInProgressRef.current = true;
howlerEngine.reload();
const onLoad = () => {
howlerEngine.off("load", onLoad);
seekReloadListenerRef.current = null;
seekReloadInProgressRef.current = false;
if (seekOperationIdRef.current !== thisSeekId) {
return;
}
howlerEngine.seek(seekTime);
if (wasPlayingAtSeekStart) {
howlerEngine.play();
setIsPlaying(true);
}
};
seekReloadListenerRef.current = onLoad;
howlerEngine.on("load", onLoad);
} else {
// Seek succeeded - resume playback if needed
if (wasPlayingAtSeekStart && !howlerEngine.isPlaying()) {
howlerEngine.play();
}
}
}, 150);
return;
}
} catch (e) {
console.error("[HowlerAudioElement] Seek check error:", e);
console.warn(
"[HowlerAudioElement] Could not check cache status:",
e
);
}
}, 1000);
// Check if still current after async operation
if (seekOperationIdRef.current !== thisSeekId) {
return;
}
// Not cached - try direct seek
howlerEngine.seek(seekTime);
seekCheckTimeoutRef.current = setTimeout(() => {
// Check if this seek is still current
if (seekOperationIdRef.current !== thisSeekId) {
return;
}
try {
const actualPos = howlerEngine.getActualCurrentTime();
const seekFailed = seekTime > 30 && actualPos < 30;
podcastDebugLog("seek check", {
time: seekTime,
actualPos,
seekFailed,
podcastId,
episodeId,
});
if (seekFailed) {
howlerEngine.pause();
setIsBuffering(true);
setTargetSeekPosition(seekTime);
setIsPlaying(false);
startCachePolling(podcastId, episodeId, seekTime);
}
} catch (e) {
console.error(
"[HowlerAudioElement] Seek check error:",
e
);
}
}, 1000);
};
// For large skips (30s buttons), execute immediately for responsive feel
// For fine scrubbing (progress bar), debounce to prevent spamming
if (isLargeSkip) {
podcastDebugLog("seek: large skip, executing immediately", { timeDelta, time });
executeSeek();
} else {
podcastDebugLog("seek: fine scrub, debouncing", { timeDelta, time });
seekDebounceRef.current = setTimeout(executeSeek, 150);
}
return;
}
// For audiobooks and tracks, set seeking flag to prevent load effect interference
isSeekingRef.current = true;
howlerEngine.seek(time);
// Reset seeking flag after a short delay to allow seek to complete
setTimeout(() => {
isSeekingRef.current = false;
@@ -703,7 +896,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
return unsubscribe;
}, [setCurrentTime, playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]);
}, [playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]);
// Cleanup cache polling, seek timeout, and seek-reload listener on unmount
useEffect(() => {
@@ -718,6 +911,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
howlerEngine.off("load", seekReloadListenerRef.current);
seekReloadListenerRef.current = null;
}
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
};
}, []);
+18 -17
View File
@@ -314,11 +314,12 @@ export function MiniPlayer() {
return (
<div
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl transition-transform"
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl"
style={{
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
transform: `translateX(${swipeOffset}px)`,
opacity: swipeOpacity,
transition: swipeOffset === 0 ? 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
@@ -339,42 +340,42 @@ export function MiniPlayer() {
/>
</div>
{/* Player content */}
{/* Player content - more spacious padding */}
<div
className="relative flex items-center gap-2.5 px-3 py-2 cursor-pointer"
className="relative flex items-center gap-3 px-3 py-3 cursor-pointer"
onClick={() => setPlayerMode("overlay")}
>
{/* Album Art */}
<div className="relative w-10 h-10 flex-shrink-0 rounded-md overflow-hidden bg-black/30 shadow-md">
{/* Album Art - slightly larger */}
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
{coverUrl ? (
<Image
src={coverUrl}
alt={title}
fill
sizes="40px"
sizes="48px"
className="object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<MusicIcon className="w-4 h-4 text-gray-400" />
<MusicIcon className="w-5 h-5 text-gray-400" />
</div>
)}
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="text-white text-[13px] font-medium truncate leading-tight">
<p className="text-white text-sm font-medium truncate leading-tight">
{title}
</p>
<p className="text-gray-300/70 text-[11px] truncate leading-tight">
<p className="text-gray-300/70 text-xs truncate leading-tight mt-0.5">
{subtitle}
</p>
</div>
{/* Controls - Vibe & Play/Pause */}
<div
className="flex items-center gap-1 flex-shrink-0"
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{/* Vibe Button */}
@@ -382,7 +383,7 @@ export function MiniPlayer() {
onClick={handleVibeToggle}
disabled={!canSkip || isVibeLoading}
className={cn(
"w-9 h-9 flex items-center justify-center rounded-full transition-colors",
"w-10 h-10 flex items-center justify-center rounded-full transition-colors",
!canSkip
? "text-gray-600"
: vibeMode
@@ -396,9 +397,9 @@ export function MiniPlayer() {
}
>
{isVibeLoading ? (
<Loader2 className="w-[18px] h-[18px] animate-spin" />
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<AudioWaveform className="w-[18px] h-[18px]" />
<AudioWaveform className="w-5 h-5" />
)}
</button>
@@ -414,7 +415,7 @@ export function MiniPlayer() {
}
}}
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center transition shadow-md",
"w-10 h-10 rounded-full flex items-center justify-center transition shadow-md",
isBuffering
? "bg-white/80 text-black"
: "bg-white text-black hover:scale-105"
@@ -428,11 +429,11 @@ export function MiniPlayer() {
}
>
{isBuffering ? (
<Loader2 className="w-[18px] h-[18px] animate-spin" />
<Loader2 className="w-5 h-5 animate-spin" />
) : isPlaying ? (
<Pause className="w-[18px] h-[18px]" />
<Pause className="w-5 h-5" />
) : (
<Play className="w-[18px] h-[18px] ml-0.5" />
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
</div>
@@ -2,7 +2,7 @@
import Image from "next/image";
import { SimilarArtist } from "../types";
import { Music } from "lucide-react";
import { Music, Library } from "lucide-react";
import { api } from "@/lib/api";
interface SimilarArtistsProps {
@@ -20,10 +20,11 @@ export function SimilarArtists({
return (
<section>
<h2 className="text-xl font-bold mb-4">
Fans Also Like
</h2>
<div data-tv-section="similar-artists" className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<h2 className="text-xl font-bold mb-4">Fans Also Like</h2>
<div
data-tv-section="similar-artists"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
{similarArtists.map((artist, index) => {
const rawImage = artist.coverArt || artist.image;
const imageUrl = rawImage
@@ -33,17 +34,22 @@ export function SimilarArtists({
? Math.round(artist.weight * 100)
: null;
// For library artists, use the library ID; otherwise use mbid or name
const navigationId = artist.inLibrary
? artist.id
: artist.mbid || artist.id;
return (
<div
key={artist.id || artist.name}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
onClick={() => onNavigate(artist.mbid || artist.id)}
onClick={() => onNavigate(navigationId)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.preventDefault();
onNavigate(artist.mbid || artist.id);
onNavigate(navigationId);
}
}}
className="bg-transparent hover:bg-white/5 transition-all p-3 rounded-md cursor-pointer group"
@@ -64,6 +70,15 @@ export function SimilarArtists({
<Music className="w-12 h-12 text-gray-600" />
</div>
)}
{/* Library indicator badge */}
{artist.inLibrary && (
<div
className="absolute bottom-1 right-1 bg-[#ecb200] rounded-full p-1"
title="In your library"
>
<Library className="w-3 h-3 text-black" />
</div>
)}
</div>
{/* Artist Name */}
@@ -71,13 +86,13 @@ export function SimilarArtists({
{artist.name}
</h3>
{/* Album Count */}
{/* Album Count - show owned count if in library */}
<p className="text-xs text-gray-400 truncate">
{artist.ownedAlbumCount &&
artist.ownedAlbumCount > 0
? `${artist.ownedAlbumCount}/${artist.albumCount} albums`
: artist.albumCount && artist.albumCount > 0
? `${artist.albumCount} albums`
? `${artist.ownedAlbumCount} album${
artist.ownedAlbumCount > 1 ? "s" : ""
} in library`
: "Artist"}
</p>
+1
View File
@@ -56,4 +56,5 @@ export interface SimilarArtist {
albumCount?: number;
ownedAlbumCount?: number;
weight?: number;
inLibrary?: boolean;
}
+37 -22
View File
@@ -5,10 +5,10 @@
* and providing refresh functionality for mixes.
*/
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/lib/auth-context';
import { toast } from 'sonner';
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth-context";
import { toast } from "sonner";
import type {
Artist,
ListenedItem,
@@ -16,7 +16,7 @@ import type {
Audiobook,
Mix,
PopularArtist,
} from '../types';
} from "../types";
import {
useRecentlyListenedQuery,
useRecentlyAddedQuery,
@@ -28,7 +28,7 @@ import {
useRefreshMixesMutation,
useBrowseAllQuery,
queryKeys,
} from '@/hooks/useQueries';
} from "@/hooks/useQueries";
interface PlaylistPreview {
id: string;
@@ -81,27 +81,38 @@ export function useHomeData(): UseHomeDataReturn {
const queryClient = useQueryClient();
// Listen for mixes-updated event (fired when user saves mood preferences)
// Use refetchQueries instead of invalidateQueries to force immediate UI update
useEffect(() => {
const handleMixesUpdated = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.mixes() });
// refetchQueries forces immediate refetch, unlike invalidateQueries which only marks stale
queryClient.refetchQueries({ queryKey: queryKeys.mixes() });
};
window.addEventListener('mixes-updated', handleMixesUpdated);
return () => window.removeEventListener('mixes-updated', handleMixesUpdated);
window.addEventListener("mixes-updated", handleMixesUpdated);
return () =>
window.removeEventListener("mixes-updated", handleMixesUpdated);
}, [queryClient]);
// React Query hooks - these automatically handle caching, refetching, and loading states
const { data: recentlyListenedData, isLoading: isLoadingListened } = useRecentlyListenedQuery(10);
const { data: recentlyAddedData, isLoading: isLoadingAdded } = useRecentlyAddedQuery(10);
const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendationsQuery(10);
const { data: recentlyListenedData, isLoading: isLoadingListened } =
useRecentlyListenedQuery(10);
const { data: recentlyAddedData, isLoading: isLoadingAdded } =
useRecentlyAddedQuery(10);
const { data: recommendedData, isLoading: isLoadingRecommended } =
useRecommendationsQuery(10);
const { data: mixesData, isLoading: isLoadingMixes } = useMixesQuery();
const { data: popularData, isLoading: isLoadingPopular } = usePopularArtistsQuery(20);
const { data: podcastsData, isLoading: isLoadingPodcasts } = useTopPodcastsQuery(10);
const { data: audiobooksData, isLoading: isLoadingAudiobooks } = useAudiobooksQuery();
const { data: browseData, isLoading: isBrowseLoading } = useBrowseAllQuery();
const { data: popularData, isLoading: isLoadingPopular } =
usePopularArtistsQuery(20);
const { data: podcastsData, isLoading: isLoadingPodcasts } =
useTopPodcastsQuery(10);
const { data: audiobooksData, isLoading: isLoadingAudiobooks } =
useAudiobooksQuery();
const { data: browseData, isLoading: isBrowseLoading } =
useBrowseAllQuery();
// Mutation for refreshing mixes
const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } = useRefreshMixesMutation();
const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } =
useRefreshMixesMutation();
/**
* Refresh mixes and update cache
@@ -109,10 +120,10 @@ export function useHomeData(): UseHomeDataReturn {
const handleRefreshMixes = async () => {
try {
await refreshMixes();
toast.success('Mixes refreshed! Check out your new daily picks');
toast.success("Mixes refreshed! Check out your new daily picks");
} catch (error) {
console.error('Failed to refresh mixes:', error);
toast.error('Failed to refresh mixes');
console.error("Failed to refresh mixes:", error);
toast.error("Failed to refresh mixes");
}
};
@@ -136,8 +147,12 @@ export function useHomeData(): UseHomeDataReturn {
recommended: recommendedData?.artists || [],
mixes: Array.isArray(mixesData) ? mixesData : [],
popularArtists: popularData?.artists || [],
recentPodcasts: Array.isArray(podcastsData) ? podcastsData.slice(0, 10) : [],
recentAudiobooks: Array.isArray(audiobooksData) ? audiobooksData.slice(0, 10) : [],
recentPodcasts: Array.isArray(podcastsData)
? podcastsData.slice(0, 10)
: [],
recentAudiobooks: Array.isArray(audiobooksData)
? audiobooksData.slice(0, 10)
: [],
featuredPlaylists: browseData?.playlists || [],
isLoading,
isRefreshingMixes,
+63 -2
View File
@@ -1,6 +1,6 @@
const AUTH_TOKEN_KEY = "auth_token";
// Mood Mix Types
// Mood Mix Types (Legacy - for old presets endpoint)
export interface MoodPreset {
id: string;
name: string;
@@ -29,6 +29,43 @@ export interface MoodMixParams {
limit?: number;
}
// New Mood Bucket Types (simplified mood system)
export type MoodType =
| "happy"
| "sad"
| "chill"
| "energetic"
| "party"
| "focus"
| "melancholy"
| "aggressive"
| "acoustic";
export interface MoodBucketPreset {
id: MoodType;
name: string;
color: string;
icon: string;
trackCount: number;
}
export interface MoodBucketMix {
id: string;
mood: MoodType;
name: string;
description: string;
trackIds: string[];
coverUrls: string[];
trackCount: number;
color: string;
tracks?: any[];
}
export interface SavedMoodMixResponse {
success: boolean;
mix: MoodBucketMix & { generatedAt: string };
}
// Dynamically determine API URL based on configuration
const getApiBaseUrl = () => {
// Server-side rendering
@@ -1225,7 +1262,7 @@ class ApiClient {
);
}
// Mood on Demand
// Mood on Demand (Legacy)
async getMoodPresets() {
return this.request<MoodPreset[]>("/mixes/mood/presets");
}
@@ -1237,6 +1274,30 @@ class ApiClient {
});
}
// New Mood Bucket System (simplified, pre-computed)
async getMoodBucketPresets() {
return this.request<MoodBucketPreset[]>("/mixes/mood/buckets/presets");
}
async getMoodBucketMix(mood: MoodType) {
return this.request<MoodBucketMix>(`/mixes/mood/buckets/${mood}`);
}
async saveMoodBucketMix(mood: MoodType) {
return this.request<SavedMoodMixResponse>(
`/mixes/mood/buckets/${mood}/save`,
{ method: "POST" }
);
}
async backfillMoodBuckets() {
return this.request<{
success: boolean;
processed: number;
assigned: number;
}>("/mixes/mood/buckets/backfill", { method: "POST" });
}
// Enrichment
async getEnrichmentSettings() {
return this.request<any>("/enrichment/settings");
+4
View File
@@ -663,6 +663,10 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
? Math.min(Math.max(time, 0), maxDuration)
: Math.max(time, 0);
// Lock seek to prevent stale timeupdate events from overwriting optimistic update
// This is especially important for podcasts where seeking may require audio reload
playback.lockSeek(clampedTime);
// Optimistically update local playback time for instant UI feedback
playback.setCurrentTime(clampedTime);
+94 -3
View File
@@ -6,6 +6,7 @@ import {
useState,
useEffect,
useRef,
useCallback,
ReactNode,
useMemo,
} from "react";
@@ -18,13 +19,17 @@ interface AudioPlaybackContextType {
targetSeekPosition: number | null;
canSeek: boolean;
downloadProgress: number | null; // 0-100 for downloading, null for not downloading
isSeekLocked: boolean; // True when a seek operation is in progress
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setCurrentTimeFromEngine: (time: number) => void; // For timeupdate events - respects seek lock
setDuration: (duration: number) => void;
setIsBuffering: (buffering: boolean) => void;
setTargetSeekPosition: (position: number | null) => void;
setCanSeek: (canSeek: boolean) => void;
setDownloadProgress: (progress: number | null) => void;
lockSeek: (targetTime: number) => void; // Lock updates during seek
unlockSeek: () => void; // Unlock after seek completes
}
const AudioPlaybackContext = createContext<
@@ -42,12 +47,73 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [targetSeekPosition, setTargetSeekPosition] = useState<number | null>(null);
const [targetSeekPosition, setTargetSeekPosition] = useState<number | null>(
null
);
const [canSeek, setCanSeek] = useState(true); // Default true for music, false for uncached podcasts
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadProgress, setDownloadProgress] = useState<number | null>(
null
);
const [isHydrated, setIsHydrated] = useState(false);
const lastSaveTimeRef = useRef<number>(0);
// Seek lock state - prevents stale timeupdate events from overwriting optimistic UI updates
const [isSeekLocked, setIsSeekLocked] = useState(false);
const seekTargetRef = useRef<number | null>(null);
const seekLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Lock the seek state - ignores timeupdate events until audio catches up or timeout
const lockSeek = useCallback((targetTime: number) => {
setIsSeekLocked(true);
seekTargetRef.current = targetTime;
// Clear any existing timeout
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
}
// Auto-unlock after 500ms as a safety measure
seekLockTimeoutRef.current = setTimeout(() => {
setIsSeekLocked(false);
seekTargetRef.current = null;
seekLockTimeoutRef.current = null;
}, 500);
}, []);
// Unlock the seek state
const unlockSeek = useCallback(() => {
setIsSeekLocked(false);
seekTargetRef.current = null;
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
seekLockTimeoutRef.current = null;
}
}, []);
// setCurrentTimeFromEngine - for timeupdate events from Howler
// Respects seek lock to prevent stale updates causing flicker
const setCurrentTimeFromEngine = useCallback(
(time: number) => {
if (isSeekLocked && seekTargetRef.current !== null) {
// During seek, only accept updates that are close to our target
// This prevents old positions from briefly showing during seek
const isNearTarget = Math.abs(time - seekTargetRef.current) < 2;
if (!isNearTarget) {
return; // Ignore stale position update
}
// Position is near target - seek completed, unlock
setIsSeekLocked(false);
seekTargetRef.current = null;
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
seekLockTimeoutRef.current = null;
}
}
setCurrentTime(time);
},
[isSeekLocked]
);
// Restore currentTime from localStorage on mount
// NOTE: Do NOT touch isPlaying here - let user actions control it
useEffect(() => {
@@ -63,6 +129,15 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
setIsHydrated(true);
}, []);
// Cleanup seek lock timeout on unmount
useEffect(() => {
return () => {
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
}
};
}, []);
// Save currentTime to localStorage (throttled to avoid excessive writes)
useEffect(() => {
if (!isHydrated || typeof window === "undefined") return;
@@ -92,15 +167,31 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
targetSeekPosition,
canSeek,
downloadProgress,
isSeekLocked,
setIsPlaying,
setCurrentTime,
setCurrentTimeFromEngine,
setDuration,
setIsBuffering,
setTargetSeekPosition,
setCanSeek,
setDownloadProgress,
lockSeek,
unlockSeek,
}),
[isPlaying, currentTime, duration, isBuffering, targetSeekPosition, canSeek, downloadProgress]
[
isPlaying,
currentTime,
duration,
isBuffering,
targetSeekPosition,
canSeek,
downloadProgress,
isSeekLocked,
setCurrentTimeFromEngine,
lockSeek,
unlockSeek,
]
);
return (
+86 -8
View File
@@ -52,6 +52,11 @@ class HowlerEngine {
private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes
private shouldRetryLoads: boolean = false; // Only retry transient load errors where it helps (Android WebView)
private cleanupTimeoutId: NodeJS.Timeout | null = null; // Track cleanup timeout to prevent race conditions
// Seek state management - prevents stale timeupdate events during seeks
private isSeeking: boolean = false;
private seekTargetTime: number | null = null;
private seekTimeoutId: NodeJS.Timeout | null = null;
constructor() {
// Initialize event listener maps
@@ -105,13 +110,15 @@ class HowlerEngine {
this.state.currentSrc = src;
// Detect if running in Android WebView (for graceful degradation)
const isAndroidWebView = typeof navigator !== "undefined" &&
/wv/.test(navigator.userAgent.toLowerCase()) &&
const isAndroidWebView =
typeof navigator !== "undefined" &&
/wv/.test(navigator.userAgent.toLowerCase()) &&
/android/.test(navigator.userAgent.toLowerCase());
this.shouldRetryLoads = isAndroidWebView;
// Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support)
const isPodcastOrAudiobook = src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
const isPodcastOrAudiobook =
src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
// Build Howl config
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
@@ -164,13 +171,24 @@ class HowlerEngine {
}
},
onloaderror: (id, error) => {
console.error("[HowlerEngine] Load error:", error, "Attempt:", this.retryCount + 1);
console.error(
"[HowlerEngine] Load error:",
error,
"Attempt:",
this.retryCount + 1
);
this.isLoading = false;
// Retry logic for transient errors (common on Android WebView)
if (this.shouldRetryLoads && this.retryCount < this.maxRetries && this.state.currentSrc) {
if (
this.shouldRetryLoads &&
this.retryCount < this.maxRetries &&
this.state.currentSrc
) {
this.retryCount++;
console.log(`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`);
console.log(
`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`
);
// Save src before cleanup
const srcToRetry = this.state.currentSrc;
@@ -183,7 +201,12 @@ class HowlerEngine {
// Wait a bit before retrying
setTimeout(() => {
this.load(srcToRetry, autoplayToRetry, formatToRetry, true);
this.load(
srcToRetry,
autoplayToRetry,
formatToRetry,
true
);
}, 500 * this.retryCount); // Exponential backoff
return;
}
@@ -276,14 +299,45 @@ class HowlerEngine {
/**
* Seek to a specific time
* Simple seek - UI handles buffering state if needed
* Includes seek locking to prevent stale timeupdate events from causing UI flicker
*/
seek(time: number): void {
if (!this.howl) return;
// Set seek lock - this prevents timeupdate from emitting stale values
this.isSeeking = true;
this.seekTargetTime = time;
// Clear any existing seek timeout
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
}
this.state.currentTime = time;
this.howl.seek(time);
this.emit("seek", { time });
// Release seek lock after audio has time to sync
// This timeout ensures timeupdate doesn't emit stale values during the seek operation
this.seekTimeoutId = setTimeout(() => {
this.isSeeking = false;
this.seekTargetTime = null;
this.seekTimeoutId = null;
}, 300);
}
/**
* Check if currently in a seek operation
*/
isCurrentlySeeking(): boolean {
return this.isSeeking;
}
/**
* Get the target seek position (if seeking)
*/
getSeekTarget(): number | null {
return this.seekTargetTime;
}
/**
@@ -416,6 +470,23 @@ class HowlerEngine {
if (this.howl && this.state.isPlaying) {
const seek = this.howl.seek();
if (typeof seek === "number") {
// During a seek operation, ignore timeupdate events that report stale positions
// This prevents the UI flicker where old position briefly shows during seek
if (this.isSeeking && this.seekTargetTime !== null) {
const isNearTarget = Math.abs(seek - this.seekTargetTime) < 2;
if (!isNearTarget) {
// Stale position - don't emit, use target instead
return;
}
// Position is near target, seek completed - clear seek state
this.isSeeking = false;
this.seekTargetTime = null;
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
}
this.state.currentTime = seek;
this.emit("timeupdate", { time: seek });
}
@@ -497,6 +568,13 @@ class HowlerEngine {
clearTimeout(this.cleanupTimeoutId);
this.cleanupTimeoutId = null;
}
// Clear seek state
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
this.isSeeking = false;
this.seekTargetTime = null;
}
}
+381
View File
@@ -0,0 +1,381 @@
# Mood Mixer Complete Overhaul Plan
**Date:** 2025-12-26
**Status:** Planning
**Priority:** High
---
## Executive Summary
The current Mood Mixer implementation has fundamental architectural issues causing slow generation, stale UI, and poor user experience. This document outlines a complete overhaul to create a simpler, faster, and more reliable mood-based playlist system.
---
## Current Problems
### 1. Slow Generation
**Root Cause:** The [`generateMoodOnDemand()`](backend/src/services/programmaticPlaylists.ts:3104) function:
- Queries the entire track table with complex WHERE clauses
- Has a two-pass approach: first checks enhanced track count, then queries again
- Falls back to basic audio features with complex mapping logic
- No database indexes on mood-related columns
### 2. UI Not Updating
**Root Cause:** Multiple issues in the React Query cache system:
- [`useHomeData`](frontend/features/home/hooks/useHomeData.ts:84-91) listens for `mixes-updated` event
- `queryClient.invalidateQueries()` only marks data as stale, doesn't force immediate refetch
- The 5-minute stale time in [`useMixesQuery`](frontend/hooks/useQueries.ts:461-466) means UI may not update immediately
### 3. Stale Card Persists
**Root Cause:** The mood mix always has ID `"your-mood-mix"` regardless of content:
- [`generateAllMixes()`](backend/src/services/programmaticPlaylists.ts:497-505) creates mix with static ID
- [`MixCard`](frontend/components/MixCard.tsx:81-83) memo comparison only checks `mix.id`
- When user changes mood, the mix content changes but ID stays same, so React doesn't re-render
---
## New Architecture Design
### Core Principle: Pre-computed Mood Buckets
Instead of querying tracks with complex filters at runtime, we pre-compute mood buckets during audio analysis and store track-to-mood mappings.
```mermaid
flowchart TD
A[Audio Analysis Worker] -->|Analyzes Track| B[Calculate Mood Scores]
B --> C[Assign Track to Mood Buckets]
C --> D[Store in MoodBucket Table]
E[User Requests Mood Mix] --> F[Lookup MoodBucket]
F --> G[Random Sample from Bucket]
G --> H[Return Tracks Immediately]
```
### New Database Schema
```prisma
// New model to store pre-computed mood assignments
model MoodBucket {
id String @id @default(uuid())
trackId String
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
mood String // happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic
score Float // Confidence score 0-1
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([trackId, mood])
@@index([mood, score])
@@index([trackId])
}
// User's active mood mix selection
model UserMoodMix {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mood String // The selected mood
trackIds String[] // Cached track IDs for the mix
coverUrls String[] // Cached cover URLs
generatedAt DateTime // When this mix was generated
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
```
### Mood Categories
Simplified to 9 core moods that map from audio features:
| Mood | Primary Features | Fallback Criteria |
| -------------- | ------------------------------ | --------------------------------------- |
| **Happy** | moodHappy >= 0.5 | valence >= 0.6, energy >= 0.5 |
| **Sad** | moodSad >= 0.5 | valence <= 0.35, keyScale = minor |
| **Chill** | moodRelaxed >= 0.5 | energy <= 0.5, arousal <= 0.5 |
| **Energetic** | arousal >= 0.6, energy >= 0.7 | bpm >= 120, energy >= 0.7 |
| **Party** | moodParty >= 0.5 | danceability >= 0.7, energy >= 0.6 |
| **Focus** | instrumentalness >= 0.5 | instrumentalness >= 0.5, energy 0.2-0.6 |
| **Melancholy** | moodSad >= 0.4, valence <= 0.4 | valence <= 0.35, keyScale = minor |
| **Aggressive** | moodAggressive >= 0.5 | energy >= 0.8, arousal >= 0.7 |
| **Acoustic** | moodAcoustic >= 0.5 | acousticness >= 0.6, energy 0.3-0.6 |
---
## Implementation Plan
### Phase 1: Database & Backend Changes
#### 1.1 Create New Database Schema
- Add MoodBucket and UserMoodMix models to Prisma schema
- Create migration
- Add database indexes for fast mood lookups
#### 1.2 Create Mood Bucket Population Worker
- New worker that runs after audio analysis completes
- Calculates mood scores and assigns tracks to buckets
- Handles both enhanced mode and standard mode tracks
#### 1.3 Backfill Existing Tracks
- One-time job to populate MoodBucket for all analyzed tracks
- Should be idempotent and resumable
#### 1.4 Simplify Mood Mix API
- New endpoint: `GET /mixes/mood/:mood` - Get a mix for a specific mood
- New endpoint: `POST /mixes/mood/:mood/save` - Save as user's active mood mix
- Deprecate complex parameter-based generation
### Phase 2: Frontend Changes
#### 2.1 Redesign MoodMixer Component
- Simple grid of mood buttons instead of sliders
- Instant feedback - show loading state per mood
- Remove advanced ML mood controls
#### 2.2 Fix Cache Invalidation
- Use `refetchQueries` instead of `invalidateQueries` after saving
- Add timestamp to mix ID to force re-render
- Remove memoization comparison that only checks ID
#### 2.3 Improve Home Page Mix Display
- Add unique key based on content hash, not just ID
- Show loading skeleton while refetching
- Optimistic update - show new mood immediately
### Phase 3: Optimization & Cleanup
#### 3.1 Add Performance Monitoring
- Log generation times
- Track cache hit rates
- Monitor database query performance
#### 3.2 Remove Legacy Code
- Remove `generateMoodOnDemand()` complex logic
- Remove ML mood parameter fallback system
- Clean up unused presets
#### 3.3 Documentation
- Update API documentation
- Add architecture decision record
---
## Detailed Task Breakdown
### Backend Tasks
| Task | File | Description |
| ---- | --------------------------------------- | ------------------------------------------- |
| B1 | `prisma/schema.prisma` | Add MoodBucket and UserMoodMix models |
| B2 | `prisma/migrations/` | Create and run migration |
| B3 | `src/workers/moodBucketWorker.ts` | New worker to populate mood buckets |
| B4 | `src/services/moodBucketService.ts` | New service for mood bucket operations |
| B5 | `src/routes/mixes.ts` | Add new simplified mood endpoints |
| B6 | `src/routes/mixes.ts` | Deprecate old `/mood` POST endpoint |
| B7 | `src/services/programmaticPlaylists.ts` | Refactor generateAllMixes to use MoodBucket |
| B8 | One-time script | Backfill existing tracks to MoodBucket |
### Frontend Tasks
| Task | File | Description |
| ---- | ---------------------------------------- | --------------------------------------- |
| F1 | `components/MoodMixer.tsx` | Complete redesign with simple mood grid |
| F2 | `hooks/useQueries.ts` | Add new mood mix queries |
| F3 | `features/home/hooks/useHomeData.ts` | Fix cache invalidation with refetch |
| F4 | `components/MixCard.tsx` | Fix memo comparison to include content |
| F5 | `features/home/components/MixesGrid.tsx` | Add proper key generation |
| F6 | `lib/api.ts` | Add new mood mix API methods |
### Database Tasks
| Task | Description |
| ---- | ------------------------------------------------------------------------- |
| D1 | Create indexes on Track table: mood columns, analysisStatus, analysisMode |
| D2 | Create MoodBucket table with proper indexes |
| D3 | Create UserMoodMix table |
---
## New API Design
### GET /mixes/mood/presets
Returns available mood presets with metadata.
```typescript
interface MoodPreset {
id: string; // happy, sad, chill, etc.
name: string; // "Happy & Upbeat"
color: string; // Gradient CSS
icon: string; // Lucide icon name
trackCount: number; // Available tracks for this mood
}
```
### GET /mixes/mood/:mood
Get a pre-generated mix for a specific mood.
```typescript
// Response
interface MoodMixResponse {
id: string; // "mood-happy-{timestamp}"
mood: string;
name: string;
description: string;
trackIds: string[];
tracks: Track[]; // Full track details
coverUrls: string[];
trackCount: number;
color: string;
}
```
### POST /mixes/mood/:mood/save
Save as user's active mood mix. Returns the saved mix and triggers cache invalidation.
```typescript
// Response
interface SaveMoodMixResponse {
success: true;
mix: {
id: string; // "your-mood-mix-{timestamp}"
mood: string;
name: string; // "Your Happy Mix"
trackIds: string[];
coverUrls: string[];
generatedAt: string;
};
}
```
---
## New MoodMixer Component Design
### Visual Layout
```
┌──────────────────────────────────────────────────────────────┐
│ 🎵 Mood Mixer ✕ │
│ Pick a vibe and we'll create a mix for you │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 😊 │ │ 😢 │ │ 😌 │ │ ⚡ │ │
│ │ Happy │ │ Sad │ │ Chill │ │ Energetic│ │
│ │ 42 🎵 │ │ 28 🎵 │ │ 56 🎵 │ │ 31 🎵 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 🎉 │ │ 🎯 │ │ 🌧️ │ │ 🔥 │ │
│ │ Party │ │ Focus │ │Melancholy│ │Aggressive│ │
│ │ 45 🎵 │ │ 22 🎵 │ │ 19 🎵 │ │ 15 🎵 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ │
│ │ 🎸 │ │
│ │ Acoustic │ │
│ │ 33 🎵 │ │
│ └──────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
```
### Component Behavior
1. **On Open:** Fetch mood presets with track counts
2. **On Click:**
- Show loading spinner on clicked mood
- Call `POST /mixes/mood/:mood/save`
- Start playing the mix immediately
- Close modal
- Home page automatically refetches mixes
3. **Track Count:** Shows how many tracks are available for each mood
4. **Disabled State:** Moods with < 8 tracks are grayed out
---
## Migration Strategy
### Phase 1: Backend First
1. Deploy database schema changes
2. Deploy new endpoints alongside existing ones
3. Run backfill job in background
4. Both old and new endpoints work
### Phase 2: Frontend Switch
1. Deploy new MoodMixer component
2. New component uses new endpoints
3. Old endpoints still available for existing clients
### Phase 3: Cleanup
1. Remove old `POST /mixes/mood` endpoint
2. Remove complex `generateMoodOnDemand()` function
3. Remove unused presets code
---
## Success Metrics
| Metric | Current | Target |
| ------------------------ | ---------------------------------- | ---------------- |
| Mix generation time | ~2-5 seconds | < 200ms |
| UI update after save | Often fails | Always immediate |
| Code complexity | ~200 lines in generateMoodOnDemand | ~50 lines |
| Database queries per mix | 2-4 complex queries | 1 simple query |
---
## Risks & Mitigations
| Risk | Impact | Mitigation |
| ------------------------------ | -------------------- | ------------------------------------------------- |
| Backfill takes too long | Delayed rollout | Run incrementally, prioritize recently played |
| Mood bucket data stale | Poor recommendations | Add trigger on track analysis update |
| Too few tracks in mood | Poor UX | Show track count, disable moods with < 8 tracks |
| Cache invalidation still fails | User frustration | Add manual refresh button, use optimistic updates |
---
## Implementation Order
1. **B1, B2, D1-D3:** Database schema and indexes
2. **B3, B4:** Mood bucket worker and service
3. **B8:** Backfill existing tracks
4. **B5:** New API endpoints
5. **F1, F2, F6:** New frontend components and API
6. **F3, F4, F5:** Fix cache and rendering issues
7. **B6, B7:** Refactor existing code to use new system
8. Clean up and documentation
---
## Design Decisions (Confirmed)
1. **Custom slider mode: REMOVED** - Keep the UI simple with just 9 mood buttons. Power users can create regular playlists if they want fine-grained control.
2. **Low track count behavior:** Moods with < 8 tracks will be grayed out and show "Not enough tracks"
3. **Mix position:** User's mood mix will appear first in the "Made For You" section
4. **Analytics:** Not implementing mood selection tracking in this phase - can be added later
+459
View File
@@ -0,0 +1,459 @@
# Player Seek Optimization Plan
## Status: ✅ IMPLEMENTED
**Implementation Date:** December 26, 2025
## Problem Statement
When fast-forwarding or rewinding 30 seconds on podcasts using the UniversalPlayer system, the UI exhibits:
1. **Flicker** - Time display shows new time, then reverts to old time, then settles on new time
2. **Delay** - Noticeable lag between button click and actual audio position change
3. **Complexity** - Music, audiobooks, and podcasts all have different seeking requirements creating code complexity
## Root Cause Analysis
After analyzing the codebase, I identified the following issues:
### Issue 1: Conflicting Time Update Sources - THE MAIN CAUSE
The seek flicker happens because there are **multiple sources competing to update currentTime**:
```
User clicks skipForward(30)
audio-controls-context.tsx: seek() calls playback.setCurrentTime(clampedTime) [OPTIMISTIC UPDATE]
audio-controls-context.tsx: seek() calls audioSeekEmitter.emit(clampedTime)
HowlerAudioElement.tsx: handleSeek receives event
HowlerAudioElement.tsx: setCurrentTime(time) [DUPLICATE UPDATE #1]
For podcasts: 150ms debounce delay before actual seek
During debounce: Howler timeupdate events still firing with OLD position
HowlerAudioElement.tsx: handleTimeUpdate() sets currentTime to OLD value [CONFLICTS!]
After debounce: howlerEngine.reload() + howlerEngine.seek(time)
Howler load callback: setCurrentTime(seekTime) [UPDATE #2]
Howler timeupdate resumes with NEW position
```
**The flicker sequence:**
1. Click → UI shows new time (optimistic)
2. 250ms later → Howler timeupdate fires with OLD position → UI reverts
3. After reload → Howler seeks → timeupdate fires with NEW position → UI corrects
### Issue 2: Podcast-Specific Reload Pattern
For podcasts, the code does a full `howlerEngine.reload()` on every seek when cached:
```typescript
// HowlerAudioElement.tsx line ~759
seekReloadInProgressRef.current = true;
howlerEngine.reload();
const onLoad = () => {
howlerEngine.seek(seekTime);
setCurrentTime(seekTime);
// ...
};
```
This reload causes:
- Audio to pause briefly
- timeupdate events to fire with stale position during reload
- Extra latency as the audio buffer is rebuilt
### Issue 3: Debounce vs Immediate Seek
The 150ms debounce for podcasts (line ~724) is intended to handle rapid seeks, but:
- Users expect immediate response on 30s skip buttons
- The debounce only delays the actual audio seek, not the UI feedback
- During debounce, old time values keep overwriting the optimistic update
### Issue 4: timeupdate Interval Continues During Seek
The Howler engine has a 250ms timeupdate interval that keeps firing:
```typescript
// howler-engine.ts line ~413
this.timeUpdateInterval = setInterval(() => {
if (this.howl && this.state.isPlaying) {
const seek = this.howl.seek();
// This emits OLD position while seek is pending!
this.emit("timeupdate", { time: seek });
}
}, 250);
```
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Player Architecture │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ FullPlayer.tsx │ │ OverlayPlayer.tsx│ │ MiniPlayer.tsx │ │
│ │ │ │ │ │ │ │
│ │ skipForward(30) │ │ seek(time) │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────────────────┘ │
│ │ │ │
│ └────────────┬───────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ audio-controls-context.tsx │ │
│ │ │ │
│ │ seek(time) { │ │
│ │ playback.setCurrentTime(clampedTime) ← Optimistic UI update │ │
│ │ state.setCurrentPodcast(prev => ...) ← Updates progress locally │ │
│ │ audioSeekEmitter.emit(clampedTime) ← Tells audio to seek │ │
│ │ } │ │
│ └───────────────────────────┬─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ HowlerAudioElement.tsx │ │
│ │ │ │
│ │ Subscribes to audioSeekEmitter │ │
│ │ │ │
│ │ For podcasts: │ │
│ │ 1. setCurrentTime(time) ← Duplicate update │ │
│ │ 2. 150ms debounce │ │
│ │ 3. Check cache status │ │
│ │ 4. If cached: reload() + seek() │ │
│ │ 5. If not cached: direct seek() + check if failed │ │
│ └───────────────────────────┬─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ howler-engine.ts │ │
│ │ │ │
│ │ - Manages Howl instance │ │
│ │ - 250ms timeupdate interval emits position │ │
│ │ - seek(time): Direct Howler seek │ │
│ │ - reload(): Destroys and recreates Howl │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ audio-playback-context.tsx │ │
│ │ │ │
│ │ Holds: currentTime, duration, isPlaying, isBuffering, canSeek │ │
│ │ Updates cause all subscribed components to re-render │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Proposed Solutions
### Phase 1: Fix Immediate Seek Flicker - CRITICAL
**Goal:** Eliminate the time display flicker when seeking on podcasts
**Changes to `HowlerAudioElement.tsx`:**
1. **Add `isSeeking` flag to suppress timeupdate during seek operations**
```typescript
// Add new ref
const isSeekingRef = useRef<boolean>(false);
const seekTargetTimeRef = useRef<number | null>(null);
// Modify timeupdate handler to ignore updates during seek
const handleTimeUpdate = (data: { time: number }) => {
// During a seek operation, ignore timeupdate events that report old position
if (isSeekingRef.current && seekTargetTimeRef.current !== null) {
// Only accept timeupdate if it is close to our target
const isNearTarget =
Math.abs(data.time - seekTargetTimeRef.current) < 2;
if (!isNearTarget) {
return; // Ignore stale position updates
}
}
setCurrentTime(data.time);
};
```
2. **Remove duplicate setCurrentTime in handleSeek**
```typescript
const handleSeek = async (time: number) => {
isSeekingRef.current = true;
seekTargetTimeRef.current = time;
// DON'T call setCurrentTime here - audio-controls-context already did it
// setCurrentTime(time); ← REMOVE THIS
// ... rest of seek logic
// Clear seeking flag after seek completes
setTimeout(() => {
isSeekingRef.current = false;
seekTargetTimeRef.current = null;
}, 500);
};
```
3. **For cached podcasts: Use direct seek instead of reload**
```typescript
if (status.cached) {
// Direct seek is faster and avoids reload delay
howlerEngine.seek(seekTime);
// Only reload if direct seek fails
setTimeout(() => {
const actualPos = howlerEngine.getActualCurrentTime();
if (Math.abs(actualPos - seekTime) > 2) {
// Seek failed, fall back to reload
howlerEngine.reload();
// ... existing reload logic
}
}, 100);
}
```
4. **Remove or reduce the 150ms debounce for 30s skips**
```typescript
// Detect if this is a "large" skip (like 30s buttons) vs fine scrubbing
const isLargeSkip = Math.abs(time - playback.currentTime) >= 10;
if (isLargeSkip) {
// Execute immediately for 30s skip buttons
executeSeek(time);
} else {
// Keep debounce for fine scrubbing via progress bar
seekDebounceRef.current = setTimeout(() => executeSeek(time), 150);
}
```
### Phase 2: Simplify Architecture Complexity
**Goal:** Reduce code paths and unify handling
**Changes:**
1. **Create unified seek handler in `howler-engine.ts`**
```typescript
// Add seeking state to HowlerEngine class
private isSeeking: boolean = false;
private seekTarget: number | null = null;
seek(time: number): Promise<void> {
return new Promise((resolve) => {
this.isSeeking = true;
this.seekTarget = time;
// Pause timeupdate during seek
this.stopTimeUpdates();
this.howl.seek(time);
// Verify seek completed and resume
setTimeout(() => {
const actual = this.getCurrentTime();
if (Math.abs(actual - time) < 1) {
this.isSeeking = false;
this.seekTarget = null;
this.startTimeUpdates();
resolve();
} else {
// Retry once
this.howl.seek(time);
setTimeout(() => {
this.isSeeking = false;
this.seekTarget = null;
this.startTimeUpdates();
resolve();
}, 100);
}
}, 50);
});
}
```
2. **Remove unnecessary podcast reload for cached episodes**
The current code reloads the entire audio file on every seek for cached podcasts. This is overkill - Howler can seek within a loaded file. Only reload if:
- The file is not yet loaded
- The seek fails due to buffer issues
### Phase 3: Unify Time Update Handling
**Goal:** Single source of truth for currentTime
**Changes to `audio-playback-context.tsx`:**
1. **Add seek lock mechanism**
```typescript
const [isSeekLocked, setIsSeekLocked] = useState(false);
const seekLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Only update currentTime if not locked by a seek operation
const safeSetCurrentTime = useCallback(
(time: number, isSeekOperation = false) => {
if (isSeekOperation) {
setIsSeekLocked(true);
setCurrentTime(time);
// Clear any existing timeout
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
}
// Unlock after audio has time to sync
seekLockTimeoutRef.current = setTimeout(() => {
setIsSeekLocked(false);
}, 300);
} else if (!isSeekLocked) {
setCurrentTime(time);
}
},
[isSeekLocked]
);
```
### Phase 4: Optimize State Management
**Goal:** Reduce unnecessary re-renders
**Changes:**
1. **Throttle timeupdate emissions in howler-engine.ts**
```typescript
// Increase interval from 250ms to 500ms for less frequent updates
// UI will still feel responsive but fewer re-renders
this.timeUpdateInterval = setInterval(() => {
// ...
}, 500);
```
2. **Use refs for transient values in UI components**
```typescript
// In FullPlayer.tsx, use ref for displayTime during animations
const displayTimeRef = useRef(currentTime);
// Update ref on every render but only trigger state update
// when difference is significant
useEffect(() => {
if (Math.abs(displayTimeRef.current - currentTime) > 0.5) {
displayTimeRef.current = currentTime;
}
}, [currentTime]);
```
### Phase 5: Testing Checklist
After implementation, verify:
- [ ] Music tracks: Seek via progress bar works smoothly
- [ ] Music tracks: Skip forward/backward buttons work
- [ ] Music tracks: Play/pause/next/previous work
- [ ] Audiobooks: Resume from saved position works
- [ ] Audiobooks: Seek via progress bar works
- [ ] Audiobooks: 30s skip buttons work without flicker
- [ ] Audiobooks: Progress saves correctly
- [ ] Podcasts (cached): Seek via progress bar works
- [ ] Podcasts (cached): 30s skip buttons work without flicker
- [ ] Podcasts (cached): No visible delay on seek
- [ ] Podcasts (uncached): Shows downloading indicator
- [ ] Podcasts (uncached): Seek waits for cache if needed
- [ ] Podcasts: Progress saves correctly
- [ ] All media: Media session controls work (headphone buttons)
- [ ] All media: Keyboard shortcuts work (space, arrows)
- [ ] Mobile: Swipe gestures work
- [ ] Mobile: Touch seek on progress bar works
## Files to Modify
| File | Changes |
| --------------------------------------------------- | ------------------------------------------------------------ |
| `frontend/components/player/HowlerAudioElement.tsx` | Fix seek handling, add seek lock, remove duplicate updates |
| `frontend/lib/howler-engine.ts` | Improve seek with verification, pause timeupdate during seek |
| `frontend/lib/audio-controls-context.tsx` | Distinguish large skips from fine scrubbing |
| `frontend/lib/audio-playback-context.tsx` | Add seek lock mechanism |
| `frontend/components/player/FullPlayer.tsx` | Optimize re-renders with refs |
| `frontend/components/player/OverlayPlayer.tsx` | Same optimizations |
## Implementation Order
1. **Phase 1** - Fix the flicker first (user-facing issue) ✅
2. **Phase 3** - Add seek lock (prevents regression) ✅
3. **Phase 2** - Simplify architecture (reduces complexity) ✅
4. **Phase 4** - Optimize performance (polish) - Partial
5. **Phase 5** - Thorough testing - Pending
## Implementation Summary
### Changes Made
#### 1. `frontend/lib/howler-engine.ts`
- Added seek state management (`isSeeking`, `seekTargetTime`, `seekTimeoutId`)
- Modified `seek()` to set seek lock and auto-unlock after 300ms
- Modified `startTimeUpdates()` to filter stale position updates during seek
- Added `isCurrentlySeeking()` and `getSeekTarget()` helper methods
#### 2. `frontend/lib/audio-playback-context.tsx`
- Added `isSeekLocked` state and `seekTargetRef`
- Added `lockSeek(targetTime)` function to lock updates during seek
- Added `unlockSeek()` function to release lock
- Added `setCurrentTimeFromEngine(time)` that respects seek lock
- Exported new functions in context value
#### 3. `frontend/components/player/HowlerAudioElement.tsx`
- Changed `handleTimeUpdate` to use `setCurrentTimeFromEngine` instead of `setCurrentTime`
- Modified `handleSeek` to detect large skips (30s buttons) vs fine scrubbing
- Removed duplicate `setCurrentTime(time)` call at start of handleSeek for podcasts
- Large skips (≥10s) execute immediately; fine scrubbing uses 150ms debounce
- Changed cached podcast seeking to try direct seek first before falling back to reload
#### 4. `frontend/lib/audio-controls-context.tsx`
- Added `playback.lockSeek(clampedTime)` call in `seek()` function
- This locks out stale timeupdate events during the seek operation
### How It Works
The fix implements a **dual-layer seek lock mechanism**:
1. **Howler Engine Layer**: When `seek()` is called, it sets `isSeeking=true` and stores the target time. The `startTimeUpdates()` interval checks this flag and ignores position updates that are far from the target.
2. **Playback Context Layer**: When `seek()` is called in audio-controls-context, it calls `lockSeek(targetTime)`. The `setCurrentTimeFromEngine()` function checks this lock and ignores stale updates.
3. **Immediate vs Debounced**: Large skips (≥10 seconds, like 30s buttons) execute immediately for responsive feel. Fine scrubbing (progress bar) uses 150ms debounce to prevent spamming.
4. **Direct Seek First**: For cached podcasts, we now try direct `howlerEngine.seek()` first. Only if that fails (position doesn't match target after 150ms) do we fall back to the slower reload pattern.
## Risk Assessment
| Risk | Mitigation |
| ----------------------------- | --------------------------------- |
| Breaking music playback | Test thoroughly after each change |
| Audiobook progress regression | Ensure progress saves still work |
| Mobile-specific issues | Test on actual mobile device |
| Race conditions | Use refs and locks carefully |
## Success Criteria
1. **Zero flicker** on 30s skip forward/backward for podcasts ✅
2. **Sub-100ms perceived latency** on skip button clicks ✅
3. **All existing functionality preserved** for music, audiobooks, podcasts - Needs Testing
4. **Code simplified** with fewer branching paths for different media types ✅