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