Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e59f92fa7 | |||
| f8b464feec | |||
| d8c608cf70 | |||
| 27b03e4b8f |
@@ -101,10 +101,6 @@ jobs:
|
|||||||
|
|
||||||
Then open http://localhost:3030 and create your account!
|
Then open http://localhost:3030 and create your account!
|
||||||
|
|
||||||
## Android App
|
|
||||||
|
|
||||||
Download the APK below and install on your device.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
See the [README](https://github.com/${{ github.repository }}#readme) for full documentation.
|
See the [README](https://github.com/${{ github.repository }}#readme) for full documentation.
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ Lidify is built for music lovers who want the convenience of streaming services
|
|||||||
|
|
||||||
## A Note on Native Apps
|
## A Note on Native Apps
|
||||||
|
|
||||||
I got a little ambitious trying to ship both a polished web app AND a native Android app at the same time. Turns out, trying to half-ass two things is worse than whole-assing one thing.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Thanks for your patience while I work through this.
|
Thanks for your patience while I work through this.
|
||||||
|
|
||||||
@@ -314,39 +312,42 @@ ALLOWED_ORIGINS=http://localhost:3030,https://lidify.yourdomain.com
|
|||||||
|
|
||||||
Lidify uses several sensitive environment variables. Never commit your `.env` file.
|
Lidify uses several sensitive environment variables. Never commit your `.env` file.
|
||||||
|
|
||||||
| Variable | Purpose | Required |
|
| Variable | Purpose | Required |
|
||||||
| ------------------------ | ------------------------------ | --------------- |
|
| ------------------------- | ------------------------------ | ------------------ |
|
||||||
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
|
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
|
||||||
| `SETTINGS_ENCRYPTION_KEY`| Encrypts stored credentials | Recommended |
|
| `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Recommended |
|
||||||
| `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek |
|
| `SOULSEEK_USERNAME` | Soulseek login | If u sing Soulseek |
|
||||||
| `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek |
|
| `SOULSEEK_PASSWORD`- | Soulseek password - | If using S-oulseek |
|
||||||
| `LIDARR_API_KEY` | Lidarr integration | If using Lidarr |
|
| `LIDARR_AP I_KEY` | Lidarr integration | If using L idarr |
|
||||||
| `OPENAI_API_KEY` | AI features | Optional |
|
| `OPENAI_API_KEY` | AI features | Optional |
|
||||||
| `LASTFM_API_KEY` | Artist recommendations | Optional |
|
| `LASTFM_API_KEY ` | Artist recommendations | Optional |
|
||||||
| `FANART_API_KEY` | Artist images | Optional |
|
| `FANART_API_KEY` | Artist images | Optional |
|
||||||
|
|
||||||
### VPN Configuration (Optional)
|
### VPN Configurati on (Optional)
|
||||||
|
|
||||||
If using Mullvad VPN for Soulseek:
|
If using Mullvad VPN for Soulseek:
|
||||||
- Place WireGuard config in `backend/mullvad/` (gitignored)
|
|
||||||
- Never commit VPN credentials or private keys
|
- Place Wi reGuard config in `ba ckend/mullvad/` (gitignored)
|
||||||
- The `*.conf` and `key.txt` patterns are already in .gitignore
|
- Never commit VPN cred entials or private keys
|
||||||
|
- The `*.conf` and `key.txt` patterns are already in .git ignore
|
||||||
|
|
||||||
### Generating Secrets
|
### Generating Secrets
|
||||||
|
|
||||||
```bash
|
```bas h
|
||||||
# Generate a secure session secret
|
# Generate a secure session secret
|
||||||
openssl rand -base64 32
|
openss l rand - base64 32
|
||||||
|
|
||||||
# Generate encryption key
|
# Generate encryption key
|
||||||
openssl rand -hex 32
|
openssl rand -hex 32
|
||||||
```
|
```
|
||||||
|
|
||||||
### Network Security
|
### Network
|
||||||
|
|
||||||
- Lidify is designed for self-hosted LAN use
|
Sec urity
|
||||||
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -356,12 +357,12 @@ Lidify works beautifully on its own, but it becomes even more powerful when conn
|
|||||||
|
|
||||||
### Lidarr
|
### 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
|
- 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
|
- Discover Weekly playlists that automatically download new recommendations
|
||||||
- Automatic library sync when Lidarr finishes importing
|
- Automatic library sync when Lidarr finishes importing
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
deviceLinkCodes DeviceLinkCode[]
|
||||||
hiddenPlaylists HiddenPlaylist[]
|
hiddenPlaylists HiddenPlaylist[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
userMoodMix UserMoodMix?
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserSettings {
|
model UserSettings {
|
||||||
@@ -129,17 +130,18 @@ model SystemSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Artist {
|
model Artist {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
mbid String @unique
|
mbid String @unique
|
||||||
name String
|
name String
|
||||||
normalizedName String @default("") // Lowercase version for case-insensitive matching
|
normalizedName String @default("") // Lowercase version for case-insensitive matching
|
||||||
summary String? @db.Text
|
summary String? @db.Text
|
||||||
heroUrl String?
|
heroUrl String?
|
||||||
genres Json? // Array of genre strings from Last.fm/MusicBrainz
|
genres Json? // Array of genre strings from Last.fm/MusicBrainz
|
||||||
lastSynced DateTime @default(now())
|
similarArtistsJson Json? // Full Last.fm similar artists data [{name, mbid, match, image}]
|
||||||
lastEnriched DateTime?
|
lastSynced DateTime @default(now())
|
||||||
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
lastEnriched DateTime?
|
||||||
searchVector Unsupported("tsvector")?
|
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
||||||
|
searchVector Unsupported("tsvector")?
|
||||||
|
|
||||||
albums Album[]
|
albums Album[]
|
||||||
similarFrom SimilarArtist[] @relation("FromArtist")
|
similarFrom SimilarArtist[] @relation("FromArtist")
|
||||||
@@ -248,16 +250,28 @@ model Track {
|
|||||||
likedBy LikedTrack[]
|
likedBy LikedTrack[]
|
||||||
cachedBy CachedTrack[]
|
cachedBy CachedTrack[]
|
||||||
transcodedFiles TranscodedFile[]
|
transcodedFiles TranscodedFile[]
|
||||||
|
moodBuckets MoodBucket[]
|
||||||
|
|
||||||
@@index([albumId])
|
@@index([albumId])
|
||||||
@@index([fileModified])
|
@@index([fileModified])
|
||||||
@@index([title])
|
@@index([title])
|
||||||
@@index([searchVector], type: Gin)
|
@@index([searchVector], type: Gin)
|
||||||
@@index([analysisStatus])
|
@@index([analysisStatus])
|
||||||
|
@@index([analysisMode])
|
||||||
@@index([bpm])
|
@@index([bpm])
|
||||||
@@index([energy])
|
@@index([energy])
|
||||||
@@index([valence])
|
@@index([valence])
|
||||||
@@index([danceability])
|
@@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
|
// Transcoded file cache for audio streaming
|
||||||
@@ -881,6 +895,44 @@ model DeviceLinkCode {
|
|||||||
@@index([userId])
|
@@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
|
// Enums
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+1065
-499
File diff suppressed because it is too large
Load Diff
+327
-21
@@ -1,6 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireAuthOrToken } from "../middleware/auth";
|
import { requireAuthOrToken } from "../middleware/auth";
|
||||||
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
|
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
|
||||||
|
import {
|
||||||
|
moodBucketService,
|
||||||
|
VALID_MOODS,
|
||||||
|
MoodType,
|
||||||
|
} from "../services/moodBucketService";
|
||||||
import { prisma } from "../utils/db";
|
import { prisma } from "../utils/db";
|
||||||
import { redisClient } from "../utils/redis";
|
import { redisClient } from "../utils/redis";
|
||||||
|
|
||||||
@@ -182,24 +187,43 @@ router.post("/mood", async (req, res) => {
|
|||||||
// Validate parameters
|
// Validate parameters
|
||||||
const validKeys = [
|
const validKeys = [
|
||||||
// Basic audio features
|
// Basic audio features
|
||||||
'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale',
|
"valence",
|
||||||
|
"energy",
|
||||||
|
"danceability",
|
||||||
|
"acousticness",
|
||||||
|
"instrumentalness",
|
||||||
|
"arousal",
|
||||||
|
"bpm",
|
||||||
|
"keyScale",
|
||||||
// ML mood predictions
|
// ML mood predictions
|
||||||
'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic',
|
"moodHappy",
|
||||||
|
"moodSad",
|
||||||
|
"moodRelaxed",
|
||||||
|
"moodAggressive",
|
||||||
|
"moodParty",
|
||||||
|
"moodAcoustic",
|
||||||
|
"moodElectronic",
|
||||||
// Other
|
// Other
|
||||||
'limit'
|
"limit",
|
||||||
];
|
];
|
||||||
for (const key of Object.keys(params)) {
|
for (const key of Object.keys(params)) {
|
||||||
if (!validKeys.includes(key)) {
|
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) {
|
if (!mix) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Not enough tracks matching your criteria",
|
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))
|
.map((id: string) => tracks.find((t) => t.id === id))
|
||||||
.filter((t: any) => t !== undefined);
|
.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({
|
res.json({
|
||||||
...mix,
|
...mix,
|
||||||
@@ -251,73 +277,123 @@ router.get("/mood/presets", async (req, res) => {
|
|||||||
id: "happy",
|
id: "happy",
|
||||||
name: "Happy & Upbeat",
|
name: "Happy & Upbeat",
|
||||||
color: "from-yellow-400 to-orange-500",
|
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",
|
id: "sad",
|
||||||
name: "Melancholic",
|
name: "Melancholic",
|
||||||
color: "from-blue-600 to-indigo-700",
|
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",
|
id: "chill",
|
||||||
name: "Chill & Relaxed",
|
name: "Chill & Relaxed",
|
||||||
color: "from-teal-400 to-cyan-500",
|
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",
|
id: "energetic",
|
||||||
name: "High Energy",
|
name: "High Energy",
|
||||||
color: "from-red-500 to-orange-600",
|
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",
|
id: "focus",
|
||||||
name: "Focus Mode",
|
name: "Focus Mode",
|
||||||
color: "from-purple-600 to-violet-700",
|
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",
|
id: "dance",
|
||||||
name: "Dance Party",
|
name: "Dance Party",
|
||||||
color: "from-pink-500 to-rose-600",
|
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",
|
id: "acoustic",
|
||||||
name: "Acoustic Vibes",
|
name: "Acoustic Vibes",
|
||||||
color: "from-amber-500 to-yellow-600",
|
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",
|
id: "dark",
|
||||||
name: "Dark & Moody",
|
name: "Dark & Moody",
|
||||||
color: "from-gray-700 to-slate-800",
|
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",
|
id: "romantic",
|
||||||
name: "Romantic",
|
name: "Romantic",
|
||||||
color: "from-rose-500 to-pink-600",
|
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",
|
id: "workout",
|
||||||
name: "Workout Beast",
|
name: "Workout Beast",
|
||||||
color: "from-green-500 to-emerald-600",
|
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",
|
id: "sleepy",
|
||||||
name: "Sleep & Unwind",
|
name: "Sleep & Unwind",
|
||||||
color: "from-indigo-400 to-purple-500",
|
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",
|
id: "confident",
|
||||||
name: "Confidence Boost",
|
name: "Confidence Boost",
|
||||||
color: "from-amber-400 to-orange-500",
|
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
|
// Validate that at least some params are provided
|
||||||
if (!params || Object.keys(params).length === 0) {
|
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
|
// Save to user record
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { moodMixParams: params }
|
data: { moodMixParams: params },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate mix cache so the new mood mix appears
|
// 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
|
* @openapi
|
||||||
* /mixes/refresh:
|
* /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 {
|
try {
|
||||||
// If artist has a temp MBID, try to get the real one from MusicBrainz
|
// If artist has a temp MBID, try to get the real one from MusicBrainz
|
||||||
if (artist.mbid.startsWith("temp-")) {
|
if (artist.mbid.startsWith("temp-")) {
|
||||||
console.log(`${logPrefix} Temp MBID detected, searching MusicBrainz...`);
|
console.log(
|
||||||
|
`${logPrefix} Temp MBID detected, searching MusicBrainz...`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const mbResults = await musicBrainzService.searchArtist(
|
const mbResults = await musicBrainzService.searchArtist(
|
||||||
artist.name,
|
artist.name,
|
||||||
@@ -40,7 +42,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
);
|
);
|
||||||
if (mbResults.length > 0 && mbResults[0].id) {
|
if (mbResults.length > 0 && mbResults[0].id) {
|
||||||
const realMbid = 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
|
// Update artist with real MBID
|
||||||
await prisma.artist.update({
|
await prisma.artist.update({
|
||||||
@@ -51,10 +55,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
// Update the local artist object
|
// Update the local artist object
|
||||||
artist.mbid = realMbid;
|
artist.mbid = realMbid;
|
||||||
} else {
|
} else {
|
||||||
console.log(`${logPrefix} MusicBrainz: No match found, keeping temp MBID`);
|
console.log(
|
||||||
|
`${logPrefix} MusicBrainz: No match found, keeping temp MBID`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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;
|
let heroUrl = null;
|
||||||
|
|
||||||
if (!artist.mbid.startsWith("temp-")) {
|
if (!artist.mbid.startsWith("temp-")) {
|
||||||
console.log(`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`);
|
console.log(
|
||||||
|
`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const wikidataInfo = await wikidataService.getArtistInfo(
|
const wikidataInfo = await wikidataService.getArtistInfo(
|
||||||
artist.mbid
|
artist.mbid
|
||||||
@@ -73,12 +85,18 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
heroUrl = wikidataInfo.image;
|
heroUrl = wikidataInfo.image;
|
||||||
if (summary) summarySource = "wikidata";
|
if (summary) summarySource = "wikidata";
|
||||||
if (heroUrl) imageSource = "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 {
|
} else {
|
||||||
console.log(`${logPrefix} Wikidata: No data returned`);
|
console.log(`${logPrefix} Wikidata: No data returned`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`${logPrefix} Wikidata: FAILED - ${error?.message || error}`);
|
console.log(
|
||||||
|
`${logPrefix} Wikidata: FAILED - ${error?.message || error}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`${logPrefix} Wikidata: Skipped (temp MBID)`);
|
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
|
// Fallback to Last.fm if Wikidata didn't work
|
||||||
if (!summary || !heroUrl) {
|
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 {
|
try {
|
||||||
const validMbid = artist.mbid.startsWith("temp-")
|
const validMbid = artist.mbid.startsWith("temp-")
|
||||||
? undefined
|
? undefined
|
||||||
@@ -108,37 +128,63 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
|
|
||||||
// Try Fanart.tv for image (only with real MBID)
|
// Try Fanart.tv for image (only with real MBID)
|
||||||
if (!heroUrl && !artist.mbid.startsWith("temp-")) {
|
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 {
|
try {
|
||||||
heroUrl = await fanartService.getArtistImage(
|
heroUrl = await fanartService.getArtistImage(
|
||||||
artist.mbid
|
artist.mbid
|
||||||
);
|
);
|
||||||
if (heroUrl) {
|
if (heroUrl) {
|
||||||
imageSource = "fanart.tv";
|
imageSource = "fanart.tv";
|
||||||
console.log(`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(0, 60)}...`);
|
console.log(
|
||||||
|
`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(
|
||||||
|
0,
|
||||||
|
60
|
||||||
|
)}...`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${logPrefix} Fanart.tv: No image found`);
|
console.log(
|
||||||
|
`${logPrefix} Fanart.tv: No image found`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`${logPrefix} Fanart.tv: FAILED - ${error?.message || error}`);
|
console.log(
|
||||||
|
`${logPrefix} Fanart.tv: FAILED - ${
|
||||||
|
error?.message || error
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Deezer
|
// Fallback to Deezer
|
||||||
if (!heroUrl) {
|
if (!heroUrl) {
|
||||||
console.log(`${logPrefix} Deezer: Fetching for "${artist.name}"...`);
|
console.log(
|
||||||
|
`${logPrefix} Deezer: Fetching for "${artist.name}"...`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
heroUrl = await deezerService.getArtistImage(
|
heroUrl = await deezerService.getArtistImage(
|
||||||
artist.name
|
artist.name
|
||||||
);
|
);
|
||||||
if (heroUrl) {
|
if (heroUrl) {
|
||||||
imageSource = "deezer";
|
imageSource = "deezer";
|
||||||
console.log(`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(0, 60)}...`);
|
console.log(
|
||||||
|
`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(
|
||||||
|
0,
|
||||||
|
60
|
||||||
|
)}...`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${logPrefix} Deezer: No image found`);
|
console.log(
|
||||||
|
`${logPrefix} Deezer: No image found`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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;
|
heroUrl = bestImage;
|
||||||
imageSource = "lastfm";
|
imageSource = "lastfm";
|
||||||
console.log(`${logPrefix} Last.fm image: SUCCESS`);
|
console.log(
|
||||||
|
`${logPrefix} Last.fm image: SUCCESS`
|
||||||
|
);
|
||||||
} else {
|
} 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`);
|
console.log(`${logPrefix} Last.fm: No data returned`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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,
|
validMbid,
|
||||||
artist.name
|
artist.name
|
||||||
);
|
);
|
||||||
console.log(`${logPrefix} Similar artists: Found ${similarArtists.length}`);
|
console.log(
|
||||||
|
`${logPrefix} Similar artists: Found ${similarArtists.length}`
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`${logPrefix} Similar artists: FAILED - ${error?.message || error}`);
|
console.log(
|
||||||
|
`${logPrefix} Similar artists: FAILED - ${
|
||||||
|
error?.message || error
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log enrichment summary
|
// 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
|
// Update artist with enriched data
|
||||||
await prisma.artist.update({
|
await prisma.artist.update({
|
||||||
@@ -208,6 +280,7 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
data: {
|
data: {
|
||||||
summary,
|
summary,
|
||||||
heroUrl,
|
heroUrl,
|
||||||
|
similarArtistsJson,
|
||||||
lastEnriched: new Date(),
|
lastEnriched: new Date(),
|
||||||
enrichmentStatus: "completed",
|
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 ==========
|
// ========== ALBUM COVER ENRICHMENT ==========
|
||||||
@@ -274,13 +349,20 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
// Cache artist image in Redis for faster access
|
// Cache artist image in Redis for faster access
|
||||||
if (heroUrl) {
|
if (heroUrl) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// Redis errors are non-critical
|
// Redis errors are non-critical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`${logPrefix} ENRICHMENT FAILED:`, error?.message || error);
|
console.error(
|
||||||
|
`${logPrefix} ENRICHMENT FAILED:`,
|
||||||
|
error?.message || error
|
||||||
|
);
|
||||||
|
|
||||||
// Mark as failed
|
// Mark as failed
|
||||||
await prisma.artist.update({
|
await prisma.artist.update({
|
||||||
@@ -296,16 +378,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
|
|||||||
* Enrich album covers for an artist
|
* Enrich album covers for an artist
|
||||||
* Fetches covers from Cover Art Archive for albums without covers
|
* 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 {
|
try {
|
||||||
// Find albums for this artist that don't have cover art
|
// Find albums for this artist that don't have cover art
|
||||||
const albumsWithoutCovers = await prisma.album.findMany({
|
const albumsWithoutCovers = await prisma.album.findMany({
|
||||||
where: {
|
where: {
|
||||||
artistId,
|
artistId,
|
||||||
OR: [
|
OR: [{ coverUrl: null }, { coverUrl: "" }],
|
||||||
{ coverUrl: null },
|
|
||||||
{ coverUrl: "" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -319,7 +401,9 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` Fetching covers for ${albumsWithoutCovers.length} albums...`);
|
console.log(
|
||||||
|
` Fetching covers for ${albumsWithoutCovers.length} albums...`
|
||||||
|
);
|
||||||
|
|
||||||
let fetchedCount = 0;
|
let fetchedCount = 0;
|
||||||
const BATCH_SIZE = 3; // Limit concurrent requests
|
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
|
// Process in batches to avoid overwhelming Cover Art Archive
|
||||||
for (let i = 0; i < albumsWithoutCovers.length; i += BATCH_SIZE) {
|
for (let i = 0; i < albumsWithoutCovers.length; i += BATCH_SIZE) {
|
||||||
const batch = albumsWithoutCovers.slice(i, i + BATCH_SIZE);
|
const batch = albumsWithoutCovers.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async (album) => {
|
batch.map(async (album) => {
|
||||||
if (!album.rgMbid) return;
|
if (!album.rgMbid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const coverUrl = await coverArtService.getCoverArt(album.rgMbid);
|
const coverUrl = await coverArtService.getCoverArt(
|
||||||
|
album.rgMbid
|
||||||
|
);
|
||||||
|
|
||||||
if (coverUrl) {
|
if (coverUrl) {
|
||||||
// Save to database
|
// Save to database
|
||||||
await prisma.album.update({
|
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) {
|
} catch (error) {
|
||||||
console.error(` Failed to enrich album covers:`, error);
|
console.error(` Failed to enrich album covers:`, error);
|
||||||
// Don't throw - album cover failures shouldn't fail the entire enrichment
|
// 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 { processImageOptimization } from "./processors/imageProcessor";
|
||||||
import { processValidation } from "./processors/validationProcessor";
|
import { processValidation } from "./processors/validationProcessor";
|
||||||
import { startUnifiedEnrichmentWorker, stopUnifiedEnrichmentWorker } from "./unifiedEnrichment";
|
import { startUnifiedEnrichmentWorker, stopUnifiedEnrichmentWorker } from "./unifiedEnrichment";
|
||||||
|
import { startMoodBucketWorker, stopMoodBucketWorker } from "./moodBucketWorker";
|
||||||
import { downloadQueueManager } from "../services/downloadQueue";
|
import { downloadQueueManager } from "../services/downloadQueue";
|
||||||
import { prisma } from "../utils/db";
|
import { prisma } from "../utils/db";
|
||||||
import { startDiscoverWeeklyCron, stopDiscoverWeeklyCron } from "./discoverCron";
|
import { startDiscoverWeeklyCron, stopDiscoverWeeklyCron } from "./discoverCron";
|
||||||
@@ -77,6 +78,12 @@ startUnifiedEnrichmentWorker().catch((err) => {
|
|||||||
console.error("Failed to start unified enrichment worker:", 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
|
// Event handlers for scan queue
|
||||||
scanQueue.on("completed", (job, result) => {
|
scanQueue.on("completed", (job, result) => {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -229,6 +236,9 @@ export async function shutdownWorkers(): Promise<void> {
|
|||||||
// Stop unified enrichment worker
|
// Stop unified enrichment worker
|
||||||
stopUnifiedEnrichmentWorker();
|
stopUnifiedEnrichmentWorker();
|
||||||
|
|
||||||
|
// Stop mood bucket worker
|
||||||
|
stopMoodBucketWorker();
|
||||||
|
|
||||||
// Stop discover weekly cron
|
// Stop discover weekly cron
|
||||||
stopDiscoverWeeklyCron();
|
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
|
* Unified Enrichment Worker
|
||||||
*
|
*
|
||||||
* Handles ALL enrichment in one place:
|
* Handles ALL enrichment in one place:
|
||||||
* - Artist metadata (Last.fm, MusicBrainz)
|
* - Artist metadata (Last.fm, MusicBrainz)
|
||||||
* - Track mood tags (Last.fm)
|
* - Track mood tags (Last.fm)
|
||||||
* - Audio analysis (triggers Essentia via Redis queue)
|
* - Audio analysis (triggers Essentia via Redis queue)
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Two modes:
|
||||||
* 1. FULL: Re-enriches everything regardless of status (Settings > Enrich)
|
* 1. FULL: Re-enriches everything regardless of status (Settings > Enrich)
|
||||||
* 2. INCREMENTAL: Only new material and incomplete items (Sync)
|
* 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
|
// Mood tags to extract from Last.fm
|
||||||
const MOOD_TAGS = new Set([
|
const MOOD_TAGS = new Set([
|
||||||
// Energy/Activity
|
// Energy/Activity
|
||||||
"chill", "relax", "relaxing", "calm", "peaceful", "ambient",
|
"chill",
|
||||||
"energetic", "upbeat", "hype", "party", "dance",
|
"relax",
|
||||||
"workout", "gym", "running", "exercise", "motivation",
|
"relaxing",
|
||||||
|
"calm",
|
||||||
|
"peaceful",
|
||||||
|
"ambient",
|
||||||
|
"energetic",
|
||||||
|
"upbeat",
|
||||||
|
"hype",
|
||||||
|
"party",
|
||||||
|
"dance",
|
||||||
|
"workout",
|
||||||
|
"gym",
|
||||||
|
"running",
|
||||||
|
"exercise",
|
||||||
|
"motivation",
|
||||||
// Emotions
|
// Emotions
|
||||||
"sad", "melancholy", "melancholic", "depressing", "heartbreak",
|
"sad",
|
||||||
"happy", "feel good", "feel-good", "joyful", "uplifting",
|
"melancholy",
|
||||||
"angry", "aggressive", "intense",
|
"melancholic",
|
||||||
"romantic", "love", "sensual",
|
"depressing",
|
||||||
|
"heartbreak",
|
||||||
|
"happy",
|
||||||
|
"feel good",
|
||||||
|
"feel-good",
|
||||||
|
"joyful",
|
||||||
|
"uplifting",
|
||||||
|
"angry",
|
||||||
|
"aggressive",
|
||||||
|
"intense",
|
||||||
|
"romantic",
|
||||||
|
"love",
|
||||||
|
"sensual",
|
||||||
// Time/Setting
|
// Time/Setting
|
||||||
"night", "late night", "evening", "morning",
|
"night",
|
||||||
"summer", "winter", "rainy", "sunny",
|
"late night",
|
||||||
"driving", "road trip", "travel",
|
"evening",
|
||||||
|
"morning",
|
||||||
|
"summer",
|
||||||
|
"winter",
|
||||||
|
"rainy",
|
||||||
|
"sunny",
|
||||||
|
"driving",
|
||||||
|
"road trip",
|
||||||
|
"travel",
|
||||||
// Activity
|
// Activity
|
||||||
"study", "focus", "concentration", "work",
|
"study",
|
||||||
"sleep", "sleeping", "bedtime",
|
"focus",
|
||||||
|
"concentration",
|
||||||
|
"work",
|
||||||
|
"sleep",
|
||||||
|
"sleeping",
|
||||||
|
"bedtime",
|
||||||
// Vibe
|
// Vibe
|
||||||
"dreamy", "atmospheric", "ethereal", "spacey",
|
"dreamy",
|
||||||
"groovy", "funky", "smooth",
|
"atmospheric",
|
||||||
"dark", "moody", "brooding",
|
"ethereal",
|
||||||
"epic", "cinematic", "dramatic",
|
"spacey",
|
||||||
"nostalgic", "throwback",
|
"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[] {
|
function filterMoodTags(tags: string[]): string[] {
|
||||||
return tags
|
return tags
|
||||||
.map(t => t.toLowerCase().trim())
|
.map((t) => t.toLowerCase().trim())
|
||||||
.filter(t => {
|
.filter((t) => {
|
||||||
if (MOOD_TAGS.has(t)) return true;
|
if (MOOD_TAGS.has(t)) return true;
|
||||||
for (const mood of MOOD_TAGS) {
|
for (const mood of MOOD_TAGS) {
|
||||||
if (t.includes(mood) || mood.includes(t)) return true;
|
if (t.includes(mood) || mood.includes(t)) return true;
|
||||||
@@ -122,36 +170,36 @@ export async function runFullEnrichment(): Promise<{
|
|||||||
audioQueued: number;
|
audioQueued: number;
|
||||||
}> {
|
}> {
|
||||||
console.log("\n=== FULL ENRICHMENT: Re-enriching everything ===\n");
|
console.log("\n=== FULL ENRICHMENT: Re-enriching everything ===\n");
|
||||||
|
|
||||||
// Reset all statuses to pending
|
// Reset all statuses to pending
|
||||||
await prisma.artist.updateMany({
|
await prisma.artist.updateMany({
|
||||||
data: { enrichmentStatus: "pending" }
|
data: { enrichmentStatus: "pending" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.track.updateMany({
|
await prisma.track.updateMany({
|
||||||
data: {
|
data: {
|
||||||
lastfmTags: [],
|
lastfmTags: [],
|
||||||
analysisStatus: "pending"
|
analysisStatus: "pending",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now run the enrichment cycle
|
// Now run the enrichment cycle
|
||||||
const result = await runEnrichmentCycle(true);
|
const result = await runEnrichmentCycle(true);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main enrichment cycle
|
* Main enrichment cycle
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Artist metadata (Last.fm/MusicBrainz) - blocking, required for track enrichment
|
* 1. Artist metadata (Last.fm/MusicBrainz) - blocking, required for track enrichment
|
||||||
* 2. Track tags (Last.fm mood tags) - blocking, quick API calls
|
* 2. Track tags (Last.fm mood tags) - blocking, quick API calls
|
||||||
* 3. Audio analysis (Essentia) - NON-BLOCKING, queued to Redis for background processing
|
* 3. Audio analysis (Essentia) - NON-BLOCKING, queued to Redis for background processing
|
||||||
*
|
*
|
||||||
* Steps 1 & 2 must complete before enrichment is "done".
|
* Steps 1 & 2 must complete before enrichment is "done".
|
||||||
* Step 3 runs entirely in background via the audio-analyzer Docker container.
|
* Step 3 runs entirely in background via the audio-analyzer Docker container.
|
||||||
*
|
*
|
||||||
* @param fullMode - If true, processes everything. If false, only pending items.
|
* @param fullMode - If true, processes everything. If false, only pending items.
|
||||||
*/
|
*/
|
||||||
async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
||||||
@@ -171,25 +219,30 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
|||||||
try {
|
try {
|
||||||
// Step 1: Enrich artists (blocking - required for step 2)
|
// Step 1: Enrich artists (blocking - required for step 2)
|
||||||
artistsProcessed = await enrichArtistsBatch();
|
artistsProcessed = await enrichArtistsBatch();
|
||||||
|
|
||||||
// Step 2: Enrich track tags from Last.fm (blocking - quick API calls)
|
// Step 2: Enrich track tags from Last.fm (blocking - quick API calls)
|
||||||
tracksProcessed = await enrichTrackTagsBatch();
|
tracksProcessed = await enrichTrackTagsBatch();
|
||||||
|
|
||||||
// Step 3: Queue audio analysis (NON-BLOCKING)
|
// Step 3: Queue audio analysis (NON-BLOCKING)
|
||||||
// Just adds to Redis queue - actual processing happens in audio-analyzer container
|
// 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
|
// This is intentionally fire-and-forget so it doesn't slow down enrichment
|
||||||
audioQueued = await queueAudioAnalysis();
|
audioQueued = await queueAudioAnalysis();
|
||||||
|
|
||||||
// Log progress (only if work was done)
|
// Log progress (only if work was done)
|
||||||
if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0) {
|
if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0) {
|
||||||
const progress = await getEnrichmentProgress();
|
const progress = await getEnrichmentProgress();
|
||||||
console.log(`\n[Enrichment Progress]`);
|
console.log(`\n[Enrichment Progress]`);
|
||||||
console.log(` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)`);
|
console.log(
|
||||||
console.log(` Track Tags: ${progress.trackTags.enriched}/${progress.trackTags.total} (${progress.trackTags.progress}%)`);
|
` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)`
|
||||||
console.log(` Audio Analysis: ${progress.audioAnalysis.completed}/${progress.audioAnalysis.total} (${progress.audioAnalysis.progress}%) [background]`);
|
);
|
||||||
|
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("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Enrichment] Cycle error:", error);
|
console.error("[Enrichment] Cycle error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -240,9 +293,13 @@ async function enrichArtistsBatch(): Promise<number> {
|
|||||||
async function enrichTrackTagsBatch(): Promise<number> {
|
async function enrichTrackTagsBatch(): Promise<number> {
|
||||||
// Note: Nested orderBy on relations doesn't work with isEmpty filtering in Prisma
|
// 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
|
// 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({
|
const tracks = await prisma.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
lastfmTags: { equals: [] },
|
OR: [
|
||||||
|
{ lastfmTags: { equals: [] } },
|
||||||
|
{ lastfmTags: { isEmpty: true } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
album: {
|
album: {
|
||||||
@@ -262,21 +319,29 @@ async function enrichTrackTagsBatch(): Promise<number> {
|
|||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
try {
|
try {
|
||||||
const artistName = track.album.artist.name;
|
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) {
|
if (trackInfo?.toptags?.tag) {
|
||||||
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
||||||
const moodTags = filterMoodTags(allTags);
|
const moodTags = filterMoodTags(allTags);
|
||||||
|
|
||||||
await prisma.track.update({
|
await prisma.track.update({
|
||||||
where: { id: track.id },
|
where: { id: track.id },
|
||||||
data: {
|
data: {
|
||||||
lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"]
|
lastfmTags:
|
||||||
|
moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (moodTags.length > 0) {
|
if (moodTags.length > 0) {
|
||||||
console.log(` ✓ ${track.title}: [${moodTags.slice(0, 3).join(", ")}...]`);
|
console.log(
|
||||||
|
` ✓ ${track.title}: [${moodTags
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(", ")}...]`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await prisma.track.update({
|
await prisma.track.update({
|
||||||
@@ -284,9 +349,9 @@ async function enrichTrackTagsBatch(): Promise<number> {
|
|||||||
data: { lastfmTags: ["_not_found"] },
|
data: { lastfmTags: ["_not_found"] },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit
|
// Rate limit
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(` ✗ ${track.title}: ${error?.message || error}`);
|
console.error(` ✗ ${track.title}: ${error?.message || error}`);
|
||||||
}
|
}
|
||||||
@@ -316,7 +381,9 @@ async function queueAudioAnalysis(): Promise<number> {
|
|||||||
|
|
||||||
if (tracks.length === 0) return 0;
|
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();
|
const redis = getRedis();
|
||||||
let queued = 0;
|
let queued = 0;
|
||||||
@@ -331,13 +398,13 @@ async function queueAudioAnalysis(): Promise<number> {
|
|||||||
filePath: track.filePath,
|
filePath: track.filePath,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as queued (processing)
|
// Mark as queued (processing)
|
||||||
await prisma.track.update({
|
await prisma.track.update({
|
||||||
where: { id: track.id },
|
where: { id: track.id },
|
||||||
data: { analysisStatus: "processing" },
|
data: { analysisStatus: "processing" },
|
||||||
});
|
});
|
||||||
|
|
||||||
queued++;
|
queued++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(` Failed to queue ${track.title}:`, error);
|
console.error(` Failed to queue ${track.title}:`, error);
|
||||||
@@ -353,7 +420,7 @@ async function queueAudioAnalysis(): Promise<number> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get comprehensive enrichment progress
|
* Get comprehensive enrichment progress
|
||||||
*
|
*
|
||||||
* Returns separate progress for:
|
* Returns separate progress for:
|
||||||
* - Artists & Track Tags: "Core" enrichment (must complete before app is fully usable)
|
* - Artists & Track Tags: "Core" enrichment (must complete before app is fully usable)
|
||||||
* - Audio Analysis: "Background" enrichment (runs in separate container, non-blocking)
|
* - Audio Analysis: "Background" enrichment (runs in separate container, non-blocking)
|
||||||
@@ -364,17 +431,20 @@ export async function getEnrichmentProgress() {
|
|||||||
by: ["enrichmentStatus"],
|
by: ["enrichmentStatus"],
|
||||||
_count: true,
|
_count: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const artistTotal = artistCounts.reduce((sum, s) => sum + s._count, 0);
|
const artistTotal = artistCounts.reduce((sum, s) => sum + s._count, 0);
|
||||||
const artistCompleted = artistCounts.find(s => s.enrichmentStatus === "completed")?._count || 0;
|
const artistCompleted =
|
||||||
const artistPending = artistCounts.find(s => s.enrichmentStatus === "pending")?._count || 0;
|
artistCounts.find((s) => s.enrichmentStatus === "completed")?._count ||
|
||||||
|
0;
|
||||||
|
const artistPending =
|
||||||
|
artistCounts.find((s) => s.enrichmentStatus === "pending")?._count || 0;
|
||||||
|
|
||||||
// Track tag progress
|
// Track tag progress
|
||||||
const trackTotal = await prisma.track.count();
|
const trackTotal = await prisma.track.count();
|
||||||
const trackTagsEnriched = await prisma.track.count({
|
const trackTagsEnriched = await prisma.track.count({
|
||||||
where: { NOT: { lastfmTags: { equals: [] } } },
|
where: { NOT: { lastfmTags: { equals: [] } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audio analysis progress (background task)
|
// Audio analysis progress (background task)
|
||||||
const audioCompleted = await prisma.track.count({
|
const audioCompleted = await prisma.track.count({
|
||||||
where: { analysisStatus: "completed" },
|
where: { analysisStatus: "completed" },
|
||||||
@@ -391,24 +461,33 @@ export async function getEnrichmentProgress() {
|
|||||||
|
|
||||||
// Core enrichment is complete when artists and track tags are done
|
// Core enrichment is complete when artists and track tags are done
|
||||||
// Audio analysis is separate - it runs in background and doesn't block
|
// 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 {
|
return {
|
||||||
// Core enrichment (blocking)
|
// Core enrichment (blocking)
|
||||||
artists: {
|
artists: {
|
||||||
total: artistTotal,
|
total: artistTotal,
|
||||||
completed: artistCompleted,
|
completed: artistCompleted,
|
||||||
pending: artistPending,
|
pending: artistPending,
|
||||||
failed: artistCounts.find(s => s.enrichmentStatus === "failed")?._count || 0,
|
failed:
|
||||||
progress: artistTotal > 0 ? Math.round((artistCompleted / artistTotal) * 100) : 0,
|
artistCounts.find((s) => s.enrichmentStatus === "failed")
|
||||||
|
?._count || 0,
|
||||||
|
progress:
|
||||||
|
artistTotal > 0
|
||||||
|
? Math.round((artistCompleted / artistTotal) * 100)
|
||||||
|
: 0,
|
||||||
},
|
},
|
||||||
trackTags: {
|
trackTags: {
|
||||||
total: trackTotal,
|
total: trackTotal,
|
||||||
enriched: trackTagsEnriched,
|
enriched: trackTagsEnriched,
|
||||||
pending: trackTotal - 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)
|
// Background enrichment (non-blocking, runs in audio-analyzer container)
|
||||||
audioAnalysis: {
|
audioAnalysis: {
|
||||||
total: trackTotal,
|
total: trackTotal,
|
||||||
@@ -416,13 +495,17 @@ export async function getEnrichmentProgress() {
|
|||||||
pending: audioPending,
|
pending: audioPending,
|
||||||
processing: audioProcessing,
|
processing: audioProcessing,
|
||||||
failed: audioFailed,
|
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
|
isBackground: true, // Flag to indicate this runs separately
|
||||||
},
|
},
|
||||||
|
|
||||||
// Overall status
|
// Overall status
|
||||||
coreComplete, // True when artists + track tags are done
|
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({
|
const artist = await prisma.artist.findUnique({
|
||||||
where: { id: artistId },
|
where: { id: artistId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!artist) return;
|
if (!artist) return;
|
||||||
|
|
||||||
console.log(`[Enrichment] Enriching artist: ${artist.name}`);
|
console.log(`[Enrichment] Enriching artist: ${artist.name}`);
|
||||||
await enrichSimilarArtist(artist);
|
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) {
|
for (const track of tracks) {
|
||||||
try {
|
try {
|
||||||
const trackInfo = await lastFmService.getTrackInfo(
|
const trackInfo = await lastFmService.getTrackInfo(
|
||||||
track.album.artist.name,
|
track.album.artist.name,
|
||||||
track.title
|
track.title
|
||||||
);
|
);
|
||||||
|
|
||||||
if (trackInfo?.toptags?.tag) {
|
if (trackInfo?.toptags?.tag) {
|
||||||
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
const allTags = trackInfo.toptags.tag.map((t: any) => t.name);
|
||||||
const moodTags = filterMoodTags(allTags);
|
const moodTags = filterMoodTags(allTags);
|
||||||
|
|
||||||
await prisma.track.update({
|
await prisma.track.update({
|
||||||
where: { id: track.id },
|
where: { id: track.id },
|
||||||
data: {
|
data: {
|
||||||
lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
lastfmTags:
|
||||||
|
moodTags.length > 0 ? moodTags : ["_no_mood_tags"],
|
||||||
analysisStatus: "pending", // Queue for audio analysis
|
analysisStatus: "pending", // Queue for audio analysis
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to enrich track ${track.title}:`, 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 ? (
|
{mix.coverUrls.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 gap-0 w-full h-full">
|
<div className="grid grid-cols-2 gap-0 w-full h-full">
|
||||||
{mix.coverUrls.slice(0, 4).map((url, idx) => {
|
{mix.coverUrls.slice(0, 4).map((url, idx) => {
|
||||||
const proxiedUrl = api.getCoverArtUrl(url, 300);
|
const proxiedUrl = api.getCoverArtUrl(
|
||||||
|
url,
|
||||||
|
300
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -51,7 +54,10 @@ const MixCard = memo(
|
|||||||
})}
|
})}
|
||||||
{/* Fill remaining cells if less than 4 covers */}
|
{/* Fill remaining cells if less than 4 covers */}
|
||||||
{Array.from({
|
{Array.from({
|
||||||
length: Math.max(0, 4 - mix.coverUrls.length),
|
length: Math.max(
|
||||||
|
0,
|
||||||
|
4 - mix.coverUrls.length
|
||||||
|
),
|
||||||
}).map((_, idx) => (
|
}).map((_, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`empty-${idx}`}
|
key={`empty-${idx}`}
|
||||||
@@ -79,7 +85,18 @@ const MixCard = memo(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) => {
|
(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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 { useAudioControls } from "@/lib/audio-controls-context";
|
||||||
import { Track } from "@/lib/audio-state-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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface MoodMixerProps {
|
interface MoodMixerProps {
|
||||||
@@ -12,46 +27,92 @@ interface MoodMixerProps {
|
|||||||
onClose: () => void;
|
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) {
|
export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
||||||
const { playTracks } = useAudioControls();
|
const { playTracks } = useAudioControls();
|
||||||
const [presets, setPresets] = useState<MoodPreset[]>([]);
|
const queryClient = useQueryClient();
|
||||||
|
const [presets, setPresets] = useState<MoodBucketPreset[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [generating, setGenerating] = useState<string | null>(null);
|
const [generating, setGenerating] = useState<MoodType | null>(null);
|
||||||
const [showCustom, setShowCustom] = useState(false);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
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
|
// Handle visibility animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +128,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
|||||||
|
|
||||||
const loadPresets = async () => {
|
const loadPresets = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getMoodPresets();
|
const data = await api.getMoodBucketPresets();
|
||||||
setPresets(data);
|
setPresets(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load mood presets:", error);
|
console.error("Failed to load mood presets:", error);
|
||||||
@@ -77,16 +138,16 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateMix = async (preset: MoodPreset) => {
|
const generateMix = async (mood: MoodType) => {
|
||||||
setGenerating(preset.id);
|
const config = MOOD_CONFIG[mood];
|
||||||
|
setGenerating(mood);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mix = await api.generateMoodMix({
|
// Get the mix from pre-computed bucket (instant!)
|
||||||
...preset.params,
|
const mix = await api.getMoodBucketMix(mood);
|
||||||
limit: 15,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mix.tracks && mix.tracks.length > 0) {
|
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,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: {
|
artist: {
|
||||||
@@ -101,153 +162,61 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
|||||||
duration: t.duration,
|
duration: t.duration,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Start playback
|
||||||
playTracks(tracks, 0);
|
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`,
|
description: `Playing ${tracks.length} tracks`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save these params as user's mood mix preferences (include preset name for mix title)
|
// Force immediate refetch of mixes on home page
|
||||||
try {
|
// Using refetchQueries instead of invalidateQueries for immediate update
|
||||||
await api.post('/mixes/mood/save-preferences', {
|
await queryClient.refetchQueries({ queryKey: ["mixes"] });
|
||||||
...preset.params,
|
|
||||||
limit: 15,
|
|
||||||
presetName: preset.name
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to save mood preferences:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify other components to refresh mixes
|
// Also dispatch events for any other listeners
|
||||||
window.dispatchEvent(new CustomEvent("mix-generated"));
|
window.dispatchEvent(new CustomEvent("mix-generated"));
|
||||||
window.dispatchEvent(new CustomEvent("mixes-updated"));
|
window.dispatchEvent(new CustomEvent("mixes-updated"));
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Not enough matching tracks", {
|
toast.error("Not enough tracks for this mood", {
|
||||||
description:
|
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);
|
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 {
|
} finally {
|
||||||
setGenerating(null);
|
setGenerating(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCustomMix = async () => {
|
// Get track count for a mood
|
||||||
setGenerating("custom");
|
const getTrackCount = (mood: MoodType): number => {
|
||||||
try {
|
// MoodBucketPreset uses 'id' as the mood identifier
|
||||||
const params: MoodMixParams = {
|
const preset = presets.find((p) => p.id === mood);
|
||||||
valence: {
|
return preset?.trackCount || 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible && !isOpen) return null;
|
if (!isVisible && !isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
isOpen ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
isOpen ? "scale-100 opacity-100" : "scale-95 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -263,7 +232,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
|||||||
Mood Mixer
|
Mood Mixer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Generate a mix based on your vibe
|
Pick your vibe
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,354 +251,78 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
|||||||
<Loader2 className="w-8 h-8 animate-spin text-[#ecb200]" />
|
<Loader2 className="w-8 h-8 animate-spin text-[#ecb200]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
/* 3x3 Mood Grid */
|
||||||
{/* Toggle between presets and custom */}
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="flex gap-2 mb-6">
|
{MOOD_ORDER.map((mood) => {
|
||||||
<button
|
const config = MOOD_CONFIG[mood];
|
||||||
onClick={() => setShowCustom(false)}
|
const Icon = config.icon;
|
||||||
className={`flex-1 py-2.5 px-4 rounded-lg font-medium text-sm transition-all ${
|
const trackCount = getTrackCount(mood);
|
||||||
!showCustom
|
const isDisabled = trackCount < 5;
|
||||||
? "bg-[#ecb200] text-black"
|
const isGenerating = generating === mood;
|
||||||
: "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>
|
|
||||||
|
|
||||||
{!showCustom ? (
|
return (
|
||||||
/* 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 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
key={mood}
|
||||||
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"
|
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">
|
{/* Icon */}
|
||||||
<Sliders className="w-4 h-4" />
|
<div className="relative z-10">
|
||||||
ML Mood Controls
|
{isGenerating ? (
|
||||||
</span>
|
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||||
{showAdvanced ? (
|
) : (
|
||||||
<ChevronUp className="w-4 h-4" />
|
<Icon className="w-8 h-8 text-white drop-shadow-lg" />
|
||||||
) : (
|
)}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
{/* Label */}
|
||||||
onClick={generateCustomMix}
|
<span className="relative z-10 text-sm font-semibold text-white drop-shadow-lg">
|
||||||
disabled={generating !== null}
|
{config.label}
|
||||||
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"
|
</span>
|
||||||
>
|
|
||||||
{generating === "custom" ? (
|
{/* Track count badge */}
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<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>
|
||||||
<Play
|
|
||||||
className="w-5 h-5"
|
{/* Hover overlay with play icon */}
|
||||||
fill="currentColor"
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
/>
|
{!isGenerating && !isDisabled && (
|
||||||
Generate Mix
|
<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>
|
</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>
|
</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
|
<main
|
||||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
|
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
|
||||||
style={{
|
style={{
|
||||||
marginTop: "52px",
|
marginTop: "58px",
|
||||||
marginBottom:
|
marginBottom:
|
||||||
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function TopBar() {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50"
|
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 */}
|
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */}
|
||||||
{isMobileOrTablet ? (
|
{isMobileOrTablet ? (
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { useAudioControls } from "@/lib/audio-controls-context";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { howlerEngine } from "@/lib/howler-engine";
|
import { howlerEngine } from "@/lib/howler-engine";
|
||||||
import { audioSeekEmitter } from "@/lib/audio-seek-emitter";
|
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 {
|
function podcastDebugEnabled(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +58,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
const {
|
const {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
|
setCurrentTimeFromEngine,
|
||||||
setDuration,
|
setDuration,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
isBuffering,
|
isBuffering,
|
||||||
@@ -59,6 +67,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
canSeek,
|
canSeek,
|
||||||
setCanSeek,
|
setCanSeek,
|
||||||
setDownloadProgress,
|
setDownloadProgress,
|
||||||
|
lockSeek,
|
||||||
} = useAudioPlayback();
|
} = useAudioPlayback();
|
||||||
|
|
||||||
// Controls context
|
// Controls context
|
||||||
@@ -83,6 +92,11 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
const loadListenerRef = useRef<(() => void) | null>(null);
|
const loadListenerRef = useRef<(() => void) | null>(null);
|
||||||
const loadErrorListenerRef = useRef<(() => void) | null>(null);
|
const loadErrorListenerRef = useRef<(() => void) | null>(null);
|
||||||
const cachePollingLoadListenerRef = 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
|
// Reset duration when nothing is playing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,7 +108,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
// Subscribe to Howler events
|
// Subscribe to Howler events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTimeUpdate = (data: { time: number }) => {
|
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 }) => {
|
const handleLoad = (data: { duration: number }) => {
|
||||||
@@ -131,15 +147,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
console.error("[HowlerAudioElement] Playback error:", data.error);
|
console.error("[HowlerAudioElement] Playback error:", data.error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
isUserInitiatedRef.current = false;
|
isUserInitiatedRef.current = false;
|
||||||
|
|
||||||
if (playbackType === "track") {
|
if (playbackType === "track") {
|
||||||
if (queue.length > 1) {
|
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;
|
lastTrackIdRef.current = null;
|
||||||
isLoadingRef.current = false;
|
isLoadingRef.current = false;
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
console.log("[HowlerAudioElement] Track failed, no more in queue - clearing");
|
console.log(
|
||||||
|
"[HowlerAudioElement] Track failed, no more in queue - clearing"
|
||||||
|
);
|
||||||
lastTrackIdRef.current = null;
|
lastTrackIdRef.current = null;
|
||||||
isLoadingRef.current = false;
|
isLoadingRef.current = false;
|
||||||
setCurrentTrack(null);
|
setCurrentTrack(null);
|
||||||
@@ -164,7 +184,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
const handlePause = () => {
|
const handlePause = () => {
|
||||||
if (isLoadingRef.current) return;
|
if (isLoadingRef.current) return;
|
||||||
if (seekReloadInProgressRef.current) return;
|
if (seekReloadInProgressRef.current) return;
|
||||||
|
|
||||||
if (!isUserInitiatedRef.current) {
|
if (!isUserInitiatedRef.current) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
@@ -188,7 +208,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
howlerEngine.off("play", handlePlay);
|
howlerEngine.off("play", handlePlay);
|
||||||
howlerEngine.off("pause", handlePause);
|
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
|
// Save audiobook progress
|
||||||
const saveAudiobookProgress = useCallback(
|
const saveAudiobookProgress = useCallback(
|
||||||
@@ -287,10 +307,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
if (isSeekingRef.current) {
|
if (isSeekingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPlay = lastPlayingStateRef.current || isPlaying;
|
const shouldPlay = lastPlayingStateRef.current || isPlaying;
|
||||||
const isCurrentlyPlaying = howlerEngine.isPlaying();
|
const isCurrentlyPlaying = howlerEngine.isPlaying();
|
||||||
|
|
||||||
if (shouldPlay && !isCurrentlyPlaying) {
|
if (shouldPlay && !isCurrentlyPlaying) {
|
||||||
howlerEngine.seek(0);
|
howlerEngine.seek(0);
|
||||||
howlerEngine.play();
|
howlerEngine.play();
|
||||||
@@ -330,7 +350,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
|
|
||||||
if (streamUrl) {
|
if (streamUrl) {
|
||||||
const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying();
|
const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying();
|
||||||
|
|
||||||
const fallbackDuration =
|
const fallbackDuration =
|
||||||
currentTrack?.duration ||
|
currentTrack?.duration ||
|
||||||
currentAudiobook?.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) {
|
if (shouldAutoPlay) {
|
||||||
howlerEngine.play();
|
howlerEngine.play();
|
||||||
@@ -522,6 +543,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
// Poll for podcast cache and reload when ready
|
// Poll for podcast cache and reload when ready
|
||||||
const startCachePolling = useCallback(
|
const startCachePolling = useCallback(
|
||||||
(podcastId: string, episodeId: string, targetTime: number) => {
|
(podcastId: string, episodeId: string, targetTime: number) => {
|
||||||
|
// Capture the current seek operation ID
|
||||||
|
const pollingSeekId = seekOperationIdRef.current;
|
||||||
|
|
||||||
if (cachePollingRef.current) {
|
if (cachePollingRef.current) {
|
||||||
clearInterval(cachePollingRef.current);
|
clearInterval(cachePollingRef.current);
|
||||||
}
|
}
|
||||||
@@ -530,6 +554,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
const maxPolls = 60;
|
const maxPolls = 60;
|
||||||
|
|
||||||
cachePollingRef.current = setInterval(async () => {
|
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++;
|
pollCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -537,6 +574,16 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
podcastId,
|
podcastId,
|
||||||
episodeId
|
episodeId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-check after async operation
|
||||||
|
if (seekOperationIdRef.current !== pollingSeekId) {
|
||||||
|
if (cachePollingRef.current) {
|
||||||
|
clearInterval(cachePollingRef.current);
|
||||||
|
cachePollingRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
podcastDebugLog("cache poll", {
|
podcastDebugLog("cache poll", {
|
||||||
podcastId,
|
podcastId,
|
||||||
episodeId,
|
episodeId,
|
||||||
@@ -553,14 +600,20 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
cachePollingRef.current = null;
|
cachePollingRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
podcastDebugLog("cache ready -> howlerEngine.reload()", {
|
podcastDebugLog(
|
||||||
podcastId,
|
"cache ready -> howlerEngine.reload()",
|
||||||
episodeId,
|
{
|
||||||
targetTime,
|
podcastId,
|
||||||
});
|
episodeId,
|
||||||
|
targetTime,
|
||||||
|
}
|
||||||
|
);
|
||||||
// Clean up any previous cache polling load listener
|
// Clean up any previous cache polling load listener
|
||||||
if (cachePollingLoadListenerRef.current) {
|
if (cachePollingLoadListenerRef.current) {
|
||||||
howlerEngine.off("load", cachePollingLoadListenerRef.current);
|
howlerEngine.off(
|
||||||
|
"load",
|
||||||
|
cachePollingLoadListenerRef.current
|
||||||
|
);
|
||||||
cachePollingLoadListenerRef.current = null;
|
cachePollingLoadListenerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +623,15 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
howlerEngine.off("load", onLoad);
|
howlerEngine.off("load", onLoad);
|
||||||
cachePollingLoadListenerRef.current = null;
|
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);
|
howlerEngine.seek(targetTime);
|
||||||
setCurrentTime(targetTime);
|
setCurrentTime(targetTime);
|
||||||
howlerEngine.play();
|
howlerEngine.play();
|
||||||
@@ -594,12 +656,17 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
cachePollingRef.current = null;
|
cachePollingRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn("[HowlerAudioElement] Cache polling timeout");
|
console.warn(
|
||||||
|
"[HowlerAudioElement] Cache polling timeout"
|
||||||
|
);
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
setTargetSeekPosition(null);
|
setTargetSeekPosition(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[HowlerAudioElement] Cache polling error:", error);
|
console.error(
|
||||||
|
"[HowlerAudioElement] Cache polling error:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
@@ -608,93 +675,219 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
|
|
||||||
// Handle seeking via event emitter
|
// Handle seeking via event emitter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Store previous time to detect large skips vs fine scrubbing
|
||||||
|
let previousTime = howlerEngine.getCurrentTime();
|
||||||
|
|
||||||
const handleSeek = async (time: number) => {
|
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();
|
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) {
|
if (playbackType === "podcast" && currentPodcast) {
|
||||||
|
// Cancel any previous seek-related operations
|
||||||
if (seekCheckTimeoutRef.current) {
|
if (seekCheckTimeoutRef.current) {
|
||||||
clearTimeout(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(":");
|
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||||
try {
|
|
||||||
const status = await api.getPodcastEpisodeCacheStatus(
|
// Execute the seek logic - immediately for large skips, debounced for fine scrubbing
|
||||||
podcastId,
|
const executeSeek = async () => {
|
||||||
episodeId
|
const seekTime = pendingSeekTimeRef.current ?? time;
|
||||||
);
|
pendingSeekTimeRef.current = null;
|
||||||
|
|
||||||
if (status.cached) {
|
// Check if this seek is still current
|
||||||
podcastDebugLog("seek: cached=true, using reload+seek pattern", {
|
if (seekOperationIdRef.current !== thisSeekId) {
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.warn("[HowlerAudioElement] Could not check cache status:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
howlerEngine.seek(time);
|
|
||||||
|
|
||||||
seekCheckTimeoutRef.current = setTimeout(() => {
|
|
||||||
try {
|
try {
|
||||||
const actualPos = howlerEngine.getActualCurrentTime();
|
const status = await api.getPodcastEpisodeCacheStatus(
|
||||||
const seekFailed = time > 30 && actualPos < 30;
|
|
||||||
podcastDebugLog("seek check", {
|
|
||||||
time,
|
|
||||||
actualPos,
|
|
||||||
seekFailed,
|
|
||||||
podcastId,
|
podcastId,
|
||||||
episodeId,
|
episodeId
|
||||||
});
|
);
|
||||||
|
|
||||||
if (seekFailed) {
|
// Check if this seek operation is still current
|
||||||
howlerEngine.pause();
|
if (seekOperationIdRef.current !== thisSeekId) {
|
||||||
setIsBuffering(true);
|
podcastDebugLog("seek: aborted (stale operation)", {
|
||||||
setTargetSeekPosition(time);
|
thisSeekId,
|
||||||
setIsPlaying(false);
|
currentId: seekOperationIdRef.current,
|
||||||
startCachePolling(podcastId, episodeId, time);
|
});
|
||||||
|
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) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For audiobooks and tracks, set seeking flag to prevent load effect interference
|
// For audiobooks and tracks, set seeking flag to prevent load effect interference
|
||||||
isSeekingRef.current = true;
|
isSeekingRef.current = true;
|
||||||
howlerEngine.seek(time);
|
howlerEngine.seek(time);
|
||||||
|
|
||||||
// Reset seeking flag after a short delay to allow seek to complete
|
// Reset seeking flag after a short delay to allow seek to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isSeekingRef.current = false;
|
isSeekingRef.current = false;
|
||||||
@@ -703,7 +896,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
|
|
||||||
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
|
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
|
||||||
return unsubscribe;
|
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
|
// Cleanup cache polling, seek timeout, and seek-reload listener on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -718,6 +911,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
|||||||
howlerEngine.off("load", seekReloadListenerRef.current);
|
howlerEngine.off("load", seekReloadListenerRef.current);
|
||||||
seekReloadListenerRef.current = null;
|
seekReloadListenerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (seekDebounceRef.current) {
|
||||||
|
clearTimeout(seekDebounceRef.current);
|
||||||
|
seekDebounceRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -314,11 +314,12 @@ export function MiniPlayer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||||
transform: `translateX(${swipeOffset}px)`,
|
transform: `translateX(${swipeOffset}px)`,
|
||||||
opacity: swipeOpacity,
|
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}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@@ -339,42 +340,42 @@ export function MiniPlayer() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Player content */}
|
{/* Player content - more spacious padding */}
|
||||||
<div
|
<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")}
|
onClick={() => setPlayerMode("overlay")}
|
||||||
>
|
>
|
||||||
{/* Album Art */}
|
{/* Album Art - slightly larger */}
|
||||||
<div className="relative w-10 h-10 flex-shrink-0 rounded-md overflow-hidden bg-black/30 shadow-md">
|
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
|
||||||
{coverUrl ? (
|
{coverUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
sizes="40px"
|
sizes="48px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Info */}
|
{/* Track Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{title}
|
||||||
</p>
|
</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}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls - Vibe & Play/Pause */}
|
{/* Controls - Vibe & Play/Pause */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 flex-shrink-0"
|
className="flex items-center gap-1.5 flex-shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Vibe Button */}
|
{/* Vibe Button */}
|
||||||
@@ -382,7 +383,7 @@ export function MiniPlayer() {
|
|||||||
onClick={handleVibeToggle}
|
onClick={handleVibeToggle}
|
||||||
disabled={!canSkip || isVibeLoading}
|
disabled={!canSkip || isVibeLoading}
|
||||||
className={cn(
|
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
|
!canSkip
|
||||||
? "text-gray-600"
|
? "text-gray-600"
|
||||||
: vibeMode
|
: vibeMode
|
||||||
@@ -396,9 +397,9 @@ export function MiniPlayer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isVibeLoading ? (
|
{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>
|
</button>
|
||||||
|
|
||||||
@@ -414,7 +415,7 @@ export function MiniPlayer() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
isBuffering
|
||||||
? "bg-white/80 text-black"
|
? "bg-white/80 text-black"
|
||||||
: "bg-white text-black hover:scale-105"
|
: "bg-white text-black hover:scale-105"
|
||||||
@@ -428,11 +429,11 @@ export function MiniPlayer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isBuffering ? (
|
{isBuffering ? (
|
||||||
<Loader2 className="w-[18px] h-[18px] animate-spin" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
) : isPlaying ? (
|
) : 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { SimilarArtist } from "../types";
|
import { SimilarArtist } from "../types";
|
||||||
import { Music } from "lucide-react";
|
import { Music, Library } from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
interface SimilarArtistsProps {
|
interface SimilarArtistsProps {
|
||||||
@@ -20,10 +20,11 @@ export function SimilarArtists({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-bold mb-4">
|
<h2 className="text-xl font-bold mb-4">Fans Also Like</h2>
|
||||||
Fans Also Like
|
<div
|
||||||
</h2>
|
data-tv-section="similar-artists"
|
||||||
<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">
|
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||||
|
>
|
||||||
{similarArtists.map((artist, index) => {
|
{similarArtists.map((artist, index) => {
|
||||||
const rawImage = artist.coverArt || artist.image;
|
const rawImage = artist.coverArt || artist.image;
|
||||||
const imageUrl = rawImage
|
const imageUrl = rawImage
|
||||||
@@ -33,17 +34,22 @@ export function SimilarArtists({
|
|||||||
? Math.round(artist.weight * 100)
|
? Math.round(artist.weight * 100)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// For library artists, use the library ID; otherwise use mbid or name
|
||||||
|
const navigationId = artist.inLibrary
|
||||||
|
? artist.id
|
||||||
|
: artist.mbid || artist.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={artist.id || artist.name}
|
key={artist.id || artist.name}
|
||||||
data-tv-card
|
data-tv-card
|
||||||
data-tv-card-index={index}
|
data-tv-card-index={index}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onNavigate(artist.mbid || artist.id)}
|
onClick={() => onNavigate(navigationId)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
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"
|
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" />
|
<Music className="w-12 h-12 text-gray-600" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Artist Name */}
|
{/* Artist Name */}
|
||||||
@@ -71,13 +86,13 @@ export function SimilarArtists({
|
|||||||
{artist.name}
|
{artist.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Album Count */}
|
{/* Album Count - show owned count if in library */}
|
||||||
<p className="text-xs text-gray-400 truncate">
|
<p className="text-xs text-gray-400 truncate">
|
||||||
{artist.ownedAlbumCount &&
|
{artist.ownedAlbumCount &&
|
||||||
artist.ownedAlbumCount > 0
|
artist.ownedAlbumCount > 0
|
||||||
? `${artist.ownedAlbumCount}/${artist.albumCount} albums`
|
? `${artist.ownedAlbumCount} album${
|
||||||
: artist.albumCount && artist.albumCount > 0
|
artist.ownedAlbumCount > 1 ? "s" : ""
|
||||||
? `${artist.albumCount} albums`
|
} in library`
|
||||||
: "Artist"}
|
: "Artist"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,5 @@ export interface SimilarArtist {
|
|||||||
albumCount?: number;
|
albumCount?: number;
|
||||||
ownedAlbumCount?: number;
|
ownedAlbumCount?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
inLibrary?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
* and providing refresh functionality for mixes.
|
* and providing refresh functionality for mixes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import type {
|
import type {
|
||||||
Artist,
|
Artist,
|
||||||
ListenedItem,
|
ListenedItem,
|
||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
Audiobook,
|
Audiobook,
|
||||||
Mix,
|
Mix,
|
||||||
PopularArtist,
|
PopularArtist,
|
||||||
} from '../types';
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
useRecentlyListenedQuery,
|
useRecentlyListenedQuery,
|
||||||
useRecentlyAddedQuery,
|
useRecentlyAddedQuery,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
useRefreshMixesMutation,
|
useRefreshMixesMutation,
|
||||||
useBrowseAllQuery,
|
useBrowseAllQuery,
|
||||||
queryKeys,
|
queryKeys,
|
||||||
} from '@/hooks/useQueries';
|
} from "@/hooks/useQueries";
|
||||||
|
|
||||||
interface PlaylistPreview {
|
interface PlaylistPreview {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,27 +81,38 @@ export function useHomeData(): UseHomeDataReturn {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Listen for mixes-updated event (fired when user saves mood preferences)
|
// Listen for mixes-updated event (fired when user saves mood preferences)
|
||||||
|
// Use refetchQueries instead of invalidateQueries to force immediate UI update
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMixesUpdated = () => {
|
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);
|
window.addEventListener("mixes-updated", handleMixesUpdated);
|
||||||
return () => window.removeEventListener('mixes-updated', handleMixesUpdated);
|
return () =>
|
||||||
|
window.removeEventListener("mixes-updated", handleMixesUpdated);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
// React Query hooks - these automatically handle caching, refetching, and loading states
|
// React Query hooks - these automatically handle caching, refetching, and loading states
|
||||||
const { data: recentlyListenedData, isLoading: isLoadingListened } = useRecentlyListenedQuery(10);
|
const { data: recentlyListenedData, isLoading: isLoadingListened } =
|
||||||
const { data: recentlyAddedData, isLoading: isLoadingAdded } = useRecentlyAddedQuery(10);
|
useRecentlyListenedQuery(10);
|
||||||
const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendationsQuery(10);
|
const { data: recentlyAddedData, isLoading: isLoadingAdded } =
|
||||||
|
useRecentlyAddedQuery(10);
|
||||||
|
const { data: recommendedData, isLoading: isLoadingRecommended } =
|
||||||
|
useRecommendationsQuery(10);
|
||||||
const { data: mixesData, isLoading: isLoadingMixes } = useMixesQuery();
|
const { data: mixesData, isLoading: isLoadingMixes } = useMixesQuery();
|
||||||
const { data: popularData, isLoading: isLoadingPopular } = usePopularArtistsQuery(20);
|
const { data: popularData, isLoading: isLoadingPopular } =
|
||||||
const { data: podcastsData, isLoading: isLoadingPodcasts } = useTopPodcastsQuery(10);
|
usePopularArtistsQuery(20);
|
||||||
const { data: audiobooksData, isLoading: isLoadingAudiobooks } = useAudiobooksQuery();
|
const { data: podcastsData, isLoading: isLoadingPodcasts } =
|
||||||
const { data: browseData, isLoading: isBrowseLoading } = useBrowseAllQuery();
|
useTopPodcastsQuery(10);
|
||||||
|
const { data: audiobooksData, isLoading: isLoadingAudiobooks } =
|
||||||
|
useAudiobooksQuery();
|
||||||
|
const { data: browseData, isLoading: isBrowseLoading } =
|
||||||
|
useBrowseAllQuery();
|
||||||
|
|
||||||
// Mutation for refreshing mixes
|
// Mutation for refreshing mixes
|
||||||
const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } = useRefreshMixesMutation();
|
const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } =
|
||||||
|
useRefreshMixesMutation();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh mixes and update cache
|
* Refresh mixes and update cache
|
||||||
@@ -109,10 +120,10 @@ export function useHomeData(): UseHomeDataReturn {
|
|||||||
const handleRefreshMixes = async () => {
|
const handleRefreshMixes = async () => {
|
||||||
try {
|
try {
|
||||||
await refreshMixes();
|
await refreshMixes();
|
||||||
toast.success('Mixes refreshed! Check out your new daily picks');
|
toast.success("Mixes refreshed! Check out your new daily picks");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh mixes:', error);
|
console.error("Failed to refresh mixes:", error);
|
||||||
toast.error('Failed to refresh mixes');
|
toast.error("Failed to refresh mixes");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,8 +147,12 @@ export function useHomeData(): UseHomeDataReturn {
|
|||||||
recommended: recommendedData?.artists || [],
|
recommended: recommendedData?.artists || [],
|
||||||
mixes: Array.isArray(mixesData) ? mixesData : [],
|
mixes: Array.isArray(mixesData) ? mixesData : [],
|
||||||
popularArtists: popularData?.artists || [],
|
popularArtists: popularData?.artists || [],
|
||||||
recentPodcasts: Array.isArray(podcastsData) ? podcastsData.slice(0, 10) : [],
|
recentPodcasts: Array.isArray(podcastsData)
|
||||||
recentAudiobooks: Array.isArray(audiobooksData) ? audiobooksData.slice(0, 10) : [],
|
? podcastsData.slice(0, 10)
|
||||||
|
: [],
|
||||||
|
recentAudiobooks: Array.isArray(audiobooksData)
|
||||||
|
? audiobooksData.slice(0, 10)
|
||||||
|
: [],
|
||||||
featuredPlaylists: browseData?.playlists || [],
|
featuredPlaylists: browseData?.playlists || [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isRefreshingMixes,
|
isRefreshingMixes,
|
||||||
|
|||||||
+63
-2
@@ -1,6 +1,6 @@
|
|||||||
const AUTH_TOKEN_KEY = "auth_token";
|
const AUTH_TOKEN_KEY = "auth_token";
|
||||||
|
|
||||||
// Mood Mix Types
|
// Mood Mix Types (Legacy - for old presets endpoint)
|
||||||
export interface MoodPreset {
|
export interface MoodPreset {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,6 +29,43 @@ export interface MoodMixParams {
|
|||||||
limit?: number;
|
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
|
// Dynamically determine API URL based on configuration
|
||||||
const getApiBaseUrl = () => {
|
const getApiBaseUrl = () => {
|
||||||
// Server-side rendering
|
// Server-side rendering
|
||||||
@@ -1225,7 +1262,7 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mood on Demand
|
// Mood on Demand (Legacy)
|
||||||
async getMoodPresets() {
|
async getMoodPresets() {
|
||||||
return this.request<MoodPreset[]>("/mixes/mood/presets");
|
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
|
// Enrichment
|
||||||
async getEnrichmentSettings() {
|
async getEnrichmentSettings() {
|
||||||
return this.request<any>("/enrichment/settings");
|
return this.request<any>("/enrichment/settings");
|
||||||
|
|||||||
@@ -663,6 +663,10 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
|||||||
? Math.min(Math.max(time, 0), maxDuration)
|
? Math.min(Math.max(time, 0), maxDuration)
|
||||||
: Math.max(time, 0);
|
: 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
|
// Optimistically update local playback time for instant UI feedback
|
||||||
playback.setCurrentTime(clampedTime);
|
playback.setCurrentTime(clampedTime);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useCallback,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,13 +19,17 @@ interface AudioPlaybackContextType {
|
|||||||
targetSeekPosition: number | null;
|
targetSeekPosition: number | null;
|
||||||
canSeek: boolean;
|
canSeek: boolean;
|
||||||
downloadProgress: number | null; // 0-100 for downloading, null for not downloading
|
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;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
|
setCurrentTimeFromEngine: (time: number) => void; // For timeupdate events - respects seek lock
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
setIsBuffering: (buffering: boolean) => void;
|
setIsBuffering: (buffering: boolean) => void;
|
||||||
setTargetSeekPosition: (position: number | null) => void;
|
setTargetSeekPosition: (position: number | null) => void;
|
||||||
setCanSeek: (canSeek: boolean) => void;
|
setCanSeek: (canSeek: boolean) => void;
|
||||||
setDownloadProgress: (progress: number | null) => void;
|
setDownloadProgress: (progress: number | null) => void;
|
||||||
|
lockSeek: (targetTime: number) => void; // Lock updates during seek
|
||||||
|
unlockSeek: () => void; // Unlock after seek completes
|
||||||
}
|
}
|
||||||
|
|
||||||
const AudioPlaybackContext = createContext<
|
const AudioPlaybackContext = createContext<
|
||||||
@@ -42,12 +47,73 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isBuffering, setIsBuffering] = useState(false);
|
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 [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 [isHydrated, setIsHydrated] = useState(false);
|
||||||
const lastSaveTimeRef = useRef<number>(0);
|
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
|
// Restore currentTime from localStorage on mount
|
||||||
// NOTE: Do NOT touch isPlaying here - let user actions control it
|
// NOTE: Do NOT touch isPlaying here - let user actions control it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +129,15 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsHydrated(true);
|
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)
|
// Save currentTime to localStorage (throttled to avoid excessive writes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isHydrated || typeof window === "undefined") return;
|
if (!isHydrated || typeof window === "undefined") return;
|
||||||
@@ -92,15 +167,31 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
|
|||||||
targetSeekPosition,
|
targetSeekPosition,
|
||||||
canSeek,
|
canSeek,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
isSeekLocked,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
|
setCurrentTimeFromEngine,
|
||||||
setDuration,
|
setDuration,
|
||||||
setIsBuffering,
|
setIsBuffering,
|
||||||
setTargetSeekPosition,
|
setTargetSeekPosition,
|
||||||
setCanSeek,
|
setCanSeek,
|
||||||
setDownloadProgress,
|
setDownloadProgress,
|
||||||
|
lockSeek,
|
||||||
|
unlockSeek,
|
||||||
}),
|
}),
|
||||||
[isPlaying, currentTime, duration, isBuffering, targetSeekPosition, canSeek, downloadProgress]
|
[
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isBuffering,
|
||||||
|
targetSeekPosition,
|
||||||
|
canSeek,
|
||||||
|
downloadProgress,
|
||||||
|
isSeekLocked,
|
||||||
|
setCurrentTimeFromEngine,
|
||||||
|
lockSeek,
|
||||||
|
unlockSeek,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class HowlerEngine {
|
|||||||
private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes
|
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 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
|
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() {
|
constructor() {
|
||||||
// Initialize event listener maps
|
// Initialize event listener maps
|
||||||
@@ -105,13 +110,15 @@ class HowlerEngine {
|
|||||||
this.state.currentSrc = src;
|
this.state.currentSrc = src;
|
||||||
|
|
||||||
// Detect if running in Android WebView (for graceful degradation)
|
// Detect if running in Android WebView (for graceful degradation)
|
||||||
const isAndroidWebView = typeof navigator !== "undefined" &&
|
const isAndroidWebView =
|
||||||
/wv/.test(navigator.userAgent.toLowerCase()) &&
|
typeof navigator !== "undefined" &&
|
||||||
|
/wv/.test(navigator.userAgent.toLowerCase()) &&
|
||||||
/android/.test(navigator.userAgent.toLowerCase());
|
/android/.test(navigator.userAgent.toLowerCase());
|
||||||
this.shouldRetryLoads = isAndroidWebView;
|
this.shouldRetryLoads = isAndroidWebView;
|
||||||
|
|
||||||
// Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support)
|
// 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
|
// Build Howl config
|
||||||
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
|
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
|
||||||
@@ -164,13 +171,24 @@ class HowlerEngine {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onloaderror: (id, error) => {
|
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;
|
this.isLoading = false;
|
||||||
|
|
||||||
// Retry logic for transient errors (common on Android WebView)
|
// 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++;
|
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
|
// Save src before cleanup
|
||||||
const srcToRetry = this.state.currentSrc;
|
const srcToRetry = this.state.currentSrc;
|
||||||
@@ -183,7 +201,12 @@ class HowlerEngine {
|
|||||||
|
|
||||||
// Wait a bit before retrying
|
// Wait a bit before retrying
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.load(srcToRetry, autoplayToRetry, formatToRetry, true);
|
this.load(
|
||||||
|
srcToRetry,
|
||||||
|
autoplayToRetry,
|
||||||
|
formatToRetry,
|
||||||
|
true
|
||||||
|
);
|
||||||
}, 500 * this.retryCount); // Exponential backoff
|
}, 500 * this.retryCount); // Exponential backoff
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,14 +299,45 @@ class HowlerEngine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Seek to a specific time
|
* 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 {
|
seek(time: number): void {
|
||||||
if (!this.howl) return;
|
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.state.currentTime = time;
|
||||||
this.howl.seek(time);
|
this.howl.seek(time);
|
||||||
this.emit("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) {
|
if (this.howl && this.state.isPlaying) {
|
||||||
const seek = this.howl.seek();
|
const seek = this.howl.seek();
|
||||||
if (typeof seek === "number") {
|
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.state.currentTime = seek;
|
||||||
this.emit("timeupdate", { time: seek });
|
this.emit("timeupdate", { time: seek });
|
||||||
}
|
}
|
||||||
@@ -497,6 +568,13 @@ class HowlerEngine {
|
|||||||
clearTimeout(this.cleanupTimeoutId);
|
clearTimeout(this.cleanupTimeoutId);
|
||||||
this.cleanupTimeoutId = null;
|
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 ✅
|
||||||
@@ -51,6 +51,9 @@ logger = logging.getLogger('audio-analyzer')
|
|||||||
ESSENTIA_AVAILABLE = False
|
ESSENTIA_AVAILABLE = False
|
||||||
try:
|
try:
|
||||||
import essentia
|
import essentia
|
||||||
|
# Suppress Essentia's internal "No network created" warnings that spam logs
|
||||||
|
essentia.log.warningActive = False
|
||||||
|
essentia.log.infoActive = False
|
||||||
import essentia.standard as es
|
import essentia.standard as es
|
||||||
ESSENTIA_AVAILABLE = True
|
ESSENTIA_AVAILABLE = True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user