diff --git a/README.md b/README.md index dbb0f5a..2c5cb17 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Lidify is built for music lovers who want the convenience of streaming services ## A Note on Native Apps -Lidify's web app and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now. +I got a little and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now. Thanks for your patience while I work through this. @@ -312,40 +312,40 @@ ALLOWED_ORIGINS=http://localhost:3030,https://lidify.yourdomain.com Lidify uses several sensitive environment variables. Never commit your `.env` file. -| Variable | Purpose | Required | -| ------------------------- | ------------------------------ | ----------------- | -| `SESSION_SECRET` | Session encryption (32+ chars) | Yes | -| `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Recommended | -| `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek | -| `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek | -| `LIDARR_API_KEY` | Lidarr integration | If using Lidarr | +| Variable | Purpose | Required | +| ------------------------ | ------------------------------ | --------------- | +| `SESSION_SECRET` | Session encryption (32+ chars) | Yes | +| `SETTINGS_ENCRYPTION_KEY`| Encrypts stored credentials | Recommended | +| `SOULSEEK_USERNAME` | Soulseek login | If u sing Soulseek | +| `SOULSEEK_PASSWORD`- | Soulseek password - | If using S-oulseek | +| `LIDARR_AP I_KEY` | Lidarr integration | If using L idarr | | `OPENAI_API_KEY` | AI features | Optional | -| `LASTFM_API_KEY` | Artist recommendations | Optional | -| `FANART_API_KEY` | Artist images | Optional | +| `LASTFM_API_KEY ` | Artist recommendations | Optional | +| `FANART_API_KEY` | Artist images | Optional | -### VPN Configuration (Optional) +### VPN Configurati on (Optional) If using Mullvad VPN for Soulseek: - -- Place WireGuard config in `backend/mullvad/` (gitignored) -- Never commit VPN credentials or private keys -- The `*.conf` and `key.txt` patterns are already in .gitignore +- Place Wi reGuard config in `ba ckend/mullvad/` (gitignored) +- Never commit VPN cred entials or private keys +- The `*.conf` and `key.txt` patterns are already in .git ignore ### Generating Secrets -```bash +```bas h # Generate a secure session secret -openssl rand -base64 32 +openss l rand - base64 32 # Generate encryption key openssl rand -hex 32 ``` -### Network Security +### Network +Sec urity -- Lidify is designed for self-hosted LAN use -- For external access, use a reverse proxy with HTTPS -- Configure `ALLOWED_ORIGINS` for your domain +- Lidify is designed for self-hosted LAN use +- For exte rnal access, use a reverse proxy with HTTPS +- C o nfigure `ALLOWED_ORIGINS` for your domain --- @@ -355,12 +355,12 @@ Lidify works beautifully on its own, but it becomes even more powerful when conn ### Lidarr -Connect Lidify to your Lidarr instance to request and download new music directly from the app. +Connect Lidify to your Lidarr instance to request and downloa d new music directly from the app. -**What you get:** +**What you get:** - Browse artists and albums you don't own -- Request downloads with a single click +- Request downloads with a single click - Discover Weekly playlists that automatically download new recommendations - Automatic library sync when Lidarr finishes importing @@ -666,6 +666,12 @@ Lidify wouldn't be possible without these services and projects: - [Fanart.tv](https://fanart.tv/) - Artist images and artwork - [Lidarr](https://lidarr.audio/) - Music collection management - [Audiobookshelf](https://www.audiobookshelf.org/) - Audiobook and podcast server +- [Soulseek](https://www.slsknet.org/) - Peer-to-peer music sharing network + +### AI Development Tools + +- [Anthropic Claude](https://www.anthropic.com/) - AI pair programming via [OpenCode](https://github.com/sst/opencode) + [GitHub Copilot](https://github.com/features/copilot) +- [Qwen 3B](https://huggingface.co/Qwen) - Local AI assistance via [Roo Code](https://roocode.com/) --- diff --git a/backend/prisma/migrations/20251225100000_add_similar_artists_json/migration.sql b/backend/prisma/migrations/20251225100000_add_similar_artists_json/migration.sql new file mode 100644 index 0000000..729a0c8 --- /dev/null +++ b/backend/prisma/migrations/20251225100000_add_similar_artists_json/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20251226000000_add_mood_bucket_system/migration.sql b/backend/prisma/migrations/20251226000000_add_mood_bucket_system/migration.sql new file mode 100644 index 0000000..daf3dc2 --- /dev/null +++ b/backend/prisma/migrations/20251226000000_add_mood_bucket_system/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 75cecd7..04c10f6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -42,6 +42,7 @@ model User { deviceLinkCodes DeviceLinkCode[] hiddenPlaylists HiddenPlaylist[] notifications Notification[] + userMoodMix UserMoodMix? } model UserSettings { @@ -129,17 +130,18 @@ model SystemSettings { } model Artist { - id String @id @default(cuid()) - mbid String @unique - name String - normalizedName String @default("") // Lowercase version for case-insensitive matching - summary String? @db.Text - heroUrl String? - genres Json? // Array of genre strings from Last.fm/MusicBrainz - lastSynced DateTime @default(now()) - lastEnriched DateTime? - enrichmentStatus String @default("pending") // pending, enriching, completed, failed - searchVector Unsupported("tsvector")? + id String @id @default(cuid()) + mbid String @unique + name String + normalizedName String @default("") // Lowercase version for case-insensitive matching + summary String? @db.Text + heroUrl String? + genres Json? // Array of genre strings from Last.fm/MusicBrainz + similarArtistsJson Json? // Full Last.fm similar artists data [{name, mbid, match, image}] + lastSynced DateTime @default(now()) + lastEnriched DateTime? + enrichmentStatus String @default("pending") // pending, enriching, completed, failed + searchVector Unsupported("tsvector")? albums Album[] similarFrom SimilarArtist[] @relation("FromArtist") @@ -248,16 +250,28 @@ model Track { likedBy LikedTrack[] cachedBy CachedTrack[] transcodedFiles TranscodedFile[] + moodBuckets MoodBucket[] @@index([albumId]) @@index([fileModified]) @@index([title]) @@index([searchVector], type: Gin) @@index([analysisStatus]) + @@index([analysisMode]) @@index([bpm]) @@index([energy]) @@index([valence]) @@index([danceability]) + @@index([moodHappy]) + @@index([moodSad]) + @@index([moodRelaxed]) + @@index([moodAggressive]) + @@index([moodParty]) + @@index([moodAcoustic]) + @@index([moodElectronic]) + @@index([arousal]) + @@index([acousticness]) + @@index([instrumentalness]) } // Transcoded file cache for audio streaming @@ -881,6 +895,44 @@ model DeviceLinkCode { @@index([userId]) } +// ============================================ +// Mood Mixer System (Pre-computed Mood Buckets) +// ============================================ + +// Pre-computed mood assignments for fast mood mix generation +// Tracks are assigned to mood buckets during audio analysis +model MoodBucket { + id String @id @default(cuid()) + trackId String + mood String // happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic + score Float // Confidence score 0-1 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + track Track @relation(fields: [trackId], references: [id], onDelete: Cascade) + + @@unique([trackId, mood]) + @@index([mood, score(sort: Desc)]) + @@index([trackId]) +} + +// User's active mood mix selection +// Stores the last generated mood mix for display on the home page +model UserMoodMix { + id String @id @default(cuid()) + userId String @unique + mood String // The selected mood (happy, sad, etc.) + trackIds String[] // Cached track IDs for the mix + coverUrls String[] // Cached cover URLs for display + generatedAt DateTime // When this mix was generated + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + // ============================================ // Enums // ============================================ diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index 34a0c12..a2b0e3f 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -90,7 +90,6 @@ router.use((req, res, next) => { */ router.post("/scan", async (req, res) => { try { - if (!config.music.musicPath) { return res.status(500).json({ error: "Music path not configured. Please set MUSIC_PATH environment variable.", @@ -100,7 +99,9 @@ router.post("/scan", async (req, res) => { // First, organize any SLSKD downloads from Docker container to music library // This ensures files are moved before the scan finds them try { - const { organizeSingles } = await import("../workers/organizeSingles"); + const { organizeSingles } = await import( + "../workers/organizeSingles" + ); console.log("[Scan] Organizing SLSKD downloads before scan..."); await organizeSingles(); console.log("[Scan] SLSKD organization complete"); @@ -155,7 +156,6 @@ router.get("/scan/status/:jobId", async (req, res) => { // POST /library/organize - Manually trigger organization script router.post("/organize", async (req, res) => { try { - // Run in background organizeSingles().catch((err) => { console.error("Manual organization failed:", err); @@ -526,7 +526,7 @@ router.get("/recently-added", async (req, res) => { // Get the 20 most recently added LIBRARY albums (by lastSynced timestamp) // This limits "Recently Added" to actual recent additions, not the entire library const recentAlbums = await prisma.album.findMany({ - where: { + where: { location: "LIBRARY", tracks: { some: {} }, // Only albums with actual tracks }, @@ -754,14 +754,21 @@ router.get("/artists", async (req, res) => { if (query) { if (where.AND) { - where.AND.push({ name: { contains: query as string, mode: "insensitive" } }); + where.AND.push({ + name: { contains: query as string, mode: "insensitive" }, + }); } else { where.name = { contains: query as string, mode: "insensitive" }; } } // Determine which album location to count based on filter - const albumLocationFilter = filter === "discovery" ? "DISCOVER" : filter === "all" ? undefined : "LIBRARY"; + const albumLocationFilter = + filter === "discovery" + ? "DISCOVER" + : filter === "all" + ? undefined + : "LIBRARY"; const [artistsWithAlbums, total] = await Promise.all([ prisma.artist.findMany({ @@ -776,7 +783,9 @@ router.get("/artists", async (req, res) => { heroUrl: true, albums: { where: { - ...(albumLocationFilter ? { location: albumLocationFilter } : {}), + ...(albumLocationFilter + ? { location: albumLocationFilter } + : {}), tracks: { some: {} }, }, select: { @@ -887,14 +896,27 @@ router.get("/enrichment-diagnostics", async (req, res) => { problemArtists, failedArtists, suggestions: [ - completedNoImage > 0 ? `${completedNoImage} artists completed enrichment but have no image - external APIs may be failing or rate limited` : null, - tempMbidCount > 0 ? `${tempMbidCount} artists have temp MBIDs - Fanart.tv won't work for them, relies on Deezer/Last.fm` : null, - statusCounts.find(s => s.enrichmentStatus === "pending")?._count ? "Enrichment still in progress - check logs" : null, - statusCounts.find(s => s.enrichmentStatus === "failed")?._count ? "Some artists failed enrichment - may need retry" : null, + completedNoImage > 0 + ? `${completedNoImage} artists completed enrichment but have no image - external APIs may be failing or rate limited` + : null, + tempMbidCount > 0 + ? `${tempMbidCount} artists have temp MBIDs - Fanart.tv won't work for them, relies on Deezer/Last.fm` + : null, + statusCounts.find((s) => s.enrichmentStatus === "pending") + ?._count + ? "Enrichment still in progress - check logs" + : null, + statusCounts.find((s) => s.enrichmentStatus === "failed") + ?._count + ? "Some artists failed enrichment - may need retry" + : null, ].filter(Boolean), }); } catch (error: any) { - console.error("[Library] Enrichment diagnostics error:", error?.message); + console.error( + "[Library] Enrichment diagnostics error:", + error?.message + ); res.status(500).json({ error: "Failed to get diagnostics" }); } }); @@ -908,9 +930,9 @@ router.post("/retry-enrichment", async (req, res) => { data: { enrichmentStatus: "pending" }, }); - res.json({ + res.json({ message: `Reset ${result.count} failed artists to pending`, - count: result.count + count: result.count, }); } catch (error: any) { console.error("[Library] Retry enrichment error:", error?.message); @@ -943,25 +965,8 @@ router.get("/artists/:id", async (req, res) => { }, }, ownedAlbums: true, - similarFrom: { - where: { - weight: { - gte: 0.1, // Only show 10%+ similarity (Last.fm match score) - }, - }, - take: 10, - orderBy: { weight: "desc" }, - include: { - toArtist: { - select: { - id: true, - mbid: true, - name: true, - heroUrl: true, - }, - }, - }, - }, + // Note: similarFrom (FK-based) is no longer used for display + // We now use similarArtistsJson which is fetched by default }; // Try finding by ID first @@ -1006,7 +1011,8 @@ router.get("/artists/:id", async (req, res) => { // Only fetch from MusicBrainz if the artist hasn't been enriched yet let albumsWithOwnership = []; const ownedRgMbids = new Set(artist.ownedAlbums.map((o) => o.rgMbid)); - const isEnriched = artist.ownedAlbums.length > 0 || artist.heroUrl !== null; + const isEnriched = + artist.ownedAlbums.length > 0 || artist.heroUrl !== null; // If artist has temp MBID, try to find real MBID by searching MusicBrainz let effectiveMbid = artist.mbid; @@ -1068,27 +1074,36 @@ router.get("/artists/:id", async (req, res) => { // ========== Supplement with MusicBrainz discography for "available to download" ========== // Always fetch discography if we have a valid MBID - users need to see what's available const hasDbAlbums = dbAlbums.length > 0; - const shouldFetchDiscography = effectiveMbid && !effectiveMbid.startsWith("temp-"); + const shouldFetchDiscography = + effectiveMbid && !effectiveMbid.startsWith("temp-"); if (shouldFetchDiscography) { try { // Check Redis cache first (cache for 24 hours) const discoCacheKey = `discography:${effectiveMbid}`; let releaseGroups: any[] = []; - + const cachedDisco = await redisClient.get(discoCacheKey); if (cachedDisco && cachedDisco !== "NOT_FOUND") { releaseGroups = JSON.parse(cachedDisco); - console.log(`[Artist] Using cached discography (${releaseGroups.length} albums)`); + console.log( + `[Artist] Using cached discography (${releaseGroups.length} albums)` + ); } else { - console.log(`[Artist] Fetching discography from MusicBrainz...`); + console.log( + `[Artist] Fetching discography from MusicBrainz...` + ); releaseGroups = await musicBrainzService.getReleaseGroups( effectiveMbid, ["album", "ep"], 100 ); // Cache for 24 hours - await redisClient.setEx(discoCacheKey, 24 * 60 * 60, JSON.stringify(releaseGroups)); + await redisClient.setEx( + discoCacheKey, + 24 * 60 * 60, + JSON.stringify(releaseGroups) + ); } console.log( @@ -1236,10 +1251,12 @@ router.get("/artists/:id", async (req, res) => { // Check cache first const cachedTopTracks = await redisClient.get(topTracksCacheKey); let lastfmTopTracks: any[] = []; - + if (cachedTopTracks && cachedTopTracks !== "NOT_FOUND") { lastfmTopTracks = JSON.parse(cachedTopTracks); - console.log(`[Artist] Using cached top tracks (${lastfmTopTracks.length})`); + console.log( + `[Artist] Using cached top tracks (${lastfmTopTracks.length})` + ); } else { // Cache miss - fetch from Last.fm const validMbid = @@ -1252,8 +1269,14 @@ router.get("/artists/:id", async (req, res) => { 10 ); // Cache for 24 hours - await redisClient.setEx(topTracksCacheKey, 24 * 60 * 60, JSON.stringify(lastfmTopTracks)); - console.log(`[Artist] Cached ${lastfmTopTracks.length} top tracks`); + await redisClient.setEx( + topTracksCacheKey, + 24 * 60 * 60, + JSON.stringify(lastfmTopTracks) + ); + console.log( + `[Artist] Cached ${lastfmTopTracks.length} top tracks` + ); } // For each Last.fm track, try to match with library track or add as unowned @@ -1332,19 +1355,113 @@ router.get("/artists/:id", async (req, res) => { effectiveMbid ); - // ========== ON-DEMAND SIMILAR ARTISTS FETCHING ========== + // ========== SIMILAR ARTISTS (from enriched JSON or Last.fm API) ========== let similarArtists: any[] = []; const similarCacheKey = `similar-artists:${artist.id}`; - if (artist.similarFrom.length === 0) { - // Check Redis cache first (24-hour cache) + // Check if artist has pre-enriched similar artists JSON (full Last.fm data) + const enrichedSimilar = artist.similarArtistsJson as Array<{ + name: string; + mbid: string | null; + match: number; + }> | null; + + if (enrichedSimilar && enrichedSimilar.length > 0) { + // Use pre-enriched data from database (fast path) + console.log( + `[Artist] Using ${enrichedSimilar.length} similar artists from enriched JSON` + ); + + // First, batch lookup which similar artists exist in our library + const similarNames = enrichedSimilar.slice(0, 10).map((s) => s.name.toLowerCase()); + const similarMbids = enrichedSimilar.slice(0, 10).map((s) => s.mbid).filter(Boolean) as string[]; + + // Find library artists matching by name or mbid + const libraryMatches = await prisma.artist.findMany({ + where: { + OR: [ + { normalizedName: { in: similarNames } }, + ...(similarMbids.length > 0 ? [{ mbid: { in: similarMbids } }] : []), + ], + }, + select: { + id: true, + name: true, + normalizedName: true, + mbid: true, + heroUrl: true, + _count: { + select: { + albums: { + where: { location: "LIBRARY", tracks: { some: {} } }, + }, + }, + }, + }, + }); + + // Create lookup maps for quick matching + const libraryByName = new Map(libraryMatches.map((a) => [a.normalizedName?.toLowerCase() || a.name.toLowerCase(), a])); + const libraryByMbid = new Map(libraryMatches.filter((a) => a.mbid).map((a) => [a.mbid!, a])); + + // Fetch images in parallel from Deezer (cached in Redis) + const similarWithImages = await Promise.all( + enrichedSimilar.slice(0, 10).map(async (s) => { + // Check if this artist is in our library + const libraryArtist = (s.mbid && libraryByMbid.get(s.mbid)) || libraryByName.get(s.name.toLowerCase()); + + let image = libraryArtist?.heroUrl || null; + + // If no library image, try Deezer + if (!image) { + try { + // Check Redis cache first + const cacheKey = `deezer-artist-image:${s.name}`; + const cached = await redisClient.get(cacheKey); + if (cached && cached !== "NOT_FOUND") { + image = cached; + } else { + image = await deezerService.getArtistImage(s.name); + if (image) { + await redisClient.setEx( + cacheKey, + 24 * 60 * 60, + image + ); + } + } + } catch (err) { + // Deezer failed, leave null + } + } + + return { + id: libraryArtist?.id || s.name, + name: s.name, + mbid: s.mbid || null, + coverArt: image, + albumCount: 0, // Would require MusicBrainz lookup - skip for performance + ownedAlbumCount: libraryArtist?._count?.albums || 0, + weight: s.match, + inLibrary: !!libraryArtist, + }; + }) + ); + + similarArtists = similarWithImages; + } else { + // No enriched data - fetch from Last.fm API with Redis cache const cachedSimilar = await redisClient.get(similarCacheKey); if (cachedSimilar && cachedSimilar !== "NOT_FOUND") { similarArtists = JSON.parse(cachedSimilar); - console.log(`[Artist] Using cached similar artists (${similarArtists.length})`); + console.log( + `[Artist] Using cached similar artists (${similarArtists.length})` + ); } else { // Cache miss - fetch from Last.fm - console.log(`[Artist] Fetching similar artists from Last.fm...`); + console.log( + `[Artist] Fetching similar artists from Last.fm...` + ); try { const validMbid = @@ -1357,100 +1474,85 @@ router.get("/artists/:id", async (req, res) => { 10 ); + // Batch lookup which similar artists exist in our library + const similarNames = lastfmSimilar.map((s: any) => s.name.toLowerCase()); + const similarMbids = lastfmSimilar.map((s: any) => s.mbid).filter(Boolean) as string[]; + + const libraryMatches = await prisma.artist.findMany({ + where: { + OR: [ + { normalizedName: { in: similarNames } }, + ...(similarMbids.length > 0 ? [{ mbid: { in: similarMbids } }] : []), + ], + }, + select: { + id: true, + name: true, + normalizedName: true, + mbid: true, + heroUrl: true, + _count: { + select: { + albums: { + where: { location: "LIBRARY", tracks: { some: {} } }, + }, + }, + }, + }, + }); + + const libraryByName = new Map(libraryMatches.map((a) => [a.normalizedName?.toLowerCase() || a.name.toLowerCase(), a])); + const libraryByMbid = new Map(libraryMatches.filter((a) => a.mbid).map((a) => [a.mbid!, a])); + // Fetch images in parallel (Deezer only - fastest source) const similarWithImages = await Promise.all( lastfmSimilar.map(async (s: any) => { - let image = null; - try { - image = await deezerService.getArtistImage(s.name); - } catch (err) { - // Deezer failed, leave null + const libraryArtist = (s.mbid && libraryByMbid.get(s.mbid)) || libraryByName.get(s.name.toLowerCase()); + + let image = libraryArtist?.heroUrl || null; + + if (!image) { + try { + image = await deezerService.getArtistImage( + s.name + ); + } catch (err) { + // Deezer failed, leave null + } } return { - id: s.name, + id: libraryArtist?.id || s.name, name: s.name, mbid: s.mbid || null, coverArt: image, albumCount: 0, - ownedAlbumCount: 0, + ownedAlbumCount: libraryArtist?._count?.albums || 0, weight: s.match, + inLibrary: !!libraryArtist, }; }) ); similarArtists = similarWithImages; - + // Cache for 24 hours - await redisClient.setEx(similarCacheKey, 24 * 60 * 60, JSON.stringify(similarArtists)); - console.log(`[Artist] Cached ${similarArtists.length} similar artists`); + await redisClient.setEx( + similarCacheKey, + 24 * 60 * 60, + JSON.stringify(similarArtists) + ); + console.log( + `[Artist] Cached ${similarArtists.length} similar artists` + ); } catch (error) { - console.error(`[Artist] Failed to fetch similar artists:`, error); + console.error( + `[Artist] Failed to fetch similar artists:`, + error + ); similarArtists = []; } } - } else { - // Use enriched data from database - console.log( - `[Artist] Using ${artist.similarFrom.length} similar artists from database` - ); - - // Format similar artists with coverArt and album counts - const similarArtistIds = artist.similarFrom.map( - (s) => s.toArtist.id - ); - - console.log( - `Fetching album counts for ${similarArtistIds.length} similar artists...` - ); - - // Count TOTAL albums in discography (from Album - enriched MusicBrainz data) - const discographyCounts = await prisma.album.groupBy({ - by: ["artistId"], - where: { artistId: { in: similarArtistIds } }, - _count: { rgMbid: true }, - }); - const discographyCountMap = new Map( - discographyCounts.map((ac) => [ac.artistId, ac._count.rgMbid]) - ); - console.log( - `Discography counts: ${discographyCountMap.size} artists have albums` - ); - - // Count albums USER OWNS (from OwnedAlbum - tracking table) - const userLibraryCounts = await prisma.ownedAlbum.groupBy({ - by: ["artistId"], - where: { artistId: { in: similarArtistIds } }, - _count: { rgMbid: true }, - }); - const userLibraryCountMap = new Map( - userLibraryCounts.map((ac) => [ac.artistId, ac._count.rgMbid]) - ); - console.log( - `User library counts: ${userLibraryCountMap.size} artists are owned` - ); - - // Use DataCacheService for batch image lookup - const similarImageMap = await dataCacheService.getArtistImagesBatch( - artist.similarFrom.map((s) => ({ - id: s.toArtist.id, - heroUrl: s.toArtist.heroUrl, - })) - ); - - similarArtists = artist.similarFrom.map((s) => { - const albumCount = discographyCountMap.get(s.toArtist.id) || 0; - const ownedAlbumCount = userLibraryCountMap.get(s.toArtist.id) || 0; - const coverArt = similarImageMap.get(s.toArtist.id) || s.toArtist.heroUrl || null; - - return { - ...s.toArtist, - coverArt, - albumCount, - ownedAlbumCount, - weight: s.weight, - }; - }); } res.json({ @@ -1488,8 +1590,8 @@ router.get("/albums", async (req, res) => { const ownedAlbumMbids = await prisma.ownedAlbum.findMany({ select: { rgMbid: true }, }); - const ownedMbids = ownedAlbumMbids.map(oa => oa.rgMbid); - + const ownedMbids = ownedAlbumMbids.map((oa) => oa.rgMbid); + // Albums with LIBRARY location OR rgMbid in OwnedAlbum where.OR = [ { location: "LIBRARY", tracks: { some: {} } }, @@ -1505,10 +1607,7 @@ router.get("/albums", async (req, res) => { if (where.OR) { // If we have OR conditions, wrap with AND where = { - AND: [ - { OR: where.OR }, - { artistId: artistId as string } - ] + AND: [{ OR: where.OR }, { artistId: artistId as string }], }; } else { where.artistId = artistId as string; @@ -2092,14 +2191,14 @@ router.get("/cover-art/:id?", imageLimiter, async (req, res) => { router.get("/album-cover/:mbid", imageLimiter, async (req, res) => { try { const { mbid } = req.params; - + if (!mbid || mbid.startsWith("temp-")) { return res.status(400).json({ error: "Valid MBID required" }); } // Fetch from Cover Art Archive (this uses caching internally) const coverUrl = await coverArtService.getCoverArt(mbid); - + if (!coverUrl) { // Return 204 No Content instead of 404 to avoid console spam // Cover Art Archive doesn't have covers for all albums @@ -2262,9 +2361,15 @@ router.get("/tracks/:id/stream", async (req, res) => { }); requestedQuality = settings?.playbackQuality || "medium"; } - - const ext = track.filePath ? path.extname(track.filePath).toLowerCase() : ""; - console.log(`[STREAM] Quality: requested=${quality || 'default'}, using=${requestedQuality}, format=${ext}`); + + const ext = track.filePath + ? path.extname(track.filePath).toLowerCase() + : ""; + console.log( + `[STREAM] Quality: requested=${ + quality || "default" + }, using=${requestedQuality}, format=${ext}` + ); // === NATIVE FILE STREAMING === // Check if track has native file path @@ -2279,7 +2384,7 @@ router.get("/tracks/:id/stream", async (req, res) => { // Get absolute path to source file // Normalize path separators for cross-platform compatibility (Windows -> Linux) - const normalizedFilePath = track.filePath.replace(/\\/g, '/'); + const normalizedFilePath = track.filePath.replace(/\\/g, "/"); const absolutePath = path.join( config.music.musicPath, normalizedFilePath @@ -2299,27 +2404,37 @@ router.get("/tracks/:id/stream", async (req, res) => { ); // Stream file with range support - console.log(`[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}`); - - res.sendFile(filePath, { - headers: { - "Content-Type": mimeType, - "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=31536000", - "Access-Control-Allow-Origin": - req.headers.origin || "*", - "Access-Control-Allow-Credentials": "true", - "Cross-Origin-Resource-Policy": "cross-origin", + console.log( + `[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}` + ); + + res.sendFile( + filePath, + { + headers: { + "Content-Type": mimeType, + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=31536000", + "Access-Control-Allow-Origin": + req.headers.origin || "*", + "Access-Control-Allow-Credentials": "true", + "Cross-Origin-Resource-Policy": "cross-origin", + }, }, - }, (err) => { - // Always destroy the streaming service to clean up intervals - streamingService.destroy(); - if (err) { - console.error(`[STREAM] sendFile error:`, err); - } else { - console.log(`[STREAM] File sent successfully: ${path.basename(filePath)}`); + (err) => { + // Always destroy the streaming service to clean up intervals + streamingService.destroy(); + if (err) { + console.error(`[STREAM] sendFile error:`, err); + } else { + console.log( + `[STREAM] File sent successfully: ${path.basename( + filePath + )}` + ); + } } - }); + ); return; } catch (err: any) { @@ -2331,7 +2446,7 @@ router.get("/tracks/:id/stream", async (req, res) => { console.warn( `[STREAM] FFmpeg not available, falling back to original quality` ); - const fallbackFilePath = track.filePath.replace(/\\/g, '/'); + const fallbackFilePath = track.filePath.replace(/\\/g, "/"); const absolutePath = path.join( config.music.musicPath, fallbackFilePath @@ -2351,23 +2466,30 @@ router.get("/tracks/:id/stream", async (req, res) => { absolutePath ); - res.sendFile(filePath, { - headers: { - "Content-Type": mimeType, - "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=31536000", - "Access-Control-Allow-Origin": - req.headers.origin || "*", - "Access-Control-Allow-Credentials": "true", - "Cross-Origin-Resource-Policy": "cross-origin", + res.sendFile( + filePath, + { + headers: { + "Content-Type": mimeType, + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=31536000", + "Access-Control-Allow-Origin": + req.headers.origin || "*", + "Access-Control-Allow-Credentials": "true", + "Cross-Origin-Resource-Policy": "cross-origin", + }, }, - }, (err) => { - // Always destroy the streaming service to clean up intervals - streamingService.destroy(); - if (err) { - console.error(`[STREAM] sendFile fallback error:`, err); + (err) => { + // Always destroy the streaming service to clean up intervals + streamingService.destroy(); + if (err) { + console.error( + `[STREAM] sendFile fallback error:`, + err + ); + } } - }); + ); return; } @@ -2454,7 +2576,6 @@ router.delete("/tracks/:id", async (req, res) => { // Delete file from filesystem if path is available if (track.filePath) { try { - const absolutePath = path.join( config.music.musicPath, track.filePath @@ -2503,7 +2624,6 @@ router.delete("/albums/:id", async (req, res) => { return res.status(404).json({ error: "Album not found" }); } - // Delete all track files let deletedFiles = 0; for (const track of album.tracks) { @@ -2583,7 +2703,6 @@ router.delete("/artists/:id", async (req, res) => { return res.status(404).json({ error: "Artist not found" }); } - // Delete all track files and collect actual artist folders from file paths let deletedFiles = 0; const artistFoldersToDelete = new Set(); @@ -2639,18 +2758,25 @@ router.delete("/artists/:id", async (req, res) => { for (const artistFolder of artistFoldersToDelete) { try { if (fs.existsSync(artistFolder)) { - console.log(`[DELETE] Attempting to delete folder: ${artistFolder}`); - + console.log( + `[DELETE] Attempting to delete folder: ${artistFolder}` + ); + // Always try recursive delete with force fs.rmSync(artistFolder, { recursive: true, force: true, }); - console.log(`[DELETE] Successfully deleted artist folder: ${artistFolder}`); + console.log( + `[DELETE] Successfully deleted artist folder: ${artistFolder}` + ); } } catch (err: any) { - console.error(`[DELETE] Failed to delete artist folder ${artistFolder}:`, err?.message || err); - + console.error( + `[DELETE] Failed to delete artist folder ${artistFolder}:`, + err?.message || err + ); + // Try alternative: delete contents first, then folder try { const files = fs.readdirSync(artistFolder); @@ -2659,20 +2785,31 @@ router.delete("/artists/:id", async (req, res) => { try { const stat = fs.statSync(filePath); if (stat.isDirectory()) { - fs.rmSync(filePath, { recursive: true, force: true }); + fs.rmSync(filePath, { + recursive: true, + force: true, + }); } else { fs.unlinkSync(filePath); } console.log(`[DELETE] Deleted: ${filePath}`); } catch (fileErr: any) { - console.error(`[DELETE] Could not delete ${filePath}:`, fileErr?.message); + console.error( + `[DELETE] Could not delete ${filePath}:`, + fileErr?.message + ); } } // Try deleting the now-empty folder fs.rmdirSync(artistFolder); - console.log(`[DELETE] Deleted artist folder after manual cleanup: ${artistFolder}`); + console.log( + `[DELETE] Deleted artist folder after manual cleanup: ${artistFolder}` + ); } catch (cleanupErr: any) { - console.error(`[DELETE] Cleanup also failed for ${artistFolder}:`, cleanupErr?.message); + console.error( + `[DELETE] Cleanup also failed for ${artistFolder}:`, + cleanupErr?.message + ); } } } @@ -2683,14 +2820,22 @@ router.delete("/artists/:id", async (req, res) => { path.join(config.music.musicPath, "Soulseek", artist.name), path.join(config.music.musicPath, "discovery", artist.name), ]; - + for (const commonPath of commonPaths) { - if (fs.existsSync(commonPath) && !artistFoldersToDelete.has(commonPath)) { + if ( + fs.existsSync(commonPath) && + !artistFoldersToDelete.has(commonPath) + ) { try { fs.rmSync(commonPath, { recursive: true, force: true }); - console.log(`[DELETE] Deleted additional artist folder: ${commonPath}`); + console.log( + `[DELETE] Deleted additional artist folder: ${commonPath}` + ); } catch (err: any) { - console.error(`[DELETE] Could not delete ${commonPath}:`, err?.message); + console.error( + `[DELETE] Could not delete ${commonPath}:`, + err?.message + ); } } } @@ -2701,16 +2846,24 @@ router.delete("/artists/:id", async (req, res) => { if (artist.mbid && !artist.mbid.startsWith("temp-")) { try { const { lidarrService } = await import("../services/lidarr"); - const lidarrResult = await lidarrService.deleteArtist(artist.mbid, true); + const lidarrResult = await lidarrService.deleteArtist( + artist.mbid, + true + ); if (lidarrResult.success) { console.log(`[DELETE] Lidarr: ${lidarrResult.message}`); lidarrDeleted = true; } else { - console.warn(`[DELETE] Lidarr deletion note: ${lidarrResult.message}`); + console.warn( + `[DELETE] Lidarr deletion note: ${lidarrResult.message}` + ); lidarrError = lidarrResult.message; } } catch (err: any) { - console.warn("[DELETE] Could not delete from Lidarr:", err?.message || err); + console.warn( + "[DELETE] Could not delete from Lidarr:", + err?.message || err + ); lidarrError = err?.message || "Unknown error"; } } @@ -2725,13 +2878,19 @@ router.delete("/artists/:id", async (req, res) => { } // Delete from database (cascade will delete albums and tracks) - console.log(`[DELETE] Deleting artist from database: ${artist.name} (${artist.id})`); + console.log( + `[DELETE] Deleting artist from database: ${artist.name} (${artist.id})` + ); await prisma.artist.delete({ where: { id: artist.id }, }); console.log( - `[DELETE] Successfully deleted artist: ${artist.name} (${deletedFiles} files${lidarrDeleted ? ', removed from Lidarr' : ''})` + `[DELETE] Successfully deleted artist: ${ + artist.name + } (${deletedFiles} files${ + lidarrDeleted ? ", removed from Lidarr" : "" + })` ); res.json({ @@ -2743,9 +2902,9 @@ router.delete("/artists/:id", async (req, res) => { } catch (error: any) { console.error("Delete artist error:", error?.message || error); console.error("Delete artist stack:", error?.stack); - res.status(500).json({ + res.status(500).json({ error: "Failed to delete artist", - details: error?.message || "Unknown error" + details: error?.message || "Unknown error", }); } }); @@ -2760,10 +2919,13 @@ router.get("/genres", async (req, res) => { const artists = await prisma.artist.findMany({ select: { name: true, normalizedName: true }, }); - const artistNames = new Set(artists.flatMap(a => [ - a.name.toLowerCase(), - a.normalizedName?.toLowerCase(), - ].filter(Boolean))); + const artistNames = new Set( + artists.flatMap((a) => + [a.name.toLowerCase(), a.normalizedName?.toLowerCase()].filter( + Boolean + ) + ) + ); // Get genres from TrackGenre relation (most accurate) const trackGenres = await prisma.genre.findMany({ @@ -2805,10 +2967,14 @@ router.get("/genres", async (req, res) => { for (const genre of albumGenres) { const normalized = genre.trim(); // Skip if it matches an artist name - if (normalized && !artistNames.has(normalized.toLowerCase())) { + if ( + normalized && + !artistNames.has(normalized.toLowerCase()) + ) { genreMap.set( normalized, - (genreMap.get(normalized) || 0) + album._count.tracks + (genreMap.get(normalized) || 0) + + album._count.tracks ); } } @@ -2849,7 +3015,7 @@ router.get("/decades", async (req, res) => { // Group by decade const decadeMap = new Map(); - + for (const album of albums) { if (album.year) { // Calculate decade start (e.g., 1987 -> 1980, 2023 -> 2020) @@ -2864,7 +3030,7 @@ router.get("/decades", async (req, res) => { // Convert to array, filter by minimum tracks, and sort by decade const decades = Array.from(decadeMap.entries()) .map(([decade, count]) => ({ decade, count })) - .filter(d => d.count >= 15) // Minimum 15 tracks for a radio station + .filter((d) => d.count >= 15) // Minimum 15 tracks for a radio station .sort((a, b) => b.decade - a.decade); // Newest first res.json({ decades }); @@ -2877,7 +3043,7 @@ router.get("/decades", async (req, res) => { /** * GET /library/radio * Get tracks for a library-based radio station - * + * * Query params: * - type: "discovery" | "favorites" | "decade" | "genre" | "mood" * - value: Optional value for decade (e.g., "1990") or genre name @@ -2909,12 +3075,14 @@ router.get("/radio", async (req, res) => { select: { id: true }, take: limitNum * 2, }); - + if (unplayedTracks.length >= limitNum) { - trackIds = unplayedTracks.map(t => t.id); + trackIds = unplayedTracks.map((t) => t.id); } else { // Fallback: get tracks with the fewest plays using raw count - const leastPlayedTracks = await prisma.$queryRaw<{ id: string }[]>` + const leastPlayedTracks = await prisma.$queryRaw< + { id: string }[] + >` SELECT t.id FROM "Track" t LEFT JOIN "Play" p ON p."trackId" = t.id @@ -2922,13 +3090,15 @@ router.get("/radio", async (req, res) => { ORDER BY COUNT(p.id) ASC LIMIT ${limitNum * 2} `; - trackIds = leastPlayedTracks.map(t => t.id); + trackIds = leastPlayedTracks.map((t) => t.id); } break; case "favorites": // Most-played tracks - use raw query for accurate count ordering - const mostPlayedTracks = await prisma.$queryRaw<{ id: string; play_count: bigint }[]>` + const mostPlayedTracks = await prisma.$queryRaw< + { id: string; play_count: bigint }[] + >` SELECT t.id, COUNT(p.id) as play_count FROM "Track" t LEFT JOIN "Play" p ON p."trackId" = t.id @@ -2937,17 +3107,19 @@ router.get("/radio", async (req, res) => { ORDER BY play_count DESC LIMIT ${limitNum * 2} `; - + if (mostPlayedTracks.length > 0) { - trackIds = mostPlayedTracks.map(t => t.id); + trackIds = mostPlayedTracks.map((t) => t.id); } else { // No play data yet - just get random tracks - console.log("[Radio:favorites] No play data found, returning random tracks"); + console.log( + "[Radio:favorites] No play data found, returning random tracks" + ); const randomTracks = await prisma.track.findMany({ select: { id: true }, take: limitNum * 2, }); - trackIds = randomTracks.map(t => t.id); + trackIds = randomTracks.map((t) => t.id); } break; @@ -2955,7 +3127,7 @@ router.get("/radio", async (req, res) => { // Filter by decade (e.g., value = "1990" for 90s) const decadeStart = parseInt(value as string) || 2000; const decadeEnd = decadeStart + 9; - + const decadeTracks = await prisma.track.findMany({ where: { album: { @@ -2968,54 +3140,63 @@ router.get("/radio", async (req, res) => { select: { id: true }, take: limitNum * 3, }); - trackIds = decadeTracks.map(t => t.id); + trackIds = decadeTracks.map((t) => t.id); break; case "genre": // Filter by genre (matches against album or track genre tags) - const genreValue = (value as string || "").toLowerCase(); - + const genreValue = ((value as string) || "").toLowerCase(); + // Strategy 1: Check trackGenres relation (most reliable) const genreRelationTracks = await prisma.track.findMany({ where: { - trackGenres: { - some: { - genre: { - name: { contains: genreValue, mode: "insensitive" } - } - } + trackGenres: { + some: { + genre: { + name: { + contains: genreValue, + mode: "insensitive", + }, + }, + }, }, }, select: { id: true }, take: limitNum * 2, }); - trackIds = genreRelationTracks.map(t => t.id); - + trackIds = genreRelationTracks.map((t) => t.id); + // Strategy 2: If not enough, check album.genres JSON field with raw query if (trackIds.length < limitNum) { - const albumGenreTracks = await prisma.$queryRaw<{ id: string }[]>` + const albumGenreTracks = await prisma.$queryRaw< + { id: string }[] + >` SELECT t.id FROM "Track" t JOIN "Album" a ON t."albumId" = a.id WHERE a.genres IS NOT NULL AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(a.genres::jsonb) AS g - WHERE LOWER(g) LIKE ${'%' + genreValue + '%'} + WHERE LOWER(g) LIKE ${"%" + genreValue + "%"} ) LIMIT ${limitNum * 2} `; - const newIds = albumGenreTracks.map(t => t.id).filter(id => !trackIds.includes(id)); + const newIds = albumGenreTracks + .map((t) => t.id) + .filter((id) => !trackIds.includes(id)); trackIds = [...trackIds, ...newIds]; } - - console.log(`[Radio:genre] Found ${trackIds.length} tracks for genre "${genreValue}"`); + + console.log( + `[Radio:genre] Found ${trackIds.length} tracks for genre "${genreValue}"` + ); break; case "mood": // Mood-based filtering using audio analysis features - const moodValue = (value as string || "").toLowerCase(); + const moodValue = ((value as string) || "").toLowerCase(); let moodWhere: any = { analysisStatus: "completed" }; - + switch (moodValue) { case "high-energy": moodWhere = { @@ -3073,65 +3254,104 @@ router.get("/radio", async (req, res) => { lastfmTags: { has: moodValue }, }; } - + const moodTracks = await prisma.track.findMany({ where: moodWhere, select: { id: true }, take: limitNum * 3, }); - trackIds = moodTracks.map(t => t.id); + trackIds = moodTracks.map((t) => t.id); break; case "workout": // High-energy workout tracks - multiple strategies let workoutTrackIds: string[] = []; - + // Strategy 1: Audio analysis - high energy AND fast BPM const energyTracks = await prisma.track.findMany({ where: { analysisStatus: "completed", OR: [ // High energy with fast tempo - { AND: [{ energy: { gte: 0.65 } }, { bpm: { gte: 115 } }] }, + { + AND: [ + { energy: { gte: 0.65 } }, + { bpm: { gte: 115 } }, + ], + }, // Has workout mood tag - { moodTags: { hasSome: ["workout", "energetic", "upbeat"] } }, + { + moodTags: { + hasSome: ["workout", "energetic", "upbeat"], + }, + }, ], }, select: { id: true }, take: limitNum * 2, }); - workoutTrackIds = energyTracks.map(t => t.id); - console.log(`[Radio:workout] Found ${workoutTrackIds.length} tracks via audio analysis`); - + workoutTrackIds = energyTracks.map((t) => t.id); + console.log( + `[Radio:workout] Found ${workoutTrackIds.length} tracks via audio analysis` + ); + // Strategy 2: Genre-based (if not enough from audio) if (workoutTrackIds.length < limitNum) { const workoutGenreNames = [ - "rock", "metal", "hard rock", "alternative rock", "punk", - "hip hop", "rap", "trap", "electronic", "edm", "house", - "techno", "drum and bass", "dubstep", "hardstyle", - "metalcore", "hardcore", "industrial", "nu metal", "pop punk" + "rock", + "metal", + "hard rock", + "alternative rock", + "punk", + "hip hop", + "rap", + "trap", + "electronic", + "edm", + "house", + "techno", + "drum and bass", + "dubstep", + "hardstyle", + "metalcore", + "hardcore", + "industrial", + "nu metal", + "pop punk", ]; - + // Check Genre table const workoutGenres = await prisma.genre.findMany({ where: { - name: { in: workoutGenreNames, mode: "insensitive" }, + name: { + in: workoutGenreNames, + mode: "insensitive", + }, }, include: { - trackGenres: { select: { trackId: true }, take: 50 }, + trackGenres: { + select: { trackId: true }, + take: 50, + }, }, }); - - const genreTrackIds = workoutGenres.flatMap(g => g.trackGenres.map(tg => tg.trackId)); - workoutTrackIds = [...new Set([...workoutTrackIds, ...genreTrackIds])]; - console.log(`[Radio:workout] After genre check: ${workoutTrackIds.length} tracks`); - + + const genreTrackIds = workoutGenres.flatMap((g) => + g.trackGenres.map((tg) => tg.trackId) + ); + workoutTrackIds = [ + ...new Set([...workoutTrackIds, ...genreTrackIds]), + ]; + console.log( + `[Radio:workout] After genre check: ${workoutTrackIds.length} tracks` + ); + // Also check album.genres JSON field if (workoutTrackIds.length < limitNum) { const albumGenreTracks = await prisma.track.findMany({ where: { album: { - OR: workoutGenreNames.map(g => ({ + OR: workoutGenreNames.map((g) => ({ genres: { string_contains: g }, })), }, @@ -3139,11 +3359,18 @@ router.get("/radio", async (req, res) => { select: { id: true }, take: limitNum, }); - workoutTrackIds = [...new Set([...workoutTrackIds, ...albumGenreTracks.map(t => t.id)])]; - console.log(`[Radio:workout] After album genre check: ${workoutTrackIds.length} tracks`); + workoutTrackIds = [ + ...new Set([ + ...workoutTrackIds, + ...albumGenreTracks.map((t) => t.id), + ]), + ]; + console.log( + `[Radio:workout] After album genre check: ${workoutTrackIds.length} tracks` + ); } } - + trackIds = workoutTrackIds; break; @@ -3152,59 +3379,94 @@ router.get("/radio", async (req, res) => { // Uses hybrid approach: Last.fm similarity (filtered to library) + genre matching + vibe boost const artistId = value as string; if (!artistId) { - return res.status(400).json({ error: "Artist ID required for artist radio" }); + return res + .status(400) + .json({ error: "Artist ID required for artist radio" }); } - - console.log(`[Radio:artist] Starting artist radio for: ${artistId}`); - + + console.log( + `[Radio:artist] Starting artist radio for: ${artistId}` + ); + // 1. Get tracks from this artist (they're in library by definition) const artistTracks = await prisma.track.findMany({ where: { album: { artistId } }, - select: { - id: true, - bpm: true, - energy: true, + select: { + id: true, + bpm: true, + energy: true, valence: true, danceability: true, }, }); - console.log(`[Radio:artist] Found ${artistTracks.length} tracks from artist`); - + console.log( + `[Radio:artist] Found ${artistTracks.length} tracks from artist` + ); + if (artistTracks.length === 0) { return res.json({ tracks: [] }); } - + // Calculate artist's average "vibe" for later matching - const analyzedTracks = artistTracks.filter(t => t.bpm || t.energy || t.valence); - const avgVibe = analyzedTracks.length > 0 ? { - bpm: analyzedTracks.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzedTracks.length, - energy: analyzedTracks.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzedTracks.length, - valence: analyzedTracks.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzedTracks.length, - danceability: analyzedTracks.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzedTracks.length, - } : null; + const analyzedTracks = artistTracks.filter( + (t) => t.bpm || t.energy || t.valence + ); + const avgVibe = + analyzedTracks.length > 0 + ? { + bpm: + analyzedTracks.reduce( + (sum, t) => sum + (t.bpm || 0), + 0 + ) / analyzedTracks.length, + energy: + analyzedTracks.reduce( + (sum, t) => sum + (t.energy || 0), + 0 + ) / analyzedTracks.length, + valence: + analyzedTracks.reduce( + (sum, t) => sum + (t.valence || 0), + 0 + ) / analyzedTracks.length, + danceability: + analyzedTracks.reduce( + (sum, t) => sum + (t.danceability || 0), + 0 + ) / analyzedTracks.length, + } + : null; console.log(`[Radio:artist] Artist vibe:`, avgVibe); - + // 2. Get library artist IDs (artists user actually owns) const ownedArtists = await prisma.ownedAlbum.findMany({ select: { artistId: true }, - distinct: ['artistId'], + distinct: ["artistId"], }); - const libraryArtistIds = new Set(ownedArtists.map(o => o.artistId)); + const libraryArtistIds = new Set( + ownedArtists.map((o) => o.artistId) + ); libraryArtistIds.delete(artistId); // Exclude the current artist - console.log(`[Radio:artist] Library has ${libraryArtistIds.size} other artists`); - + console.log( + `[Radio:artist] Library has ${libraryArtistIds.size} other artists` + ); + // 3. Try Last.fm similar artists, filtered to library const similarInLibrary = await prisma.similarArtist.findMany({ - where: { + where: { fromArtistId: artistId, toArtistId: { in: Array.from(libraryArtistIds) }, }, orderBy: { weight: "desc" }, take: 15, }); - let similarArtistIds = similarInLibrary.map(s => s.toArtistId); - console.log(`[Radio:artist] Found ${similarArtistIds.length} Last.fm similar artists in library`); - + let similarArtistIds = similarInLibrary.map( + (s) => s.toArtistId + ); + console.log( + `[Radio:artist] Found ${similarArtistIds.length} Last.fm similar artists in library` + ); + // 4. Fallback: genre matching if not enough similar artists if (similarArtistIds.length < 5 && libraryArtistIds.size > 0) { const artist = await prisma.artist.findUnique({ @@ -3212,7 +3474,7 @@ router.get("/radio", async (req, res) => { select: { genres: true }, }); const artistGenres = (artist?.genres as string[]) || []; - + if (artistGenres.length > 0) { // Find library artists with overlapping genres const genreMatchArtists = await prisma.artist.findMany({ @@ -3221,53 +3483,79 @@ router.get("/radio", async (req, res) => { }, select: { id: true, genres: true }, }); - + // Score artists by genre overlap const scoredArtists = genreMatchArtists - .map(a => { - const theirGenres = (a.genres as string[]) || []; - const overlap = artistGenres.filter(g => - theirGenres.some(tg => tg.toLowerCase().includes(g.toLowerCase()) || - g.toLowerCase().includes(tg.toLowerCase())) + .map((a) => { + const theirGenres = + (a.genres as string[]) || []; + const overlap = artistGenres.filter((g) => + theirGenres.some( + (tg) => + tg + .toLowerCase() + .includes(g.toLowerCase()) || + g + .toLowerCase() + .includes(tg.toLowerCase()) + ) ).length; return { id: a.id, score: overlap }; }) - .filter(a => a.score > 0) + .filter((a) => a.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 10); - - const genreArtistIds = scoredArtists.map(a => a.id); - similarArtistIds = [...new Set([...similarArtistIds, ...genreArtistIds])]; - console.log(`[Radio:artist] After genre matching: ${similarArtistIds.length} similar artists`); + + const genreArtistIds = scoredArtists.map((a) => a.id); + similarArtistIds = [ + ...new Set([ + ...similarArtistIds, + ...genreArtistIds, + ]), + ]; + console.log( + `[Radio:artist] After genre matching: ${similarArtistIds.length} similar artists` + ); } } - + // 5. Get tracks from similar library artists - let similarTracks: { id: string; bpm: number | null; energy: number | null; valence: number | null; danceability: number | null }[] = []; + let similarTracks: { + id: string; + bpm: number | null; + energy: number | null; + valence: number | null; + danceability: number | null; + }[] = []; if (similarArtistIds.length > 0) { similarTracks = await prisma.track.findMany({ - where: { album: { artistId: { in: similarArtistIds } } }, - select: { - id: true, - bpm: true, - energy: true, + where: { + album: { artistId: { in: similarArtistIds } }, + }, + select: { + id: true, + bpm: true, + energy: true, valence: true, danceability: true, }, }); - console.log(`[Radio:artist] Found ${similarTracks.length} tracks from similar artists`); + console.log( + `[Radio:artist] Found ${similarTracks.length} tracks from similar artists` + ); } - + // 6. Apply vibe boost if we have audio analysis data if (avgVibe && similarTracks.length > 0) { // Score each similar track by how close its vibe is to the artist's average similarTracks = similarTracks - .map(t => { - if (!t.bpm && !t.energy && !t.valence) return { ...t, vibeScore: 0.5 }; - + .map((t) => { + if (!t.bpm && !t.energy && !t.valence) + return { ...t, vibeScore: 0.5 }; + let score = 0; let factors = 0; - + if (t.bpm && avgVibe.bpm) { // BPM within 20 = good match const bpmDiff = Math.abs(t.bpm - avgVibe.bpm); @@ -3275,37 +3563,74 @@ router.get("/radio", async (req, res) => { factors++; } if (t.energy !== null && avgVibe.energy) { - score += 1 - Math.abs((t.energy || 0) - avgVibe.energy); + score += + 1 - + Math.abs((t.energy || 0) - avgVibe.energy); factors++; } if (t.valence !== null && avgVibe.valence) { - score += 1 - Math.abs((t.valence || 0) - avgVibe.valence); + score += + 1 - + Math.abs( + (t.valence || 0) - avgVibe.valence + ); factors++; } - if (t.danceability !== null && avgVibe.danceability) { - score += 1 - Math.abs((t.danceability || 0) - avgVibe.danceability); + if ( + t.danceability !== null && + avgVibe.danceability + ) { + score += + 1 - + Math.abs( + (t.danceability || 0) - + avgVibe.danceability + ); factors++; } - - return { ...t, vibeScore: factors > 0 ? score / factors : 0.5 }; + + return { + ...t, + vibeScore: factors > 0 ? score / factors : 0.5, + }; }) - .sort((a, b) => (b as any).vibeScore - (a as any).vibeScore); - - console.log(`[Radio:artist] Applied vibe boost, top score: ${(similarTracks[0] as any)?.vibeScore?.toFixed(2)}`); + .sort( + (a, b) => + (b as any).vibeScore - (a as any).vibeScore + ); + + console.log( + `[Radio:artist] Applied vibe boost, top score: ${( + similarTracks[0] as any + )?.vibeScore?.toFixed(2)}` + ); } - + // 7. Mix: ~40% original artist, ~60% similar (vibe-boosted) - const originalCount = Math.min(Math.ceil(limitNum * 0.4), artistTracks.length); - const similarCount = Math.min(limitNum - originalCount, similarTracks.length); - - const selectedOriginal = artistTracks.sort(() => Math.random() - 0.5).slice(0, originalCount); + const originalCount = Math.min( + Math.ceil(limitNum * 0.4), + artistTracks.length + ); + const similarCount = Math.min( + limitNum - originalCount, + similarTracks.length + ); + + const selectedOriginal = artistTracks + .sort(() => Math.random() - 0.5) + .slice(0, originalCount); // Take top vibe-matched tracks (already sorted by vibe score), then shuffle slightly - const selectedSimilar = similarTracks.slice(0, similarCount * 2) + const selectedSimilar = similarTracks + .slice(0, similarCount * 2) .sort(() => Math.random() - 0.3) // Slight shuffle to add variety .slice(0, similarCount); - - trackIds = [...selectedOriginal, ...selectedSimilar].map(t => t.id); - console.log(`[Radio:artist] Final mix: ${selectedOriginal.length} original + ${selectedSimilar.length} similar = ${trackIds.length} tracks`); + + trackIds = [...selectedOriginal, ...selectedSimilar].map( + (t) => t.id + ); + console.log( + `[Radio:artist] Final mix: ${selectedOriginal.length} original + ${selectedSimilar.length} similar = ${trackIds.length} tracks` + ); break; case "vibe": @@ -3313,40 +3638,56 @@ router.get("/radio", async (req, res) => { // Pure audio feature matching with graceful fallbacks const sourceTrackId = value as string; if (!sourceTrackId) { - return res.status(400).json({ error: "Track ID required for vibe matching" }); + return res + .status(400) + .json({ error: "Track ID required for vibe matching" }); } - - console.log(`[Radio:vibe] Starting vibe match for track: ${sourceTrackId}`); - + + console.log( + `[Radio:vibe] Starting vibe match for track: ${sourceTrackId}` + ); + // 1. Get the source track's audio features (including Enhanced mode fields) - const sourceTrack = await prisma.track.findUnique({ + const sourceTrack = (await prisma.track.findUnique({ where: { id: sourceTrackId }, include: { album: { - select: { - artistId: true, + select: { + artistId: true, genres: true, - artist: { select: { id: true, name: true } } - } - } - } - }) as any; // Cast to any to include all Track fields - + artist: { select: { id: true, name: true } }, + }, + }, + }, + })) as any; // Cast to any to include all Track fields + if (!sourceTrack) { return res.status(404).json({ error: "Track not found" }); } - + // Check if track has Enhanced mode analysis - const isEnhancedAnalysis = sourceTrack.analysisMode === 'enhanced' || - (sourceTrack.moodHappy !== null && sourceTrack.moodSad !== null); - - console.log(`[Radio:vibe] Source: "${sourceTrack.title}" by ${sourceTrack.album.artist.name}`); - console.log(`[Radio:vibe] Analysis mode: ${isEnhancedAnalysis ? 'ENHANCED' : 'STANDARD'}`); - console.log(`[Radio:vibe] Source features: BPM=${sourceTrack.bpm}, Energy=${sourceTrack.energy}, Valence=${sourceTrack.valence}`); + const isEnhancedAnalysis = + sourceTrack.analysisMode === "enhanced" || + (sourceTrack.moodHappy !== null && + sourceTrack.moodSad !== null); + + console.log( + `[Radio:vibe] Source: "${sourceTrack.title}" by ${sourceTrack.album.artist.name}` + ); + console.log( + `[Radio:vibe] Analysis mode: ${ + isEnhancedAnalysis ? "ENHANCED" : "STANDARD" + }` + ); + console.log( + `[Radio:vibe] Source features: BPM=${sourceTrack.bpm}, Energy=${sourceTrack.energy}, Valence=${sourceTrack.valence}` + ); if (isEnhancedAnalysis) { - console.log(`[Radio:vibe] ML Moods: Happy=${sourceTrack.moodHappy}, Sad=${sourceTrack.moodSad}, Relaxed=${sourceTrack.moodRelaxed}, Aggressive=${sourceTrack.moodAggressive}, Party=${sourceTrack.moodParty}, Acoustic=${sourceTrack.moodAcoustic}, Electronic=${sourceTrack.moodElectronic}`); + console.log( + `[Radio:vibe] ML Moods: Happy=${sourceTrack.moodHappy}, Sad=${sourceTrack.moodSad}, Relaxed=${sourceTrack.moodRelaxed}, Aggressive=${sourceTrack.moodAggressive}, Party=${sourceTrack.moodParty}, Acoustic=${sourceTrack.moodAcoustic}, Electronic=${sourceTrack.moodElectronic}` + ); } - + // Store source features for frontend visualization vibeSourceFeatures = { bpm: sourceTrack.bpm, @@ -3364,26 +3705,29 @@ router.get("/radio", async (req, res) => { moodParty: sourceTrack.moodParty, moodAcoustic: sourceTrack.moodAcoustic, moodElectronic: sourceTrack.moodElectronic, - analysisMode: isEnhancedAnalysis ? 'enhanced' : 'standard', + analysisMode: isEnhancedAnalysis ? "enhanced" : "standard", }; - + let vibeMatchedIds: string[] = []; const sourceArtistId = sourceTrack.album.artistId; - + // 2. Try audio feature matching first (if track is analyzed) - const hasAudioData = sourceTrack.bpm || sourceTrack.energy || sourceTrack.valence; - + const hasAudioData = + sourceTrack.bpm || + sourceTrack.energy || + sourceTrack.valence; + if (hasAudioData) { // Get all analyzed tracks (excluding source) - include Enhanced mode fields const analyzedTracks = await prisma.track.findMany({ - where: { + where: { id: { not: sourceTrackId }, analysisStatus: "completed", }, - select: { - id: true, - bpm: true, - energy: true, + select: { + id: true, + bpm: true, + energy: true, valence: true, arousal: true, danceability: true, @@ -3404,30 +3748,52 @@ router.get("/radio", async (req, res) => { analysisMode: true, }, }); - - console.log(`[Radio:vibe] Found ${analyzedTracks.length} analyzed tracks to compare`); - + + console.log( + `[Radio:vibe] Found ${analyzedTracks.length} analyzed tracks to compare` + ); + if (analyzedTracks.length > 0) { // === COSINE SIMILARITY SCORING === // Industry-standard approach: build feature vectors, compute cosine similarity // Uses ALL 13 features for comprehensive matching - + // Enhanced valence: mode/tonality + mood + audio features - const calculateEnhancedValence = (track: any): number => { + const calculateEnhancedValence = ( + track: any + ): number => { const happy = track.moodHappy ?? 0.5; const sad = track.moodSad ?? 0.5; const party = (track as any).moodParty ?? 0.5; - const isMajor = track.keyScale === 'major'; - const isMinor = track.keyScale === 'minor'; - const modeValence = isMajor ? 0.3 : (isMinor ? -0.2 : 0); - const moodValence = happy * 0.35 + party * 0.25 + (1 - sad) * 0.2; - const audioValence = (track.energy ?? 0.5) * 0.1 + (track.danceabilityMl ?? track.danceability ?? 0.5) * 0.1; - - return Math.max(0, Math.min(1, moodValence + modeValence + audioValence)); + const isMajor = track.keyScale === "major"; + const isMinor = track.keyScale === "minor"; + const modeValence = isMajor + ? 0.3 + : isMinor + ? -0.2 + : 0; + const moodValence = + happy * 0.35 + party * 0.25 + (1 - sad) * 0.2; + const audioValence = + (track.energy ?? 0.5) * 0.1 + + (track.danceabilityMl ?? + track.danceability ?? + 0.5) * + 0.1; + + return Math.max( + 0, + Math.min( + 1, + moodValence + modeValence + audioValence + ) + ); }; // Enhanced arousal: mood + energy + tempo (avoids unreliable "electronic" mood) - const calculateEnhancedArousal = (track: any): number => { + const calculateEnhancedArousal = ( + track: any + ): number => { const aggressive = track.moodAggressive ?? 0.5; const party = (track as any).moodParty ?? 0.5; const relaxed = track.moodRelaxed ?? 0.5; @@ -3436,10 +3802,22 @@ router.get("/radio", async (req, res) => { const bpm = track.bpm ?? 120; const moodArousal = aggressive * 0.3 + party * 0.2; const energyArousal = energy * 0.25; - const tempoArousal = Math.max(0, Math.min(1, (bpm - 60) / 120)) * 0.15; - const calmReduction = ((1 - relaxed) * 0.05) + ((1 - acoustic) * 0.05); - - return Math.max(0, Math.min(1, moodArousal + energyArousal + tempoArousal + calmReduction)); + const tempoArousal = + Math.max(0, Math.min(1, (bpm - 60) / 120)) * + 0.15; + const calmReduction = + (1 - relaxed) * 0.05 + (1 - acoustic) * 0.05; + + return Math.max( + 0, + Math.min( + 1, + moodArousal + + energyArousal + + tempoArousal + + calmReduction + ) + ); }; // OOD detection using Energy-based scoring @@ -3448,36 +3826,44 @@ router.get("/radio", async (req, res) => { track.moodHappy ?? 0.5, track.moodSad ?? 0.5, track.moodRelaxed ?? 0.5, - track.moodAggressive ?? 0.5 + track.moodAggressive ?? 0.5, ]; - + const minMood = Math.min(...coreMoods); const maxMood = Math.max(...coreMoods); - + // Enhanced OOD detection based on research // Flag if all core moods are high (>0.7) with low variance, OR if all are very neutral (~0.5) - const allHigh = minMood > 0.7 && (maxMood - minMood) < 0.3; - const allNeutral = Math.abs(maxMood - 0.5) < 0.15 && Math.abs(minMood - 0.5) < 0.15; - + const allHigh = + minMood > 0.7 && maxMood - minMood < 0.3; + const allNeutral = + Math.abs(maxMood - 0.5) < 0.15 && + Math.abs(minMood - 0.5) < 0.15; + return allHigh || allNeutral; }; // Octave-aware BPM distance calculation - const octaveAwareBPMDistance = (bpm1: number, bpm2: number): number => { + const octaveAwareBPMDistance = ( + bpm1: number, + bpm2: number + ): number => { if (!bpm1 || !bpm2) return 0; - + // Normalize to standard octave range (77-154 BPM) const normalizeToOctave = (bpm: number): number => { while (bpm < 77) bpm *= 2; while (bpm > 154) bpm /= 2; return bpm; }; - + const norm1 = normalizeToOctave(bpm1); const norm2 = normalizeToOctave(bpm2); - + // Calculate distance on logarithmic scale for harmonic equivalence - const logDistance = Math.abs(Math.log2(norm1) - Math.log2(norm2)); + const logDistance = Math.abs( + Math.log2(norm1) - Math.log2(norm2) + ); return Math.min(logDistance, 1); // Cap at 1 for similarity calculation }; @@ -3485,43 +3871,67 @@ router.get("/radio", async (req, res) => { const buildFeatureVector = (track: any): number[] => { // Detect OOD and apply normalization if needed const isOOD = detectOOD(track); - + // Get mood values with OOD normalization - const getMoodValue = (value: number | null, defaultValue: number): number => { + const getMoodValue = ( + value: number | null, + defaultValue: number + ): number => { if (!value) return defaultValue; if (!isOOD) return value; // Normalize OOD predictions to spread them out (0.2-0.8 range) - return 0.2 + Math.max(0, Math.min(0.6, value - 0.2)); + return ( + 0.2 + + Math.max(0, Math.min(0.6, value - 0.2)) + ); }; - + // Use enhanced valence/arousal calculations - const enhancedValence = calculateEnhancedValence(track); - const enhancedArousal = calculateEnhancedArousal(track); - + const enhancedValence = + calculateEnhancedValence(track); + const enhancedArousal = + calculateEnhancedArousal(track); + return [ // ML Mood predictions (7 features) - enhanced weighting and OOD handling - getMoodValue(track.moodHappy, 0.5) * 1.3, // 1.3x weight for semantic features + getMoodValue(track.moodHappy, 0.5) * 1.3, // 1.3x weight for semantic features getMoodValue(track.moodSad, 0.5) * 1.3, getMoodValue(track.moodRelaxed, 0.5) * 1.3, getMoodValue(track.moodAggressive, 0.5) * 1.3, - getMoodValue((track as any).moodParty, 0.5) * 1.3, - getMoodValue((track as any).moodAcoustic, 0.5) * 1.3, - getMoodValue((track as any).moodElectronic, 0.5) * 1.3, + getMoodValue((track as any).moodParty, 0.5) * + 1.3, + getMoodValue((track as any).moodAcoustic, 0.5) * + 1.3, + getMoodValue( + (track as any).moodElectronic, + 0.5 + ) * 1.3, // Audio features (5 features) - standard weight track.energy ?? 0.5, enhancedArousal, // Use enhanced arousal - track.danceabilityMl ?? track.danceability ?? 0.5, + track.danceabilityMl ?? + track.danceability ?? + 0.5, track.instrumentalness ?? 0.5, // Octave-aware BPM normalized to 0-1 - 1 - octaveAwareBPMDistance(track.bpm ?? 120, 120), // Similarity to reference tempo + 1 - + octaveAwareBPMDistance( + track.bpm ?? 120, + 120 + ), // Similarity to reference tempo // Enhanced key mode with valence consideration enhancedValence, // Use enhanced valence instead of binary key ]; }; - + // Helper: Compute cosine similarity between two vectors - const cosineSimilarity = (a: number[], b: number[]): number => { - let dot = 0, magA = 0, magB = 0; + const cosineSimilarity = ( + a: number[], + b: number[] + ): number => { + let dot = 0, + magA = 0, + magB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; magA += a[i] * a[i]; @@ -3530,40 +3940,55 @@ router.get("/radio", async (req, res) => { if (magA === 0 || magB === 0) return 0; return dot / (Math.sqrt(magA) * Math.sqrt(magB)); }; - + // Helper: Compute tag overlap bonus const computeTagBonus = ( - sourceTags: string[], + sourceTags: string[], sourceGenres: string[], trackTags: string[], trackGenres: string[] ): number => { - const sourceSet = new Set([...sourceTags, ...sourceGenres].map(t => t.toLowerCase())); - const trackSet = new Set([...trackTags, ...trackGenres].map(t => t.toLowerCase())); - if (sourceSet.size === 0 || trackSet.size === 0) return 0; - const overlap = [...sourceSet].filter(tag => trackSet.has(tag)).length; + const sourceSet = new Set( + [...sourceTags, ...sourceGenres].map((t) => + t.toLowerCase() + ) + ); + const trackSet = new Set( + [...trackTags, ...trackGenres].map((t) => + t.toLowerCase() + ) + ); + if (sourceSet.size === 0 || trackSet.size === 0) + return 0; + const overlap = [...sourceSet].filter((tag) => + trackSet.has(tag) + ).length; // Max 5% bonus for tag overlap return Math.min(0.05, overlap * 0.01); }; - + // Build source feature vector once const sourceVector = buildFeatureVector(sourceTrack); - + // Check if source track has Enhanced mode data const bothEnhanced = isEnhancedAnalysis; - - const scored = analyzedTracks.map(t => { + + const scored = analyzedTracks.map((t) => { // Check if target track has Enhanced mode data - const targetEnhanced = t.analysisMode === 'enhanced' || + const targetEnhanced = + t.analysisMode === "enhanced" || (t.moodHappy !== null && t.moodSad !== null); const useEnhanced = bothEnhanced && targetEnhanced; - + // Build target feature vector const targetVector = buildFeatureVector(t as any); - + // Compute base cosine similarity - let score = cosineSimilarity(sourceVector, targetVector); - + let score = cosineSimilarity( + sourceVector, + targetVector + ); + // Add tag/genre overlap bonus (max 5%) const tagBonus = computeTagBonus( sourceTrack.lastfmTags || [], @@ -3571,87 +3996,135 @@ router.get("/radio", async (req, res) => { t.lastfmTags || [], t.essentiaGenres || [] ); - + // Final score: 95% cosine similarity + 5% tag bonus const finalScore = score * 0.95 + tagBonus; - - return { id: t.id, score: finalScore, enhanced: useEnhanced }; + + return { + id: t.id, + score: finalScore, + enhanced: useEnhanced, + }; }); - + // Filter to good matches and sort by score // Use lower threshold (40%) for Enhanced mode since it's more precise - const minThreshold = isEnhancedAnalysis ? 0.40 : 0.50; + const minThreshold = isEnhancedAnalysis ? 0.4 : 0.5; const goodMatches = scored - .filter(t => t.score > minThreshold) + .filter((t) => t.score > minThreshold) .sort((a, b) => b.score - a.score); - - vibeMatchedIds = goodMatches.map(t => t.id); - const enhancedCount = goodMatches.filter(t => t.enhanced).length; - console.log(`[Radio:vibe] Audio matching found ${vibeMatchedIds.length} tracks (>${minThreshold * 100}% similarity)`); - console.log(`[Radio:vibe] Enhanced matches: ${enhancedCount}, Standard matches: ${goodMatches.length - enhancedCount}`); - + + vibeMatchedIds = goodMatches.map((t) => t.id); + const enhancedCount = goodMatches.filter( + (t) => t.enhanced + ).length; + console.log( + `[Radio:vibe] Audio matching found ${ + vibeMatchedIds.length + } tracks (>${minThreshold * 100}% similarity)` + ); + console.log( + `[Radio:vibe] Enhanced matches: ${enhancedCount}, Standard matches: ${ + goodMatches.length - enhancedCount + }` + ); + if (goodMatches.length > 0) { - console.log(`[Radio:vibe] Top match score: ${goodMatches[0].score.toFixed(2)} (${goodMatches[0].enhanced ? 'enhanced' : 'standard'})`); + console.log( + `[Radio:vibe] Top match score: ${goodMatches[0].score.toFixed( + 2 + )} (${ + goodMatches[0].enhanced + ? "enhanced" + : "standard" + })` + ); } } } - + // 3. Fallback A: Same artist's other tracks if (vibeMatchedIds.length < limitNum) { const artistTracks = await prisma.track.findMany({ - where: { + where: { album: { artistId: sourceArtistId }, id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, }, select: { id: true }, }); - const newIds = artistTracks.map(t => t.id); + const newIds = artistTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; - console.log(`[Radio:vibe] Fallback A (same artist): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`); + console.log( + `[Radio:vibe] Fallback A (same artist): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` + ); } - + // 4. Fallback B: Similar artists from Last.fm (filtered to library) if (vibeMatchedIds.length < limitNum) { const ownedArtistIds = await prisma.ownedAlbum.findMany({ select: { artistId: true }, - distinct: ['artistId'], + distinct: ["artistId"], }); - const libraryArtistSet = new Set(ownedArtistIds.map(o => o.artistId)); + const libraryArtistSet = new Set( + ownedArtistIds.map((o) => o.artistId) + ); libraryArtistSet.delete(sourceArtistId); - + const similarArtists = await prisma.similarArtist.findMany({ - where: { + where: { fromArtistId: sourceArtistId, toArtistId: { in: Array.from(libraryArtistSet) }, }, orderBy: { weight: "desc" }, take: 10, }); - + if (similarArtists.length > 0) { - const similarArtistTracks = await prisma.track.findMany({ - where: { - album: { artistId: { in: similarArtists.map(s => s.toArtistId) } }, - id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, - }, - select: { id: true }, - }); - const newIds = similarArtistTracks.map(t => t.id); + const similarArtistTracks = await prisma.track.findMany( + { + where: { + album: { + artistId: { + in: similarArtists.map( + (s) => s.toArtistId + ), + }, + }, + id: { + notIn: [ + sourceTrackId, + ...vibeMatchedIds, + ], + }, + }, + select: { id: true }, + } + ); + const newIds = similarArtistTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; - console.log(`[Radio:vibe] Fallback B (similar artists): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`); + console.log( + `[Radio:vibe] Fallback B (similar artists): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` + ); } } - + // 5. Fallback C: Same genre (using TrackGenre relation) - const sourceGenres = (sourceTrack.album.genres as string[]) || []; - if (vibeMatchedIds.length < limitNum && sourceGenres.length > 0) { + const sourceGenres = + (sourceTrack.album.genres as string[]) || []; + if ( + vibeMatchedIds.length < limitNum && + sourceGenres.length > 0 + ) { // Search using the TrackGenre relation for better accuracy const genreTracks = await prisma.track.findMany({ where: { trackGenres: { some: { genre: { - name: { in: sourceGenres, mode: "insensitive" }, + name: { + in: sourceGenres, + mode: "insensitive", + }, }, }, }, @@ -3660,25 +4133,33 @@ router.get("/radio", async (req, res) => { select: { id: true }, take: limitNum, }); - const newIds = genreTracks.map(t => t.id); + const newIds = genreTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; - console.log(`[Radio:vibe] Fallback C (same genre): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`); + console.log( + `[Radio:vibe] Fallback C (same genre): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` + ); } - + // 6. Fallback D: Random from library if (vibeMatchedIds.length < limitNum) { const randomTracks = await prisma.track.findMany({ - where: { id: { notIn: [sourceTrackId, ...vibeMatchedIds] } }, + where: { + id: { notIn: [sourceTrackId, ...vibeMatchedIds] }, + }, select: { id: true }, take: limitNum - vibeMatchedIds.length, }); - const newIds = randomTracks.map(t => t.id); + const newIds = randomTracks.map((t) => t.id); vibeMatchedIds = [...vibeMatchedIds, ...newIds]; - console.log(`[Radio:vibe] Fallback D (random): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`); + console.log( + `[Radio:vibe] Fallback D (random): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}` + ); } - + trackIds = vibeMatchedIds; - console.log(`[Radio:vibe] Final vibe queue: ${trackIds.length} tracks`); + console.log( + `[Radio:vibe] Final vibe queue: ${trackIds.length} tracks` + ); break; case "all": @@ -3687,14 +4168,15 @@ router.get("/radio", async (req, res) => { const allTracks = await prisma.track.findMany({ select: { id: true }, }); - trackIds = allTracks.map(t => t.id); + trackIds = allTracks.map((t) => t.id); } // For vibe mode, keep the sorted order (by match score) // For other modes, shuffle the results - const finalIds = type === "vibe" - ? trackIds.slice(0, limitNum) // Already sorted by match score - : trackIds.sort(() => Math.random() - 0.5).slice(0, limitNum); + const finalIds = + type === "vibe" + ? trackIds.slice(0, limitNum) // Already sorted by match score + : trackIds.sort(() => Math.random() - 0.5).slice(0, limitNum); if (finalIds.length === 0) { return res.json({ tracks: [] }); @@ -3723,72 +4205,155 @@ router.get("/radio", async (req, res) => { }, }, }); - + // For vibe mode, reorder tracks to match the sorted finalIds order // (Prisma's findMany with IN doesn't preserve order) let orderedTracks = tracks; if (type === "vibe") { - const trackMap = new Map(tracks.map(t => [t.id, t])); + const trackMap = new Map(tracks.map((t) => [t.id, t])); orderedTracks = finalIds - .map(id => trackMap.get(id)) - .filter((t): t is typeof tracks[0] => t !== undefined); + .map((id) => trackMap.get(id)) + .filter((t): t is (typeof tracks)[0] => t !== undefined); } - + // === VIBE QUEUE LOGGING === // Log detailed info for vibe matching analysis (using ordered tracks) if (type === "vibe" && vibeSourceFeatures) { console.log("\n" + "=".repeat(100)); console.log("VIBE QUEUE ANALYSIS - Source Track"); console.log("=".repeat(100)); - + // Find source track for logging const srcTrack = await prisma.track.findUnique({ where: { id: value as string }, include: { album: { include: { artist: { select: { name: true } } } }, - trackGenres: { include: { genre: { select: { name: true } } } }, + trackGenres: { + include: { genre: { select: { name: true } } }, + }, }, }); - + if (srcTrack) { - console.log(`SOURCE: "${srcTrack.title}" by ${srcTrack.album.artist.name}`); + console.log( + `SOURCE: "${srcTrack.title}" by ${srcTrack.album.artist.name}` + ); console.log(` Album: ${srcTrack.album.title}`); - console.log(` Analysis Mode: ${(srcTrack as any).analysisMode || 'unknown'}`); - console.log(` BPM: ${srcTrack.bpm?.toFixed(1) || 'N/A'} | Energy: ${srcTrack.energy?.toFixed(2) || 'N/A'} | Valence: ${srcTrack.valence?.toFixed(2) || 'N/A'}`); - console.log(` Danceability: ${srcTrack.danceability?.toFixed(2) || 'N/A'} | Arousal: ${srcTrack.arousal?.toFixed(2) || 'N/A'} | Key: ${srcTrack.keyScale || 'N/A'}`); - console.log(` ML Moods: Happy=${(srcTrack as any).moodHappy?.toFixed(2) || 'N/A'}, Sad=${(srcTrack as any).moodSad?.toFixed(2) || 'N/A'}, Relaxed=${(srcTrack as any).moodRelaxed?.toFixed(2) || 'N/A'}, Aggressive=${(srcTrack as any).moodAggressive?.toFixed(2) || 'N/A'}`); - console.log(` Genres: ${srcTrack.trackGenres.map(tg => tg.genre.name).join(', ') || 'N/A'}`); - console.log(` Last.fm Tags: ${((srcTrack as any).lastfmTags || []).join(', ') || 'N/A'}`); - console.log(` Mood Tags: ${((srcTrack as any).moodTags || []).join(', ') || 'N/A'}`); + console.log( + ` Analysis Mode: ${ + (srcTrack as any).analysisMode || "unknown" + }` + ); + console.log( + ` BPM: ${srcTrack.bpm?.toFixed(1) || "N/A"} | Energy: ${ + srcTrack.energy?.toFixed(2) || "N/A" + } | Valence: ${srcTrack.valence?.toFixed(2) || "N/A"}` + ); + console.log( + ` Danceability: ${ + srcTrack.danceability?.toFixed(2) || "N/A" + } | Arousal: ${ + srcTrack.arousal?.toFixed(2) || "N/A" + } | Key: ${srcTrack.keyScale || "N/A"}` + ); + console.log( + ` ML Moods: Happy=${ + (srcTrack as any).moodHappy?.toFixed(2) || "N/A" + }, Sad=${ + (srcTrack as any).moodSad?.toFixed(2) || "N/A" + }, Relaxed=${ + (srcTrack as any).moodRelaxed?.toFixed(2) || "N/A" + }, Aggressive=${ + (srcTrack as any).moodAggressive?.toFixed(2) || "N/A" + }` + ); + console.log( + ` Genres: ${ + srcTrack.trackGenres + .map((tg) => tg.genre.name) + .join(", ") || "N/A" + }` + ); + console.log( + ` Last.fm Tags: ${ + ((srcTrack as any).lastfmTags || []).join(", ") || "N/A" + }` + ); + console.log( + ` Mood Tags: ${ + ((srcTrack as any).moodTags || []).join(", ") || "N/A" + }` + ); } - + console.log("\n" + "-".repeat(100)); - console.log(`VIBE QUEUE - ${orderedTracks.length} tracks (showing up to 50, SORTED BY MATCH SCORE)`); + console.log( + `VIBE QUEUE - ${orderedTracks.length} tracks (showing up to 50, SORTED BY MATCH SCORE)` + ); console.log("-".repeat(100)); - console.log(`${"#".padEnd(3)} | ${"TRACK".padEnd(35)} | ${"ARTIST".padEnd(20)} | ${"BPM".padEnd(6)} | ${"ENG".padEnd(5)} | ${"VAL".padEnd(5)} | ${"H".padEnd(4)} | ${"S".padEnd(4)} | ${"R".padEnd(4)} | ${"A".padEnd(4)} | MODE | GENRES`); + console.log( + `${"#".padEnd(3)} | ${"TRACK".padEnd(35)} | ${"ARTIST".padEnd( + 20 + )} | ${"BPM".padEnd(6)} | ${"ENG".padEnd(5)} | ${"VAL".padEnd( + 5 + )} | ${"H".padEnd(4)} | ${"S".padEnd(4)} | ${"R".padEnd( + 4 + )} | ${"A".padEnd(4)} | MODE | GENRES` + ); console.log("-".repeat(100)); - + orderedTracks.slice(0, 50).forEach((track, i) => { const t = track as any; const title = track.title.substring(0, 33).padEnd(35); - const artist = track.album.artist.name.substring(0, 18).padEnd(20); - const bpm = track.bpm ? track.bpm.toFixed(0).padEnd(6) : "N/A".padEnd(6); - const energy = track.energy !== null ? track.energy.toFixed(2).padEnd(5) : "N/A".padEnd(5); - const valence = track.valence !== null ? track.valence.toFixed(2).padEnd(5) : "N/A".padEnd(5); - const happy = t.moodHappy !== null ? t.moodHappy.toFixed(2).padEnd(4) : "N/A".padEnd(4); - const sad = t.moodSad !== null ? t.moodSad.toFixed(2).padEnd(4) : "N/A".padEnd(4); - const relaxed = t.moodRelaxed !== null ? t.moodRelaxed.toFixed(2).padEnd(4) : "N/A".padEnd(4); - const aggressive = t.moodAggressive !== null ? t.moodAggressive.toFixed(2).padEnd(4) : "N/A".padEnd(4); - const mode = (t.analysisMode || "std").substring(0, 7).padEnd(8); - const genres = track.trackGenres.slice(0, 3).map(tg => tg.genre.name).join(", "); - - console.log(`${String(i + 1).padEnd(3)} | ${title} | ${artist} | ${bpm} | ${energy} | ${valence} | ${happy} | ${sad} | ${relaxed} | ${aggressive} | ${mode} | ${genres}`); + const artist = track.album.artist.name + .substring(0, 18) + .padEnd(20); + const bpm = track.bpm + ? track.bpm.toFixed(0).padEnd(6) + : "N/A".padEnd(6); + const energy = + track.energy !== null + ? track.energy.toFixed(2).padEnd(5) + : "N/A".padEnd(5); + const valence = + track.valence !== null + ? track.valence.toFixed(2).padEnd(5) + : "N/A".padEnd(5); + const happy = + t.moodHappy !== null + ? t.moodHappy.toFixed(2).padEnd(4) + : "N/A".padEnd(4); + const sad = + t.moodSad !== null + ? t.moodSad.toFixed(2).padEnd(4) + : "N/A".padEnd(4); + const relaxed = + t.moodRelaxed !== null + ? t.moodRelaxed.toFixed(2).padEnd(4) + : "N/A".padEnd(4); + const aggressive = + t.moodAggressive !== null + ? t.moodAggressive.toFixed(2).padEnd(4) + : "N/A".padEnd(4); + const mode = (t.analysisMode || "std") + .substring(0, 7) + .padEnd(8); + const genres = track.trackGenres + .slice(0, 3) + .map((tg) => tg.genre.name) + .join(", "); + + console.log( + `${String(i + 1).padEnd( + 3 + )} | ${title} | ${artist} | ${bpm} | ${energy} | ${valence} | ${happy} | ${sad} | ${relaxed} | ${aggressive} | ${mode} | ${genres}` + ); }); - + if (orderedTracks.length > 50) { console.log(`... and ${orderedTracks.length - 50} more tracks`); } - + console.log("=".repeat(100) + "\n"); } @@ -3832,16 +4397,17 @@ router.get("/radio", async (req, res) => { })); // For vibe mode, keep sorted order. For other modes, shuffle. - const finalTracks = type === "vibe" - ? transformedTracks - : transformedTracks.sort(() => Math.random() - 0.5); + const finalTracks = + type === "vibe" + ? transformedTracks + : transformedTracks.sort(() => Math.random() - 0.5); // Include source features if this was a vibe request const response: any = { tracks: finalTracks }; if (vibeSourceFeatures) { response.sourceFeatures = vibeSourceFeatures; } - + res.json(response); } catch (error) { console.error("Radio endpoint error:", error); diff --git a/backend/src/routes/mixes.ts b/backend/src/routes/mixes.ts index f6919e5..c6406d9 100644 --- a/backend/src/routes/mixes.ts +++ b/backend/src/routes/mixes.ts @@ -1,6 +1,11 @@ import { Router } from "express"; import { requireAuthOrToken } from "../middleware/auth"; import { programmaticPlaylistService } from "../services/programmaticPlaylists"; +import { + moodBucketService, + VALID_MOODS, + MoodType, +} from "../services/moodBucketService"; import { prisma } from "../utils/db"; import { redisClient } from "../utils/redis"; @@ -182,24 +187,43 @@ router.post("/mood", async (req, res) => { // Validate parameters const validKeys = [ // Basic audio features - 'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale', + "valence", + "energy", + "danceability", + "acousticness", + "instrumentalness", + "arousal", + "bpm", + "keyScale", // ML mood predictions - 'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic', + "moodHappy", + "moodSad", + "moodRelaxed", + "moodAggressive", + "moodParty", + "moodAcoustic", + "moodElectronic", // Other - 'limit' + "limit", ]; for (const key of Object.keys(params)) { if (!validKeys.includes(key)) { - return res.status(400).json({ error: `Invalid parameter: ${key}` }); + return res + .status(400) + .json({ error: `Invalid parameter: ${key}` }); } } - const mix = await programmaticPlaylistService.generateMoodOnDemand(userId, params); + const mix = await programmaticPlaylistService.generateMoodOnDemand( + userId, + params + ); if (!mix) { return res.status(400).json({ error: "Not enough tracks matching your criteria", - suggestion: "Try widening your parameters or wait for more tracks to be analyzed" + suggestion: + "Try widening your parameters or wait for more tracks to be analyzed", }); } @@ -228,7 +252,9 @@ router.post("/mood", async (req, res) => { .map((id: string) => tracks.find((t) => t.id === id)) .filter((t: any) => t !== undefined); - console.log(`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`); + console.log( + `[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks` + ); res.json({ ...mix, @@ -251,73 +277,123 @@ router.get("/mood/presets", async (req, res) => { id: "happy", name: "Happy & Upbeat", color: "from-yellow-400 to-orange-500", - params: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 }, energy: { min: 0.4 } }, + params: { + moodHappy: { min: 0.5 }, + moodSad: { max: 0.4 }, + energy: { min: 0.4 }, + }, }, { id: "sad", name: "Melancholic", color: "from-blue-600 to-indigo-700", - params: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 }, keyScale: "minor" }, + params: { + moodSad: { min: 0.5 }, + moodHappy: { max: 0.4 }, + keyScale: "minor", + }, }, { id: "chill", name: "Chill & Relaxed", color: "from-teal-400 to-cyan-500", - params: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 }, energy: { max: 0.55 } }, + params: { + moodRelaxed: { min: 0.5 }, + moodAggressive: { max: 0.3 }, + energy: { max: 0.55 }, + }, }, { id: "energetic", name: "High Energy", color: "from-red-500 to-orange-600", - params: { arousal: { min: 0.6 }, energy: { min: 0.65 }, moodRelaxed: { max: 0.4 } }, + params: { + arousal: { min: 0.6 }, + energy: { min: 0.65 }, + moodRelaxed: { max: 0.4 }, + }, }, { id: "focus", name: "Focus Mode", color: "from-purple-600 to-violet-700", - params: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 }, energy: { min: 0.2, max: 0.6 } }, + params: { + instrumentalness: { min: 0.5 }, + moodRelaxed: { min: 0.3 }, + energy: { min: 0.2, max: 0.6 }, + }, }, { id: "dance", name: "Dance Party", color: "from-pink-500 to-rose-600", - params: { moodParty: { min: 0.5 }, danceability: { min: 0.6 }, energy: { min: 0.5 } }, + params: { + moodParty: { min: 0.5 }, + danceability: { min: 0.6 }, + energy: { min: 0.5 }, + }, }, { id: "acoustic", name: "Acoustic Vibes", color: "from-amber-500 to-yellow-600", - params: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } }, + params: { + moodAcoustic: { min: 0.5 }, + moodElectronic: { max: 0.4 }, + }, }, { id: "dark", name: "Dark & Moody", color: "from-gray-700 to-slate-800", - params: { moodAggressive: { min: 0.4 }, moodHappy: { max: 0.4 }, keyScale: "minor" }, + params: { + moodAggressive: { min: 0.4 }, + moodHappy: { max: 0.4 }, + keyScale: "minor", + }, }, { id: "romantic", name: "Romantic", color: "from-rose-500 to-pink-600", - params: { moodRelaxed: { min: 0.3 }, moodAggressive: { max: 0.3 }, acousticness: { min: 0.3 }, energy: { max: 0.6 } }, + params: { + moodRelaxed: { min: 0.3 }, + moodAggressive: { max: 0.3 }, + acousticness: { min: 0.3 }, + energy: { max: 0.6 }, + }, }, { id: "workout", name: "Workout Beast", color: "from-green-500 to-emerald-600", - params: { arousal: { min: 0.6 }, energy: { min: 0.7 }, moodRelaxed: { max: 0.4 }, bpm: { min: 110 } }, + params: { + arousal: { min: 0.6 }, + energy: { min: 0.7 }, + moodRelaxed: { max: 0.4 }, + bpm: { min: 110 }, + }, }, { id: "sleepy", name: "Sleep & Unwind", color: "from-indigo-400 to-purple-500", - params: { moodRelaxed: { min: 0.5 }, energy: { max: 0.35 }, moodAggressive: { max: 0.2 } }, + params: { + moodRelaxed: { min: 0.5 }, + energy: { max: 0.35 }, + moodAggressive: { max: 0.2 }, + }, }, { id: "confident", name: "Confidence Boost", color: "from-amber-400 to-orange-500", - params: { moodHappy: { min: 0.4 }, moodParty: { min: 0.3 }, energy: { min: 0.5 }, danceability: { min: 0.5 } }, + params: { + moodHappy: { min: 0.4 }, + moodParty: { min: 0.3 }, + energy: { min: 0.5 }, + danceability: { min: 0.5 }, + }, }, ]; @@ -339,13 +415,15 @@ router.post("/mood/save-preferences", async (req, res) => { // Validate that at least some params are provided if (!params || Object.keys(params).length === 0) { - return res.status(400).json({ error: "No mood parameters provided" }); + return res + .status(400) + .json({ error: "No mood parameters provided" }); } // Save to user record await prisma.user.update({ where: { id: userId }, - data: { moodMixParams: params } + data: { moodMixParams: params }, }); // Invalidate mix cache so the new mood mix appears @@ -361,6 +439,234 @@ router.post("/mood/save-preferences", async (req, res) => { } }); +// ============================================ +// NEW SIMPLIFIED MOOD BUCKET ENDPOINTS +// ============================================ + +/** + * @openapi + * /mixes/mood/buckets/presets: + * get: + * summary: Get mood presets with track counts + * description: Returns available mood categories with how many tracks are available for each + * tags: [Mixes] + * security: + * - sessionAuth: [] + * - apiKeyAuth: [] + * responses: + * 200: + * description: List of mood presets with track counts + */ +router.get("/mood/buckets/presets", async (req, res) => { + try { + const presets = await moodBucketService.getMoodPresets(); + res.json(presets); + } catch (error) { + console.error("Get mood presets error:", error); + res.status(500).json({ error: "Failed to get mood presets" }); + } +}); + +/** + * @openapi + * /mixes/mood/buckets/{mood}: + * get: + * summary: Get a mood mix for a specific mood + * description: Fast lookup from pre-computed mood bucket table + * tags: [Mixes] + * security: + * - sessionAuth: [] + * - apiKeyAuth: [] + * parameters: + * - in: path + * name: mood + * required: true + * schema: + * type: string + * enum: [happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic] + * description: Mood category + * responses: + * 200: + * description: Mood mix with track details + * 400: + * description: Invalid mood or not enough tracks + */ +router.get("/mood/buckets/:mood", async (req, res) => { + try { + const mood = req.params.mood as MoodType; + + if (!VALID_MOODS.includes(mood)) { + return res.status(400).json({ + error: `Invalid mood: ${mood}`, + validMoods: VALID_MOODS, + }); + } + + const mix = await moodBucketService.getMoodMix(mood); + + if (!mix) { + return res.status(400).json({ + error: `Not enough tracks for mood: ${mood}`, + suggestion: "Wait for more tracks to be analyzed", + }); + } + + // Load full track details + const tracks = await prisma.track.findMany({ + where: { id: { in: mix.trackIds } }, + include: { + album: { + include: { + artist: { + select: { id: true, name: true, mbid: true }, + }, + }, + }, + }, + }); + + // Preserve mix order + const orderedTracks = mix.trackIds + .map((id: string) => tracks.find((t) => t.id === id)) + .filter((t: any) => t !== undefined); + + res.json({ + ...mix, + tracks: orderedTracks, + }); + } catch (error) { + console.error("Get mood bucket mix error:", error); + res.status(500).json({ error: "Failed to get mood mix" }); + } +}); + +/** + * @openapi + * /mixes/mood/buckets/{mood}/save: + * post: + * summary: Save a mood as user's active mood mix + * description: Generates a mix for the mood and saves it as the user's "Your X Mix" on the home page + * tags: [Mixes] + * security: + * - sessionAuth: [] + * - apiKeyAuth: [] + * parameters: + * - in: path + * name: mood + * required: true + * schema: + * type: string + * enum: [happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic] + * responses: + * 200: + * description: Mood mix saved and returned for immediate playback + * 400: + * description: Invalid mood or not enough tracks + */ +router.post("/mood/buckets/:mood/save", async (req, res) => { + try { + const userId = getRequestUserId(req); + if (!userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + + const mood = req.params.mood as MoodType; + + if (!VALID_MOODS.includes(mood)) { + return res.status(400).json({ + error: `Invalid mood: ${mood}`, + validMoods: VALID_MOODS, + }); + } + + const savedMix = await moodBucketService.saveUserMoodMix(userId, mood); + + if (!savedMix) { + return res.status(400).json({ + error: `Not enough tracks for mood: ${mood}`, + suggestion: "Wait for more tracks to be analyzed", + }); + } + + // Invalidate mixes cache so home page refetches + const cacheKey = `mixes:${userId}`; + await redisClient.del(cacheKey); + + // Load full track details for immediate playback + const tracks = await prisma.track.findMany({ + where: { id: { in: savedMix.trackIds } }, + include: { + album: { + include: { + artist: { + select: { id: true, name: true, mbid: true }, + }, + }, + }, + }, + }); + + // Preserve mix order + const orderedTracks = savedMix.trackIds + .map((id: string) => tracks.find((t) => t.id === id)) + .filter((t: any) => t !== undefined); + + console.log( + `[MIXES] Saved mood bucket mix for user ${userId}: ${mood} (${savedMix.trackCount} tracks)` + ); + + res.json({ + success: true, + mix: { + ...savedMix, + tracks: orderedTracks, + }, + }); + } catch (error) { + console.error("Save mood bucket mix error:", error); + res.status(500).json({ error: "Failed to save mood mix" }); + } +}); + +/** + * @openapi + * /mixes/mood/buckets/backfill: + * post: + * summary: Backfill mood buckets for all analyzed tracks + * description: Admin endpoint to populate mood buckets for existing tracks + * tags: [Mixes] + * security: + * - sessionAuth: [] + * - apiKeyAuth: [] + * responses: + * 200: + * description: Backfill completed + */ +router.post("/mood/buckets/backfill", async (req, res) => { + try { + const userId = getRequestUserId(req); + if (!userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + + // TODO: Add admin check + console.log( + `[MIXES] Starting mood bucket backfill requested by user ${userId}` + ); + + const result = await moodBucketService.backfillAllTracks(); + + res.json({ + success: true, + processed: result.processed, + assigned: result.assigned, + }); + } catch (error) { + console.error("Backfill mood buckets error:", error); + res.status(500).json({ error: "Failed to backfill mood buckets" }); + } +}); + /** * @openapi * /mixes/refresh: diff --git a/backend/src/services/moodBucketService.ts b/backend/src/services/moodBucketService.ts new file mode 100644 index 0000000..c21f1d9 --- /dev/null +++ b/backend/src/services/moodBucketService.ts @@ -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 = { + 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 { + 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 { + const isEnhanced = track.analysisMode === "enhanced"; + const scores: Record = { + 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 + ): 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 { + await prisma.moodBucket.deleteMany({ + where: { trackId }, + }); + } +} + +export const moodBucketService = new MoodBucketService(); diff --git a/backend/src/services/programmaticPlaylists.ts b/backend/src/services/programmaticPlaylists.ts index 1d626b0..8d5aea2 100644 --- a/backend/src/services/programmaticPlaylists.ts +++ b/backend/src/services/programmaticPlaylists.ts @@ -1,5 +1,6 @@ import { prisma } from "../utils/db"; import { lastFmService } from "./lastfm"; +import { moodBucketService } from "./moodBucketService"; export interface ProgrammaticMix { id: string; @@ -16,64 +17,91 @@ export interface ProgrammaticMix { // Using actual CSS rgba values for inline styles (Tailwind classes get purged at build time) const MIX_COLORS: Record = { // Night/Introspection - Deep blues and purples for calm, night sky, solitude - "late-night": "linear-gradient(to bottom, rgba(30, 27, 75, 0.7), rgba(30, 58, 138, 0.5), rgba(15, 23, 42, 0.4))", - "3am-thoughts": "linear-gradient(to bottom, rgba(46, 16, 101, 0.7), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", - "night-drive": "linear-gradient(to bottom, rgba(15, 23, 42, 0.7), rgba(49, 46, 129, 0.5), rgba(88, 28, 135, 0.4))", - + "late-night": + "linear-gradient(to bottom, rgba(30, 27, 75, 0.7), rgba(30, 58, 138, 0.5), rgba(15, 23, 42, 0.4))", + "3am-thoughts": + "linear-gradient(to bottom, rgba(46, 16, 101, 0.7), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + "night-drive": + "linear-gradient(to bottom, rgba(15, 23, 42, 0.7), rgba(49, 46, 129, 0.5), rgba(88, 28, 135, 0.4))", + // Calm/Relaxation - Teal and seafoam for spa-like tranquility - "chill": "linear-gradient(to bottom, rgba(17, 94, 89, 0.6), rgba(22, 78, 99, 0.5), rgba(15, 23, 42, 0.4))", - "coffee-shop": "linear-gradient(to bottom, rgba(120, 53, 15, 0.6), rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4))", - "rainy-day": "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(31, 41, 55, 0.5), rgba(39, 39, 42, 0.4))", - "sunday-morning": "linear-gradient(to bottom, rgba(253, 186, 116, 0.4), rgba(252, 211, 77, 0.3), rgba(68, 64, 60, 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))", + "coffee-shop": + "linear-gradient(to bottom, rgba(120, 53, 15, 0.6), rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4))", + "rainy-day": + "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(31, 41, 55, 0.5), rgba(39, 39, 42, 0.4))", + "sunday-morning": + "linear-gradient(to bottom, rgba(253, 186, 116, 0.4), rgba(252, 211, 77, 0.3), rgba(68, 64, 60, 0.4))", + // Energy/Workout - Red and orange to increase heart rate - "workout": "linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", - "confidence-boost": "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(68, 64, 60, 0.4))", - + workout: + "linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", + "confidence-boost": + "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(68, 64, 60, 0.4))", + // Happy/Uplifting - Yellow and warm amber for optimism - "happy": "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))", - "summer-vibes": "linear-gradient(to bottom, rgba(8, 145, 178, 0.5), rgba(15, 118, 110, 0.4), rgba(30, 58, 138, 0.4))", - "golden-hour": "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(234, 88, 12, 0.4), rgba(136, 19, 55, 0.4))", - + happy: "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))", + "summer-vibes": + "linear-gradient(to bottom, rgba(8, 145, 178, 0.5), rgba(15, 118, 110, 0.4), rgba(30, 58, 138, 0.4))", + "golden-hour": + "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(234, 88, 12, 0.4), rgba(136, 19, 55, 0.4))", + // Sad/Melancholy - Cool blue-grays for "feeling blue" - "melancholy": "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(30, 58, 138, 0.5), rgba(17, 24, 39, 0.4))", - "sad-girl-sundays": "linear-gradient(to bottom, rgba(136, 19, 55, 0.5), rgba(30, 41, 59, 0.5), rgba(59, 7, 100, 0.4))", - "heartbreak-hotel": "linear-gradient(to bottom, rgba(30, 58, 138, 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))", + "sad-girl-sundays": + "linear-gradient(to bottom, rgba(136, 19, 55, 0.5), rgba(30, 41, 59, 0.5), rgba(59, 7, 100, 0.4))", + "heartbreak-hotel": + "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + // Party/Dance - Hot pink and magenta for club energy - "dance-floor": "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))", - + "dance-floor": + "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))", + // Acoustic/Organic - Warm browns like wood instruments - "acoustic": "linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", - "unplugged": "linear-gradient(to bottom, rgba(68, 64, 60, 0.6), rgba(120, 53, 15, 0.5), rgba(38, 38, 38, 0.4))", - + acoustic: + "linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", + unplugged: + "linear-gradient(to bottom, rgba(68, 64, 60, 0.6), rgba(120, 53, 15, 0.5), rgba(38, 38, 38, 0.4))", + // Focus/Instrumental - Purple for creativity and concentration - "instrumental": "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", - "focus-flow": "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(30, 41, 59, 0.5), rgba(17, 24, 39, 0.4))", - + instrumental: + "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + "focus-flow": + "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(30, 41, 59, 0.5), rgba(17, 24, 39, 0.4))", + // Adventure/Road Trip - Sunset oranges for freedom - "road-trip": "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(14, 165, 233, 0.4))", - + "road-trip": + "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(14, 165, 233, 0.4))", + // Character/Mood Archetypes - "main-character": "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(202, 138, 4, 0.4), rgba(124, 45, 18, 0.4))", - "villain-era": "linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))", - + "main-character": + "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(202, 138, 4, 0.4), rgba(124, 45, 18, 0.4))", + "villain-era": + "linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))", + // Nostalgia - Sepia and vintage tones - "throwback": "linear-gradient(to bottom, rgba(146, 64, 14, 0.5), rgba(124, 45, 18, 0.4), rgba(68, 64, 60, 0.4))", - + throwback: + "linear-gradient(to bottom, rgba(146, 64, 14, 0.5), rgba(124, 45, 18, 0.4), rgba(68, 64, 60, 0.4))", + // Genre/Era based - More neutral but themed - "era": "linear-gradient(to bottom, rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4), rgba(39, 39, 42, 0.4))", - "genre": "linear-gradient(to bottom, rgba(63, 63, 70, 0.5), rgba(30, 41, 59, 0.4), rgba(17, 24, 39, 0.4))", - "top-tracks": "linear-gradient(to bottom, rgba(6, 95, 70, 0.5), rgba(17, 94, 89, 0.4), rgba(15, 23, 42, 0.4))", - "rediscover": "linear-gradient(to bottom, rgba(55, 48, 163, 0.5), rgba(76, 29, 149, 0.4), rgba(15, 23, 42, 0.4))", - "artist-similar": "linear-gradient(to bottom, rgba(107, 33, 168, 0.5), rgba(112, 26, 117, 0.4), rgba(15, 23, 42, 0.4))", - "discovery": "linear-gradient(to bottom, rgba(2, 132, 199, 0.5), rgba(30, 58, 138, 0.4), rgba(15, 23, 42, 0.4))", - + era: "linear-gradient(to bottom, rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4), rgba(39, 39, 42, 0.4))", + genre: "linear-gradient(to bottom, rgba(63, 63, 70, 0.5), rgba(30, 41, 59, 0.4), rgba(17, 24, 39, 0.4))", + "top-tracks": + "linear-gradient(to bottom, rgba(6, 95, 70, 0.5), rgba(17, 94, 89, 0.4), rgba(15, 23, 42, 0.4))", + rediscover: + "linear-gradient(to bottom, rgba(55, 48, 163, 0.5), rgba(76, 29, 149, 0.4), rgba(15, 23, 42, 0.4))", + "artist-similar": + "linear-gradient(to bottom, rgba(107, 33, 168, 0.5), rgba(112, 26, 117, 0.4), rgba(15, 23, 42, 0.4))", + discovery: + "linear-gradient(to bottom, rgba(2, 132, 199, 0.5), rgba(30, 58, 138, 0.4), rgba(15, 23, 42, 0.4))", + // Mood-on-demand default - "mood": "linear-gradient(to bottom, rgba(162, 28, 175, 0.5), rgba(107, 33, 168, 0.4), rgba(15, 23, 42, 0.4))", - + mood: "linear-gradient(to bottom, rgba(162, 28, 175, 0.5), rgba(107, 33, 168, 0.4), rgba(15, 23, 42, 0.4))", + // Default fallback - "default": "linear-gradient(to bottom, rgba(88, 28, 135, 0.4), rgba(26, 26, 26, 1), transparent)", + default: + "linear-gradient(to bottom, rgba(88, 28, 135, 0.4), rgba(26, 26, 26, 1), transparent)", }; // Helper to get color for a mix type @@ -117,8 +145,8 @@ async function findTracksByGenrePatterns( limit: number = 100 ): Promise { // Strategy 1: Use track's lastfmTags and essentiaGenres (native String[] fields) - const tagPatterns = genrePatterns.map(g => g.toLowerCase()); - + const tagPatterns = genrePatterns.map((g) => g.toLowerCase()); + const tracks = await prisma.track.findMany({ where: { OR: [ @@ -146,30 +174,35 @@ async function findTracksByGenrePatterns( }); // Filter by genre patterns (case-insensitive partial match) - const genreMatched = albumTracks.filter(t => { + const genreMatched = albumTracks.filter((t) => { const albumGenres = t.album.genres as string[] | null; if (!albumGenres || !Array.isArray(albumGenres)) return false; - return albumGenres.some(ag => - genrePatterns.some(gp => ag.toLowerCase().includes(gp.toLowerCase())) + return albumGenres.some((ag) => + genrePatterns.some((gp) => + ag.toLowerCase().includes(gp.toLowerCase()) + ) ); }); // Merge unique tracks - const existingIds = new Set(tracks.map(t => t.id)); - const merged = [...tracks, ...genreMatched.filter(t => !existingIds.has(t.id))]; - + const existingIds = new Set(tracks.map((t) => t.id)); + const merged = [ + ...tracks, + ...genreMatched.filter((t) => !existingIds.has(t.id)), + ]; + return merged.slice(0, limit); } export class ProgrammaticPlaylistService { private readonly TRACK_LIMIT = 20; private readonly DAILY_MIX_COUNT = 5; - + // Track count thresholds for mix generation - private readonly MIN_TRACKS_DAILY = 8; // Minimum to generate a daily mix - private readonly MIN_TRACKS_WEEKLY = 15; // Minimum to generate a weekly mix - private readonly DAILY_TRACK_LIMIT = 10; // Daily mix size - private readonly WEEKLY_TRACK_LIMIT = 20; // Weekly mix size + private readonly MIN_TRACKS_DAILY = 8; // Minimum to generate a daily mix + private readonly MIN_TRACKS_WEEKLY = 15; // Minimum to generate a weekly mix + private readonly DAILY_TRACK_LIMIT = 10; // Daily mix size + private readonly WEEKLY_TRACK_LIMIT = 20; // Weekly mix size /** * Generate 4 daily rotating mixes @@ -247,7 +280,8 @@ export class ProgrammaticPlaylistService { }, // Audio analysis-based mixes (using Essentia features) { - fn: () => this.generateHighEnergyMix(userId, today + seedSuffix), + fn: () => + this.generateHighEnergyMix(userId, today + seedSuffix), weight: 2, name: "High Energy Mix", }, @@ -262,12 +296,14 @@ export class ProgrammaticPlaylistService { name: "Happy Vibes Mix", }, { - fn: () => this.generateMelancholyMix(userId, today + seedSuffix), + fn: () => + this.generateMelancholyMix(userId, today + seedSuffix), weight: 2, name: "Melancholy Mix", }, { - fn: () => this.generateDanceFloorMix(userId, today + seedSuffix), + fn: () => + this.generateDanceFloorMix(userId, today + seedSuffix), weight: 2, name: "Dance Floor Mix", }, @@ -277,7 +313,8 @@ export class ProgrammaticPlaylistService { name: "Acoustic Mix", }, { - fn: () => this.generateInstrumentalMix(userId, today + seedSuffix), + fn: () => + this.generateInstrumentalMix(userId, today + seedSuffix), weight: 2, name: "Instrumental Mix", }, @@ -294,12 +331,17 @@ export class ProgrammaticPlaylistService { }, // Curated Vibe Mixes (Daily, 10 tracks) { - fn: () => this.generateSadGirlSundays(userId, today + seedSuffix), + fn: () => + this.generateSadGirlSundays(userId, today + seedSuffix), weight: 2, name: "Sad Girl Sundays", }, { - fn: () => this.generateMainCharacterEnergy(userId, today + seedSuffix), + fn: () => + this.generateMainCharacterEnergy( + userId, + today + seedSuffix + ), weight: 2, name: "Main Character Energy", }, @@ -329,7 +371,8 @@ export class ProgrammaticPlaylistService { name: "Golden Hour", }, { - fn: () => this.generateShowerKaraoke(userId, today + seedSuffix), + fn: () => + this.generateShowerKaraoke(userId, today + seedSuffix), weight: 2, name: "Shower Karaoke", }, @@ -339,17 +382,23 @@ export class ProgrammaticPlaylistService { name: "In My Feelings", }, { - fn: () => this.generateMidnightDrive(userId, today + seedSuffix), + fn: () => + this.generateMidnightDrive(userId, today + seedSuffix), weight: 2, name: "Midnight Drive", }, { - fn: () => this.generateCoffeeShopVibes(userId, today + seedSuffix), + fn: () => + this.generateCoffeeShopVibes(userId, today + seedSuffix), weight: 2, name: "Coffee Shop Vibes", }, { - fn: () => this.generateRomanticizeYourLife(userId, today + seedSuffix), + fn: () => + this.generateRomanticizeYourLife( + userId, + today + seedSuffix + ), weight: 2, name: "Romanticize Your Life", }, @@ -478,35 +527,17 @@ export class ProgrammaticPlaylistService { console.log(`[MIXES] After fallbacks: ${finalMixes.length} mixes`); } - // Check if user has saved mood preferences and generate their personalized mood mix + // Check if user has saved mood mix from the new bucket system (fast lookup) try { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { moodMixParams: true } - }); - - if (user?.moodMixParams && typeof user.moodMixParams === 'object') { - const params = user.moodMixParams as any; - const presetName = params.presetName || "Mood"; - const mixName = `Your ${presetName} Mix`; - - console.log(`[MIXES] User has saved mood preferences, generating "${mixName}"...`); - const moodMix = await this.generateMoodOnDemand(userId, params); - if (moodMix) { - // Override the mix metadata with the preset name - const yourMoodMix: ProgrammaticMix = { - ...moodMix, - id: "your-mood-mix", - type: "mood", - name: mixName, - description: `Based on your ${presetName.toLowerCase()} preferences`, - }; - finalMixes.push(yourMoodMix); - console.log(`[MIXES] Added "${mixName}" with ${moodMix.trackCount} tracks`); - } + const savedMoodMix = await moodBucketService.getUserMoodMix(userId); + if (savedMoodMix) { + console.log( + `[MIXES] User has saved mood mix: "${savedMoodMix.name}" with ${savedMoodMix.trackCount} tracks` + ); + finalMixes.push(savedMoodMix); } } catch (err) { - console.error("[MIXES] Error generating user's mood mix:", err); + console.error("[MIXES] Error getting user's saved mood mix:", err); } return finalMixes; @@ -928,9 +959,22 @@ export class ProgrammaticPlaylistService { today: string ): Promise { const partyGenres = [ - "dance", "electronic", "pop", "disco", "house", "techno", "edm", - "funk", "electro", "dance pop", "club", "eurodance", "trance", - "dubstep", "drum and bass", "hip hop" + "dance", + "electronic", + "pop", + "disco", + "house", + "techno", + "edm", + "funk", + "electro", + "dance pop", + "club", + "eurodance", + "trance", + "dubstep", + "drum and bass", + "hip hop", ]; let tracks: any[] = []; @@ -941,21 +985,33 @@ export class ProgrammaticPlaylistService { include: { trackGenres: { include: { - track: { include: { album: { select: { coverUrl: true } } } }, + track: { + include: { album: { select: { coverUrl: true } } }, + }, }, take: 50, }, }, }); tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); - console.log(`[PARTY MIX] Found ${tracks.length} tracks from Genre table`); + console.log( + `[PARTY MIX] Found ${tracks.length} tracks from Genre table` + ); // Strategy 2: Album genre field (using helper for proper JSON array handling) if (tracks.length < 15) { - const albumGenreTracks = await findTracksByGenrePatterns(partyGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[PARTY MIX] After album genre fallback: ${tracks.length} tracks`); + const albumGenreTracks = await findTracksByGenrePatterns( + partyGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[PARTY MIX] After album genre fallback: ${tracks.length} tracks` + ); } // Strategy 3: Audio analysis (high energy, high danceability) @@ -965,19 +1021,31 @@ export class ProgrammaticPlaylistService { analysisStatus: "completed", OR: [ { danceability: { gte: 0.7 } }, - { AND: [{ energy: { gte: 0.7 } }, { bpm: { gte: 110 } }] }, + { + AND: [ + { energy: { gte: 0.7 } }, + { bpm: { gte: 110 } }, + ], + }, ], }, include: { album: { select: { coverUrl: true } } }, take: 50, }); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[PARTY MIX] After audio analysis fallback: ${tracks.length} tracks`); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...audioTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[PARTY MIX] After audio analysis fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[PARTY MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[PARTY MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1030,9 +1098,9 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - + console.log(`[CHILL MIX] Enhanced mode: Found ${tracks.length} tracks`); - + // Strategy 2: Standard mode fallback if (tracks.length < this.MIN_TRACKS_DAILY) { console.log(`[CHILL MIX] Falling back to Standard mode`); @@ -1057,13 +1125,19 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - console.log(`[CHILL MIX] Standard mode: Found ${tracks.length} tracks`); + console.log( + `[CHILL MIX] Standard mode: Found ${tracks.length} tracks` + ); } - - console.log(`[CHILL MIX] Total: ${tracks.length} tracks matching criteria`); + + console.log( + `[CHILL MIX] Total: ${tracks.length} tracks matching criteria` + ); if (tracks.length < this.MIN_TRACKS_DAILY) { - console.log(`[CHILL MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`); + console.log( + `[CHILL MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})` + ); return null; } @@ -1076,9 +1150,11 @@ export class ProgrammaticPlaylistService { // Determine if daily or weekly based on available tracks const isWeekly = tracks.length >= this.MIN_TRACKS_WEEKLY; - const trackLimit = isWeekly ? this.WEEKLY_TRACK_LIMIT : this.DAILY_TRACK_LIMIT; + const trackLimit = isWeekly + ? this.WEEKLY_TRACK_LIMIT + : this.DAILY_TRACK_LIMIT; const selectedTracks = shuffled.slice(0, trackLimit); - + const coverUrls = selectedTracks .filter((t) => t.album.coverUrl) .slice(0, 4) @@ -1106,10 +1182,25 @@ export class ProgrammaticPlaylistService { today: string ): Promise { const workoutGenres = [ - "rock", "metal", "hard rock", "alternative rock", "punk", - "hip hop", "rap", "trap", "hardcore", "metalcore", - "industrial", "drum and bass", "hardstyle", "nu metal", - "electronic", "edm", "house", "techno", "pop punk" + "rock", + "metal", + "hard rock", + "alternative rock", + "punk", + "hip hop", + "rap", + "trap", + "hardcore", + "metalcore", + "industrial", + "drum and bass", + "hardstyle", + "nu metal", + "electronic", + "edm", + "house", + "techno", + "pop punk", ]; let tracks: any[] = []; @@ -1131,8 +1222,10 @@ export class ProgrammaticPlaylistService { take: 100, }); tracks = enhancedTracks; - console.log(`[WORKOUT MIX] Enhanced mode: Found ${tracks.length} tracks`); - + console.log( + `[WORKOUT MIX] Enhanced mode: Found ${tracks.length} tracks` + ); + // Strategy 2: Standard mode fallback - audio analysis if (tracks.length < 15) { console.log(`[WORKOUT MIX] Falling back to Standard mode`); @@ -1140,16 +1233,35 @@ export class ProgrammaticPlaylistService { where: { analysisStatus: "completed", OR: [ - { AND: [{ energy: { gte: 0.65 } }, { bpm: { gte: 115 } }] }, - { moodTags: { hasSome: ["workout", "energetic", "upbeat", "powerful"] } }, + { + AND: [ + { energy: { gte: 0.65 } }, + { bpm: { gte: 115 } }, + ], + }, + { + moodTags: { + hasSome: [ + "workout", + "energetic", + "upbeat", + "powerful", + ], + }, + }, ], }, include: { album: { select: { coverUrl: true } } }, take: 100, }); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[WORKOUT MIX] Standard mode: Total ${tracks.length} tracks`); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...audioTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[WORKOUT MIX] Standard mode: Total ${tracks.length} tracks` + ); } // Strategy 2: Genre table @@ -1159,28 +1271,49 @@ export class ProgrammaticPlaylistService { include: { trackGenres: { include: { - track: { include: { album: { select: { coverUrl: true } } } }, + track: { + include: { + album: { select: { coverUrl: true } }, + }, + }, }, take: 50, }, }, }); - const genreTracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...genreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[WORKOUT MIX] After Genre table: ${tracks.length} tracks`); + const genreTracks = genres.flatMap((g) => + g.trackGenres.map((tg) => tg.track) + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...genreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[WORKOUT MIX] After Genre table: ${tracks.length} tracks` + ); } // Strategy 3: Album genre field (using helper for proper JSON array handling) if (tracks.length < 15) { - const albumGenreTracks = await findTracksByGenrePatterns(workoutGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[WORKOUT MIX] After album genre fallback: ${tracks.length} tracks`); + const albumGenreTracks = await findTracksByGenrePatterns( + workoutGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[WORKOUT MIX] After album genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[WORKOUT MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[WORKOUT MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1218,9 +1351,19 @@ export class ProgrammaticPlaylistService { today: string ): Promise { const focusGenres = [ - "classical", "instrumental", "jazz", "piano", "ambient", - "post-rock", "math rock", "soundtrack", "score", - "contemporary classical", "minimal", "modern classical", "neoclassical" + "classical", + "instrumental", + "jazz", + "piano", + "ambient", + "post-rock", + "math rock", + "soundtrack", + "score", + "contemporary classical", + "minimal", + "modern classical", + "neoclassical", ]; let tracks: any[] = []; @@ -1231,21 +1374,33 @@ export class ProgrammaticPlaylistService { include: { trackGenres: { include: { - track: { include: { album: { select: { coverUrl: true } } } }, + track: { + include: { album: { select: { coverUrl: true } } }, + }, }, take: 50, }, }, }); tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); - console.log(`[FOCUS MIX] Found ${tracks.length} tracks from Genre table`); + console.log( + `[FOCUS MIX] Found ${tracks.length} tracks from Genre table` + ); // Strategy 2: Album genre field (using helper for proper JSON array handling) if (tracks.length < 15) { - const albumGenreTracks = await findTracksByGenrePatterns(focusGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[FOCUS MIX] After album genre fallback: ${tracks.length} tracks`); + const albumGenreTracks = await findTracksByGenrePatterns( + focusGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[FOCUS MIX] After album genre fallback: ${tracks.length} tracks` + ); } // Strategy 3: Audio analysis (high instrumentalness, moderate energy) @@ -1259,13 +1414,20 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 50, }); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[FOCUS MIX] After audio analysis fallback: ${tracks.length} tracks`); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...audioTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[FOCUS MIX] After audio analysis fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[FOCUS MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[FOCUS MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1320,19 +1482,40 @@ export class ProgrammaticPlaylistService { take: 100, }); tracks = audioTracks; - console.log(`[HIGH ENERGY MIX] Found ${tracks.length} tracks from audio analysis`); + console.log( + `[HIGH ENERGY MIX] Found ${tracks.length} tracks from audio analysis` + ); // Strategy 2: Fallback to energetic genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const energyGenres = ["rock", "metal", "punk", "electronic", "edm", "dance", "hip hop", "trap"]; - const albumGenreTracks = await findTracksByGenrePatterns(energyGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[HIGH ENERGY MIX] After genre fallback: ${tracks.length} tracks`); + const energyGenres = [ + "rock", + "metal", + "punk", + "electronic", + "edm", + "dance", + "hip hop", + "trap", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + energyGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[HIGH ENERGY MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[HIGH ENERGY MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[HIGH ENERGY MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1389,9 +1572,11 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - - console.log(`[LATE NIGHT MIX] Enhanced mode: Found ${tracks.length} tracks`); - + + console.log( + `[LATE NIGHT MIX] Enhanced mode: Found ${tracks.length} tracks` + ); + // Fallback to Standard mode if not enough Enhanced tracks if (tracks.length < this.MIN_TRACKS_DAILY) { console.log(`[LATE NIGHT MIX] Falling back to Standard mode`); @@ -1416,14 +1601,20 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - console.log(`[LATE NIGHT MIX] Standard mode: Found ${tracks.length} tracks`); + console.log( + `[LATE NIGHT MIX] Standard mode: Found ${tracks.length} tracks` + ); } - - console.log(`[LATE NIGHT MIX] Total: ${tracks.length} tracks matching criteria`); + + console.log( + `[LATE NIGHT MIX] Total: ${tracks.length} tracks matching criteria` + ); // No fallback padding - if not enough truly mellow tracks, don't generate if (tracks.length < this.MIN_TRACKS_DAILY) { - console.log(`[LATE NIGHT MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`); + console.log( + `[LATE NIGHT MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})` + ); return null; } @@ -1436,9 +1627,11 @@ export class ProgrammaticPlaylistService { // Determine if daily or weekly based on available tracks const isWeekly = tracks.length >= this.MIN_TRACKS_WEEKLY; - const trackLimit = isWeekly ? this.WEEKLY_TRACK_LIMIT : this.DAILY_TRACK_LIMIT; + const trackLimit = isWeekly + ? this.WEEKLY_TRACK_LIMIT + : this.DAILY_TRACK_LIMIT; const selectedTracks = shuffled.slice(0, trackLimit); - + const coverUrls = selectedTracks .filter((t) => t.album.coverUrl) .slice(0, 4) @@ -1480,7 +1673,7 @@ export class ProgrammaticPlaylistService { }); tracks = enhancedTracks; console.log(`[HAPPY MIX] Enhanced mode: Found ${tracks.length} tracks`); - + // Strategy 2: Standard mode fallback - valence/energy heuristics if (tracks.length < 15) { const standardTracks = await prisma.track.findMany({ @@ -1492,22 +1685,45 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...standardTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[HAPPY MIX] After Standard fallback: ${tracks.length} tracks`); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...standardTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[HAPPY MIX] After Standard fallback: ${tracks.length} tracks` + ); } // Strategy 2: Fallback to upbeat/happy genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const happyGenres = ["pop", "funk", "disco", "soul", "reggae", "ska", "motown"]; - const albumGenreTracks = await findTracksByGenrePatterns(happyGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[HAPPY MIX] After genre fallback: ${tracks.length} tracks`); + const happyGenres = [ + "pop", + "funk", + "disco", + "soul", + "reggae", + "ska", + "motown", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + happyGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[HAPPY MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[HAPPY MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[HAPPY MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1558,8 +1774,10 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 150, }); - console.log(`[MELANCHOLY MIX] Enhanced mode: Found ${enhancedTracks.length} tracks`); - + console.log( + `[MELANCHOLY MIX] Enhanced mode: Found ${enhancedTracks.length} tracks` + ); + if (enhancedTracks.length >= 15) { tracks = enhancedTracks; } else { @@ -1574,34 +1792,68 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 150, }); - console.log(`[MELANCHOLY MIX] Standard mode: Found ${audioTracks.length} low-valence tracks`); + console.log( + `[MELANCHOLY MIX] Standard mode: Found ${audioTracks.length} low-valence tracks` + ); // Further filter: prefer minor key OR sad mood tags tracks = audioTracks.filter((t) => { const hasMinorKey = t.keyScale === "minor"; const hasSadTags = t.moodTags?.some((tag: string) => - ["sad", "melancholic", "melancholy", "moody", "atmospheric"].includes(tag.toLowerCase()) + [ + "sad", + "melancholic", + "melancholy", + "moody", + "atmospheric", + ].includes(tag.toLowerCase()) ); const hasLastfmSadTags = t.lastfmTags?.some((tag: string) => - ["sad", "melancholic", "melancholy", "depressing", "emotional", "heartbreak"].includes(tag.toLowerCase()) + [ + "sad", + "melancholic", + "melancholy", + "depressing", + "emotional", + "heartbreak", + ].includes(tag.toLowerCase()) ); return hasMinorKey || hasSadTags || hasLastfmSadTags; }); - console.log(`[MELANCHOLY MIX] After tag filter: ${tracks.length} tracks`); + console.log( + `[MELANCHOLY MIX] After tag filter: ${tracks.length} tracks` + ); } // Strategy 2: Fallback to sad/emotional genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const sadGenres = ["blues", "soul", "ballad", "singer-songwriter", "slowcore", "sadcore"]; - const albumGenreTracks = await findTracksByGenrePatterns(sadGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[MELANCHOLY MIX] After genre fallback: ${tracks.length} tracks`); + const sadGenres = [ + "blues", + "soul", + "ballad", + "singer-songwriter", + "slowcore", + "sadcore", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + sadGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[MELANCHOLY MIX] After genre fallback: ${tracks.length} tracks` + ); } // Require minimum 15 tracks for a meaningful playlist if (tracks.length < 15) { - console.log(`[MELANCHOLY MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[MELANCHOLY MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1667,19 +1919,39 @@ export class ProgrammaticPlaylistService { take: 100, }); tracks = audioTracks; - console.log(`[DANCE FLOOR MIX] Found ${tracks.length} tracks from audio analysis`); + console.log( + `[DANCE FLOOR MIX] Found ${tracks.length} tracks from audio analysis` + ); // Strategy 2: Fallback to dance genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const danceGenres = ["dance", "electronic", "edm", "house", "disco", "techno", "pop"]; - const albumGenreTracks = await findTracksByGenrePatterns(danceGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[DANCE FLOOR MIX] After genre fallback: ${tracks.length} tracks`); + const danceGenres = [ + "dance", + "electronic", + "edm", + "house", + "disco", + "techno", + "pop", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + danceGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[DANCE FLOOR MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[DANCE FLOOR MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[DANCE FLOOR MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1730,19 +2002,37 @@ export class ProgrammaticPlaylistService { take: 100, }); tracks = audioTracks; - console.log(`[ACOUSTIC MIX] Found ${tracks.length} tracks from audio analysis`); + console.log( + `[ACOUSTIC MIX] Found ${tracks.length} tracks from audio analysis` + ); // Strategy 2: Fallback to acoustic genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const acousticGenres = ["acoustic", "folk", "singer-songwriter", "unplugged", "indie folk"]; - const albumGenreTracks = await findTracksByGenrePatterns(acousticGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[ACOUSTIC MIX] After genre fallback: ${tracks.length} tracks`); + const acousticGenres = [ + "acoustic", + "folk", + "singer-songwriter", + "unplugged", + "indie folk", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + acousticGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[ACOUSTIC MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[ACOUSTIC MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[ACOUSTIC MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1793,19 +2083,38 @@ export class ProgrammaticPlaylistService { take: 100, }); tracks = audioTracks; - console.log(`[INSTRUMENTAL MIX] Found ${tracks.length} tracks from audio analysis`); + console.log( + `[INSTRUMENTAL MIX] Found ${tracks.length} tracks from audio analysis` + ); // Strategy 2: Fallback to instrumental genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const instrumentalGenres = ["instrumental", "classical", "soundtrack", "score", "ambient", "post-rock"]; - const albumGenreTracks = await findTracksByGenrePatterns(instrumentalGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[INSTRUMENTAL MIX] After genre fallback: ${tracks.length} tracks`); + const instrumentalGenres = [ + "instrumental", + "classical", + "soundtrack", + "score", + "ambient", + "post-rock", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + instrumentalGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[INSTRUMENTAL MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[INSTRUMENTAL MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[INSTRUMENTAL MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -1900,7 +2209,16 @@ export class ProgrammaticPlaylistService { const taggedTracks = await prisma.track.findMany({ where: { OR: [ - { lastfmTags: { hasSome: ["driving", "road trip", "travel", "summer"] } }, + { + lastfmTags: { + hasSome: [ + "driving", + "road trip", + "travel", + "summer", + ], + }, + }, { moodTags: { hasSome: ["energetic", "upbeat", "happy"] } }, ], }, @@ -1921,22 +2239,43 @@ export class ProgrammaticPlaylistService { include: { album: { select: { coverUrl: true } } }, take: 100, }); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[ROAD TRIP MIX] After audio fallback: ${tracks.length} tracks`); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...audioTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[ROAD TRIP MIX] After audio fallback: ${tracks.length} tracks` + ); } // Strategy 3: Fallback to upbeat rock/pop genres (using helper for proper JSON array handling) if (tracks.length < 15) { - const roadTripGenres = ["rock", "pop", "indie", "alternative", "classic rock"]; - const albumGenreTracks = await findTracksByGenrePatterns(roadTripGenres, 100); - const existingIds = new Set(tracks.map(t => t.id)); - tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; - console.log(`[ROAD TRIP MIX] After genre fallback: ${tracks.length} tracks`); + const roadTripGenres = [ + "rock", + "pop", + "indie", + "alternative", + "classic rock", + ]; + const albumGenreTracks = await findTracksByGenrePatterns( + roadTripGenres, + 100 + ); + const existingIds = new Set(tracks.map((t) => t.id)); + tracks = [ + ...tracks, + ...albumGenreTracks.filter((t) => !existingIds.has(t.id)), + ]; + console.log( + `[ROAD TRIP MIX] After genre fallback: ${tracks.length} tracks` + ); } if (tracks.length < 15) { - console.log(`[ROAD TRIP MIX] FAILED: Only ${tracks.length} tracks found`); + console.log( + `[ROAD TRIP MIX] FAILED: Only ${tracks.length} tracks found` + ); return null; } @@ -2002,7 +2341,15 @@ export class ProgrammaticPlaylistService { acousticness: { gte: 0.5 }, }, { - lastfmTags: { hasSome: ["relaxed", "calm", "peaceful", "chill", "sunday"] }, + lastfmTags: { + hasSome: [ + "relaxed", + "calm", + "peaceful", + "chill", + "sunday", + ], + }, }, ], }, @@ -2045,7 +2392,14 @@ export class ProgrammaticPlaylistService { valence: { gte: 0.5 }, }, { - lastfmTags: { hasSome: ["motivation", "uplifting", "energetic", "happy"] }, + lastfmTags: { + hasSome: [ + "motivation", + "uplifting", + "energetic", + "happy", + ], + }, }, ], }, @@ -2088,7 +2442,9 @@ export class ProgrammaticPlaylistService { energy: { gte: 0.6 }, }, { - lastfmTags: { hasSome: ["party", "dance", "fun", "groovy"] }, + lastfmTags: { + hasSome: ["party", "dance", "fun", "groovy"], + }, }, ], }, @@ -2153,7 +2509,14 @@ export class ProgrammaticPlaylistService { ], }, { - lastfmTags: { hasSome: ["sad", "melancholic", "heartbreak", "emotional"] }, + lastfmTags: { + hasSome: [ + "sad", + "melancholic", + "heartbreak", + "emotional", + ], + }, }, ], }, @@ -2201,7 +2564,14 @@ export class ProgrammaticPlaylistService { ], }, { - lastfmTags: { hasSome: ["empowering", "confident", "uplifting", "anthemic"] }, + lastfmTags: { + hasSome: [ + "empowering", + "confident", + "uplifting", + "anthemic", + ], + }, }, ], }, @@ -2242,16 +2612,22 @@ export class ProgrammaticPlaylistService { analysisStatus: "completed", OR: [ { - AND: [ - { keyScale: "minor" }, - { energy: { gte: 0.65 } }, - ], + AND: [{ keyScale: "minor" }, { energy: { gte: 0.65 } }], }, { - moodTags: { hasSome: ["aggressive", "dark", "intense"] }, + moodTags: { + hasSome: ["aggressive", "dark", "intense"], + }, }, { - lastfmTags: { hasSome: ["dark", "aggressive", "intense", "powerful"] }, + lastfmTags: { + hasSome: [ + "dark", + "aggressive", + "intense", + "powerful", + ], + }, }, ], }, @@ -2452,7 +2828,9 @@ export class ProgrammaticPlaylistService { ], }, { - lastfmTags: { hasSome: ["warm", "sunset", "dreamy", "peaceful"] }, + lastfmTags: { + hasSome: ["warm", "sunset", "dreamy", "peaceful"], + }, }, ], }, @@ -2541,7 +2919,14 @@ export class ProgrammaticPlaylistService { ], }, { - lastfmTags: { hasSome: ["emotional", "heartbreak", "feelings", "vulnerable"] }, + lastfmTags: { + hasSome: [ + "emotional", + "heartbreak", + "feelings", + "vulnerable", + ], + }, }, ], }, @@ -2689,7 +3074,14 @@ export class ProgrammaticPlaylistService { ], }, { - lastfmTags: { hasSome: ["dreamy", "aesthetic", "cinematic", "romantic"] }, + lastfmTags: { + hasSome: [ + "dreamy", + "aesthetic", + "cinematic", + "romantic", + ], + }, }, ], }, @@ -2823,13 +3215,13 @@ export class ProgrammaticPlaylistService { none: {}, }, }, - include: { - album: { - select: { + include: { + album: { + select: { coverUrl: true, artist: { select: { id: true } }, - } - } + }, + }, }, take: 200, }); @@ -2843,13 +3235,13 @@ export class ProgrammaticPlaylistService { }, take: 200, }); - + const filtered = lowPlayTracks - .filter(t => t._count.plays <= 3) - .map(t => ({ ...t, album: t.album })); - + .filter((t) => t._count.plays <= 3) + .map((t) => ({ ...t, album: t.album })); + if (filtered.length < 15) return null; - + const shuffled = randomSample(filtered, this.WEEKLY_TRACK_LIMIT); const coverUrls = shuffled .filter((t) => t.album.coverUrl) @@ -2895,8 +3287,21 @@ export class ProgrammaticPlaylistService { today: string ): Promise { // Circle of fifths order - const keyOrder = ["C", "G", "D", "A", "E", "B", "F#", "Db", "Ab", "Eb", "Bb", "F"]; - + const keyOrder = [ + "C", + "G", + "D", + "A", + "E", + "B", + "F#", + "Db", + "Ab", + "Eb", + "Bb", + "F", + ]; + const tracks = await prisma.track.findMany({ where: { analysisStatus: "completed", @@ -2920,12 +3325,19 @@ export class ProgrammaticPlaylistService { const journey: typeof tracks = []; const seed = getSeededRandom(`key-journey-${today}`); let seedVal = seed; - + for (const key of keyOrder) { const keyTracks = byKey.get(key) || []; - if (keyTracks.length > 0 && journey.length < this.WEEKLY_TRACK_LIMIT) { + if ( + keyTracks.length > 0 && + journey.length < this.WEEKLY_TRACK_LIMIT + ) { // Pick 1-2 tracks from each key - const count = Math.min(2, keyTracks.length, this.WEEKLY_TRACK_LIMIT - journey.length); + const count = Math.min( + 2, + keyTracks.length, + this.WEEKLY_TRACK_LIMIT - journey.length + ); seedVal = (seedVal * 9301 + 49297) % 233280; const shuffled = keyTracks.sort(() => { seedVal = (seedVal * 9301 + 49297) % 233280; @@ -2975,14 +3387,16 @@ export class ProgrammaticPlaylistService { // Sort by BPM const sorted = [...tracks].sort((a, b) => (a.bpm || 0) - (b.bpm || 0)); - + // Build an arc: slow → fast → slow - const slow = sorted.filter(t => (t.bpm || 0) < 100); - const medium = sorted.filter(t => (t.bpm || 0) >= 100 && (t.bpm || 0) < 130); - const fast = sorted.filter(t => (t.bpm || 0) >= 130); + const slow = sorted.filter((t) => (t.bpm || 0) < 100); + const medium = sorted.filter( + (t) => (t.bpm || 0) >= 100 && (t.bpm || 0) < 130 + ); + const fast = sorted.filter((t) => (t.bpm || 0) >= 130); const flow: typeof tracks = []; - + // Intro: 4 slow tracks flow.push(...randomSample(slow, Math.min(4, slow.length))); // Build: 4 medium tracks @@ -2990,9 +3404,19 @@ export class ProgrammaticPlaylistService { // Peak: 5 fast tracks flow.push(...randomSample(fast, Math.min(6, fast.length))); // Cool down: 3 medium tracks - flow.push(...randomSample(medium.filter(t => !flow.includes(t)), Math.min(3, medium.length))); + flow.push( + ...randomSample( + medium.filter((t) => !flow.includes(t)), + Math.min(3, medium.length) + ) + ); // Outro: 3 slow tracks - flow.push(...randomSample(slow.filter(t => !flow.includes(t)), Math.min(2, slow.length))); + flow.push( + ...randomSample( + slow.filter((t) => !flow.includes(t)), + Math.min(2, slow.length) + ) + ); if (flow.length < 15) return null; @@ -3129,44 +3553,152 @@ export class ProgrammaticPlaylistService { }; // Check if any ML mood params are being used - const mlMoodParams = ['moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic']; - const usesMLMoods = mlMoodParams.some(key => params[key as keyof typeof params] !== undefined); + const mlMoodParams = [ + "moodHappy", + "moodSad", + "moodRelaxed", + "moodAggressive", + "moodParty", + "moodAcoustic", + "moodElectronic", + ]; + const usesMLMoods = mlMoodParams.some( + (key) => params[key as keyof typeof params] !== undefined + ); - // If using ML moods, require enhanced analysis mode + // First, check how many enhanced tracks we have + let useEnhancedMode = false; if (usesMLMoods) { - where.analysisMode = "enhanced"; + const enhancedCount = await prisma.track.count({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + }, + }); + + // Only require enhanced mode if we have at least 15 enhanced tracks + if (enhancedCount >= 15) { + where.analysisMode = "enhanced"; + useEnhancedMode = true; + } else { + // Not enough enhanced tracks - convert ML mood params to basic audio feature equivalents + console.log( + `[MoodMixer] Only ${enhancedCount} enhanced tracks, falling back to basic features` + ); + + // Map ML moods to basic audio features for fallback + // This provides approximate matching when enhanced mode isn't available + if (params.moodHappy) { + where.valence = where.valence || {}; + if (params.moodHappy.min !== undefined) + where.valence.gte = Math.max( + where.valence.gte || 0, + params.moodHappy.min + ); + } + if (params.moodSad) { + where.valence = where.valence || {}; + if (params.moodSad.min !== undefined) + where.valence.lte = Math.min( + where.valence.lte || 1, + 1 - params.moodSad.min + ); + } + if (params.moodRelaxed) { + where.energy = where.energy || {}; + if (params.moodRelaxed.min !== undefined) + where.energy.lte = Math.min( + where.energy.lte || 1, + 1 - params.moodRelaxed.min * 0.5 + ); + } + if (params.moodAggressive) { + where.energy = where.energy || {}; + if (params.moodAggressive.min !== undefined) + where.energy.gte = Math.max( + where.energy.gte || 0, + params.moodAggressive.min + ); + } + if (params.moodParty) { + where.danceability = where.danceability || {}; + if (params.moodParty.min !== undefined) + where.danceability.gte = Math.max( + where.danceability.gte || 0, + params.moodParty.min + ); + } + // Clear the ML mood params since we're falling back + delete params.moodHappy; + delete params.moodSad; + delete params.moodRelaxed; + delete params.moodAggressive; + delete params.moodParty; + delete params.moodAcoustic; + delete params.moodElectronic; + } } - // Basic audio feature filters + // Basic audio feature filters - merge with any existing from fallback if (params.valence) { - where.valence = {}; - if (params.valence.min !== undefined) where.valence.gte = params.valence.min; - if (params.valence.max !== undefined) where.valence.lte = params.valence.max; + where.valence = where.valence || {}; + if (params.valence.min !== undefined) + where.valence.gte = Math.max( + where.valence.gte || 0, + params.valence.min + ); + if (params.valence.max !== undefined) + where.valence.lte = Math.min( + where.valence.lte ?? 1, + params.valence.max + ); } if (params.energy) { - where.energy = {}; - if (params.energy.min !== undefined) where.energy.gte = params.energy.min; - if (params.energy.max !== undefined) where.energy.lte = params.energy.max; + where.energy = where.energy || {}; + if (params.energy.min !== undefined) + where.energy.gte = Math.max( + where.energy.gte || 0, + params.energy.min + ); + if (params.energy.max !== undefined) + where.energy.lte = Math.min( + where.energy.lte ?? 1, + params.energy.max + ); } if (params.danceability) { - where.danceability = {}; - if (params.danceability.min !== undefined) where.danceability.gte = params.danceability.min; - if (params.danceability.max !== undefined) where.danceability.lte = params.danceability.max; + where.danceability = where.danceability || {}; + if (params.danceability.min !== undefined) + where.danceability.gte = Math.max( + where.danceability.gte || 0, + params.danceability.min + ); + if (params.danceability.max !== undefined) + where.danceability.lte = Math.min( + where.danceability.lte ?? 1, + params.danceability.max + ); } if (params.acousticness) { where.acousticness = {}; - if (params.acousticness.min !== undefined) where.acousticness.gte = params.acousticness.min; - if (params.acousticness.max !== undefined) where.acousticness.lte = params.acousticness.max; + if (params.acousticness.min !== undefined) + where.acousticness.gte = params.acousticness.min; + if (params.acousticness.max !== undefined) + where.acousticness.lte = params.acousticness.max; } if (params.instrumentalness) { where.instrumentalness = {}; - if (params.instrumentalness.min !== undefined) where.instrumentalness.gte = params.instrumentalness.min; - if (params.instrumentalness.max !== undefined) where.instrumentalness.lte = params.instrumentalness.max; + if (params.instrumentalness.min !== undefined) + where.instrumentalness.gte = params.instrumentalness.min; + if (params.instrumentalness.max !== undefined) + where.instrumentalness.lte = params.instrumentalness.max; } if (params.arousal) { where.arousal = {}; - if (params.arousal.min !== undefined) where.arousal.gte = params.arousal.min; - if (params.arousal.max !== undefined) where.arousal.lte = params.arousal.max; + if (params.arousal.min !== undefined) + where.arousal.gte = params.arousal.min; + if (params.arousal.max !== undefined) + where.arousal.lte = params.arousal.max; } if (params.bpm) { where.bpm = {}; @@ -3180,38 +3712,52 @@ export class ProgrammaticPlaylistService { // ML mood prediction filters if (params.moodHappy) { where.moodHappy = {}; - if (params.moodHappy.min !== undefined) where.moodHappy.gte = params.moodHappy.min; - if (params.moodHappy.max !== undefined) where.moodHappy.lte = params.moodHappy.max; + if (params.moodHappy.min !== undefined) + where.moodHappy.gte = params.moodHappy.min; + if (params.moodHappy.max !== undefined) + where.moodHappy.lte = params.moodHappy.max; } if (params.moodSad) { where.moodSad = {}; - if (params.moodSad.min !== undefined) where.moodSad.gte = params.moodSad.min; - if (params.moodSad.max !== undefined) where.moodSad.lte = params.moodSad.max; + if (params.moodSad.min !== undefined) + where.moodSad.gte = params.moodSad.min; + if (params.moodSad.max !== undefined) + where.moodSad.lte = params.moodSad.max; } if (params.moodRelaxed) { where.moodRelaxed = {}; - if (params.moodRelaxed.min !== undefined) where.moodRelaxed.gte = params.moodRelaxed.min; - if (params.moodRelaxed.max !== undefined) where.moodRelaxed.lte = params.moodRelaxed.max; + if (params.moodRelaxed.min !== undefined) + where.moodRelaxed.gte = params.moodRelaxed.min; + if (params.moodRelaxed.max !== undefined) + where.moodRelaxed.lte = params.moodRelaxed.max; } if (params.moodAggressive) { where.moodAggressive = {}; - if (params.moodAggressive.min !== undefined) where.moodAggressive.gte = params.moodAggressive.min; - if (params.moodAggressive.max !== undefined) where.moodAggressive.lte = params.moodAggressive.max; + if (params.moodAggressive.min !== undefined) + where.moodAggressive.gte = params.moodAggressive.min; + if (params.moodAggressive.max !== undefined) + where.moodAggressive.lte = params.moodAggressive.max; } if (params.moodParty) { where.moodParty = {}; - if (params.moodParty.min !== undefined) where.moodParty.gte = params.moodParty.min; - if (params.moodParty.max !== undefined) where.moodParty.lte = params.moodParty.max; + if (params.moodParty.min !== undefined) + where.moodParty.gte = params.moodParty.min; + if (params.moodParty.max !== undefined) + where.moodParty.lte = params.moodParty.max; } if (params.moodAcoustic) { where.moodAcoustic = {}; - if (params.moodAcoustic.min !== undefined) where.moodAcoustic.gte = params.moodAcoustic.min; - if (params.moodAcoustic.max !== undefined) where.moodAcoustic.lte = params.moodAcoustic.max; + if (params.moodAcoustic.min !== undefined) + where.moodAcoustic.gte = params.moodAcoustic.min; + if (params.moodAcoustic.max !== undefined) + where.moodAcoustic.lte = params.moodAcoustic.max; } if (params.moodElectronic) { where.moodElectronic = {}; - if (params.moodElectronic.min !== undefined) where.moodElectronic.gte = params.moodElectronic.min; - if (params.moodElectronic.max !== undefined) where.moodElectronic.lte = params.moodElectronic.max; + if (params.moodElectronic.min !== undefined) + where.moodElectronic.gte = params.moodElectronic.min; + if (params.moodElectronic.max !== undefined) + where.moodElectronic.lte = params.moodElectronic.max; } const tracks = await prisma.track.findMany({ diff --git a/backend/src/workers/artistEnrichment.ts b/backend/src/workers/artistEnrichment.ts index 4eb4f0d..6c7b3ef 100644 --- a/backend/src/workers/artistEnrichment.ts +++ b/backend/src/workers/artistEnrichment.ts @@ -32,7 +32,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { try { // If artist has a temp MBID, try to get the real one from MusicBrainz if (artist.mbid.startsWith("temp-")) { - console.log(`${logPrefix} Temp MBID detected, searching MusicBrainz...`); + console.log( + `${logPrefix} Temp MBID detected, searching MusicBrainz...` + ); try { const mbResults = await musicBrainzService.searchArtist( artist.name, @@ -40,7 +42,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { ); if (mbResults.length > 0 && mbResults[0].id) { const realMbid = mbResults[0].id; - console.log(`${logPrefix} MusicBrainz: Found real MBID: ${realMbid}`); + console.log( + `${logPrefix} MusicBrainz: Found real MBID: ${realMbid}` + ); // Update artist with real MBID await prisma.artist.update({ @@ -51,10 +55,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise { // Update the local artist object artist.mbid = realMbid; } else { - console.log(`${logPrefix} MusicBrainz: No match found, keeping temp MBID`); + console.log( + `${logPrefix} MusicBrainz: No match found, keeping temp MBID` + ); } } catch (error: any) { - console.log(`${logPrefix} MusicBrainz: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} MusicBrainz: FAILED - ${ + error?.message || error + }` + ); } } @@ -63,7 +73,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { let heroUrl = null; if (!artist.mbid.startsWith("temp-")) { - console.log(`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`); + console.log( + `${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...` + ); try { const wikidataInfo = await wikidataService.getArtistInfo( artist.mbid @@ -73,12 +85,18 @@ export async function enrichSimilarArtist(artist: Artist): Promise { heroUrl = wikidataInfo.image; if (summary) summarySource = "wikidata"; if (heroUrl) imageSource = "wikidata"; - console.log(`${logPrefix} Wikidata: SUCCESS (image: ${heroUrl ? "yes" : "no"}, summary: ${summary ? "yes" : "no"})`); + console.log( + `${logPrefix} Wikidata: SUCCESS (image: ${ + heroUrl ? "yes" : "no" + }, summary: ${summary ? "yes" : "no"})` + ); } else { console.log(`${logPrefix} Wikidata: No data returned`); } } catch (error: any) { - console.log(`${logPrefix} Wikidata: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} Wikidata: FAILED - ${error?.message || error}` + ); } } else { console.log(`${logPrefix} Wikidata: Skipped (temp MBID)`); @@ -86,7 +104,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { // Fallback to Last.fm if Wikidata didn't work if (!summary || !heroUrl) { - console.log(`${logPrefix} Last.fm: Fetching (need summary: ${!summary}, need image: ${!heroUrl})...`); + console.log( + `${logPrefix} Last.fm: Fetching (need summary: ${!summary}, need image: ${!heroUrl})...` + ); try { const validMbid = artist.mbid.startsWith("temp-") ? undefined @@ -108,37 +128,63 @@ export async function enrichSimilarArtist(artist: Artist): Promise { // Try Fanart.tv for image (only with real MBID) if (!heroUrl && !artist.mbid.startsWith("temp-")) { - console.log(`${logPrefix} Fanart.tv: Fetching for MBID ${artist.mbid}...`); + console.log( + `${logPrefix} Fanart.tv: Fetching for MBID ${artist.mbid}...` + ); try { heroUrl = await fanartService.getArtistImage( artist.mbid ); if (heroUrl) { imageSource = "fanart.tv"; - console.log(`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(0, 60)}...`); + console.log( + `${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring( + 0, + 60 + )}...` + ); } else { - console.log(`${logPrefix} Fanart.tv: No image found`); + console.log( + `${logPrefix} Fanart.tv: No image found` + ); } } catch (error: any) { - console.log(`${logPrefix} Fanart.tv: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} Fanart.tv: FAILED - ${ + error?.message || error + }` + ); } } // Fallback to Deezer if (!heroUrl) { - console.log(`${logPrefix} Deezer: Fetching for "${artist.name}"...`); + console.log( + `${logPrefix} Deezer: Fetching for "${artist.name}"...` + ); try { heroUrl = await deezerService.getArtistImage( artist.name ); if (heroUrl) { imageSource = "deezer"; - console.log(`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(0, 60)}...`); + console.log( + `${logPrefix} Deezer: SUCCESS - ${heroUrl.substring( + 0, + 60 + )}...` + ); } else { - console.log(`${logPrefix} Deezer: No image found`); + console.log( + `${logPrefix} Deezer: No image found` + ); } } catch (error: any) { - console.log(`${logPrefix} Deezer: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} Deezer: FAILED - ${ + error?.message || error + }` + ); } } @@ -165,9 +211,13 @@ export async function enrichSimilarArtist(artist: Artist): Promise { ) { heroUrl = bestImage; imageSource = "lastfm"; - console.log(`${logPrefix} Last.fm image: SUCCESS`); + console.log( + `${logPrefix} Last.fm image: SUCCESS` + ); } else { - console.log(`${logPrefix} Last.fm image: Placeholder/none`); + console.log( + `${logPrefix} Last.fm image: Placeholder/none` + ); } } } @@ -175,7 +225,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { console.log(`${logPrefix} Last.fm: No data returned`); } } catch (error: any) { - console.log(`${logPrefix} Last.fm: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} Last.fm: FAILED - ${error?.message || error}` + ); } } @@ -194,13 +246,33 @@ export async function enrichSimilarArtist(artist: Artist): Promise { validMbid, artist.name ); - console.log(`${logPrefix} Similar artists: Found ${similarArtists.length}`); + console.log( + `${logPrefix} Similar artists: Found ${similarArtists.length}` + ); } catch (error: any) { - console.log(`${logPrefix} Similar artists: FAILED - ${error?.message || error}`); + console.log( + `${logPrefix} Similar artists: FAILED - ${ + error?.message || error + }` + ); } // Log enrichment summary - console.log(`${logPrefix} SUMMARY: image=${imageSource}, summary=${summarySource}, heroUrl=${heroUrl ? "set" : "null"}`); + console.log( + `${logPrefix} SUMMARY: image=${imageSource}, summary=${summarySource}, heroUrl=${ + heroUrl ? "set" : "null" + }` + ); + + // Prepare similar artists JSON for storage (full Last.fm data) + const similarArtistsJson = + similarArtists.length > 0 + ? similarArtists.map((s) => ({ + name: s.name, + mbid: s.mbid || null, + match: s.similarity, + })) + : null; // Update artist with enriched data await prisma.artist.update({ @@ -208,6 +280,7 @@ export async function enrichSimilarArtist(artist: Artist): Promise { data: { summary, heroUrl, + similarArtistsJson, lastEnriched: new Date(), enrichmentStatus: "completed", }, @@ -264,7 +337,9 @@ export async function enrichSimilarArtist(artist: Artist): Promise { } } - console.log(`${logPrefix} Stored ${similarArtists.length} similar artist relationships`); + console.log( + `${logPrefix} Stored ${similarArtists.length} similar artist relationships` + ); } // ========== ALBUM COVER ENRICHMENT ========== @@ -274,13 +349,20 @@ export async function enrichSimilarArtist(artist: Artist): Promise { // Cache artist image in Redis for faster access if (heroUrl) { try { - await redisClient.setEx(`hero:${artist.id}`, 7 * 24 * 60 * 60, heroUrl); + await redisClient.setEx( + `hero:${artist.id}`, + 7 * 24 * 60 * 60, + heroUrl + ); } catch (err) { // Redis errors are non-critical } } } catch (error: any) { - console.error(`${logPrefix} ENRICHMENT FAILED:`, error?.message || error); + console.error( + `${logPrefix} ENRICHMENT FAILED:`, + error?.message || error + ); // Mark as failed await prisma.artist.update({ @@ -296,16 +378,16 @@ export async function enrichSimilarArtist(artist: Artist): Promise { * Enrich album covers for an artist * Fetches covers from Cover Art Archive for albums without covers */ -async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null): Promise { +async function enrichAlbumCovers( + artistId: string, + artistHeroUrl: string | null +): Promise { try { // Find albums for this artist that don't have cover art const albumsWithoutCovers = await prisma.album.findMany({ where: { artistId, - OR: [ - { coverUrl: null }, - { coverUrl: "" }, - ], + OR: [{ coverUrl: null }, { coverUrl: "" }], }, select: { id: true, @@ -319,7 +401,9 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null) return; } - console.log(` Fetching covers for ${albumsWithoutCovers.length} albums...`); + console.log( + ` Fetching covers for ${albumsWithoutCovers.length} albums...` + ); let fetchedCount = 0; const BATCH_SIZE = 3; // Limit concurrent requests @@ -327,14 +411,16 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null) // Process in batches to avoid overwhelming Cover Art Archive for (let i = 0; i < albumsWithoutCovers.length; i += BATCH_SIZE) { const batch = albumsWithoutCovers.slice(i, i + BATCH_SIZE); - + await Promise.all( batch.map(async (album) => { if (!album.rgMbid) return; try { - const coverUrl = await coverArtService.getCoverArt(album.rgMbid); - + const coverUrl = await coverArtService.getCoverArt( + album.rgMbid + ); + if (coverUrl) { // Save to database await prisma.album.update({ @@ -363,7 +449,9 @@ async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null) ); } - console.log(` Fetched ${fetchedCount}/${albumsWithoutCovers.length} album covers`); + console.log( + ` Fetched ${fetchedCount}/${albumsWithoutCovers.length} album covers` + ); } catch (error) { console.error(` Failed to enrich album covers:`, error); // Don't throw - album cover failures shouldn't fail the entire enrichment diff --git a/backend/src/workers/index.ts b/backend/src/workers/index.ts index 4c2018d..d82a0e6 100644 --- a/backend/src/workers/index.ts +++ b/backend/src/workers/index.ts @@ -9,6 +9,7 @@ import { processDiscoverWeekly } from "./processors/discoverProcessor"; import { processImageOptimization } from "./processors/imageProcessor"; import { processValidation } from "./processors/validationProcessor"; import { startUnifiedEnrichmentWorker, stopUnifiedEnrichmentWorker } from "./unifiedEnrichment"; +import { startMoodBucketWorker, stopMoodBucketWorker } from "./moodBucketWorker"; import { downloadQueueManager } from "../services/downloadQueue"; import { prisma } from "../utils/db"; import { startDiscoverWeeklyCron, stopDiscoverWeeklyCron } from "./discoverCron"; @@ -77,6 +78,12 @@ startUnifiedEnrichmentWorker().catch((err) => { console.error("Failed to start unified enrichment worker:", err); }); +// Start mood bucket worker +// Assigns newly analyzed tracks to mood buckets for fast mood mix generation +startMoodBucketWorker().catch((err) => { + console.error("Failed to start mood bucket worker:", err); +}); + // Event handlers for scan queue scanQueue.on("completed", (job, result) => { console.log( @@ -229,6 +236,9 @@ export async function shutdownWorkers(): Promise { // Stop unified enrichment worker stopUnifiedEnrichmentWorker(); + // Stop mood bucket worker + stopMoodBucketWorker(); + // Stop discover weekly cron stopDiscoverWeeklyCron(); diff --git a/backend/src/workers/moodBucketWorker.ts b/backend/src/workers/moodBucketWorker.ts new file mode 100644 index 0000000..f5e83d4 --- /dev/null +++ b/backend/src/workers/moodBucketWorker.ts @@ -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 { + 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 = {}; + 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(); +} diff --git a/backend/src/workers/unifiedEnrichment.ts b/backend/src/workers/unifiedEnrichment.ts index 96ad4bb..ac7ee4b 100644 --- a/backend/src/workers/unifiedEnrichment.ts +++ b/backend/src/workers/unifiedEnrichment.ts @@ -1,11 +1,11 @@ /** * Unified Enrichment Worker - * + * * Handles ALL enrichment in one place: * - Artist metadata (Last.fm, MusicBrainz) * - Track mood tags (Last.fm) * - Audio analysis (triggers Essentia via Redis queue) - * + * * Two modes: * 1. FULL: Re-enriches everything regardless of status (Settings > Enrich) * 2. INCREMENTAL: Only new material and incomplete items (Sync) @@ -29,27 +29,75 @@ let redis: Redis | null = null; // Mood tags to extract from Last.fm const MOOD_TAGS = new Set([ // Energy/Activity - "chill", "relax", "relaxing", "calm", "peaceful", "ambient", - "energetic", "upbeat", "hype", "party", "dance", - "workout", "gym", "running", "exercise", "motivation", + "chill", + "relax", + "relaxing", + "calm", + "peaceful", + "ambient", + "energetic", + "upbeat", + "hype", + "party", + "dance", + "workout", + "gym", + "running", + "exercise", + "motivation", // Emotions - "sad", "melancholy", "melancholic", "depressing", "heartbreak", - "happy", "feel good", "feel-good", "joyful", "uplifting", - "angry", "aggressive", "intense", - "romantic", "love", "sensual", + "sad", + "melancholy", + "melancholic", + "depressing", + "heartbreak", + "happy", + "feel good", + "feel-good", + "joyful", + "uplifting", + "angry", + "aggressive", + "intense", + "romantic", + "love", + "sensual", // Time/Setting - "night", "late night", "evening", "morning", - "summer", "winter", "rainy", "sunny", - "driving", "road trip", "travel", + "night", + "late night", + "evening", + "morning", + "summer", + "winter", + "rainy", + "sunny", + "driving", + "road trip", + "travel", // Activity - "study", "focus", "concentration", "work", - "sleep", "sleeping", "bedtime", + "study", + "focus", + "concentration", + "work", + "sleep", + "sleeping", + "bedtime", // Vibe - "dreamy", "atmospheric", "ethereal", "spacey", - "groovy", "funky", "smooth", - "dark", "moody", "brooding", - "epic", "cinematic", "dramatic", - "nostalgic", "throwback", + "dreamy", + "atmospheric", + "ethereal", + "spacey", + "groovy", + "funky", + "smooth", + "dark", + "moody", + "brooding", + "epic", + "cinematic", + "dramatic", + "nostalgic", + "throwback", ]); /** @@ -57,8 +105,8 @@ const MOOD_TAGS = new Set([ */ function filterMoodTags(tags: string[]): string[] { return tags - .map(t => t.toLowerCase().trim()) - .filter(t => { + .map((t) => t.toLowerCase().trim()) + .filter((t) => { if (MOOD_TAGS.has(t)) return true; for (const mood of MOOD_TAGS) { if (t.includes(mood) || mood.includes(t)) return true; @@ -122,36 +170,36 @@ export async function runFullEnrichment(): Promise<{ audioQueued: number; }> { console.log("\n=== FULL ENRICHMENT: Re-enriching everything ===\n"); - + // Reset all statuses to pending await prisma.artist.updateMany({ - data: { enrichmentStatus: "pending" } + data: { enrichmentStatus: "pending" }, }); - + await prisma.track.updateMany({ - data: { + data: { lastfmTags: [], - analysisStatus: "pending" - } + analysisStatus: "pending", + }, }); - + // Now run the enrichment cycle const result = await runEnrichmentCycle(true); - + return result; } /** * Main enrichment cycle - * + * * Flow: * 1. Artist metadata (Last.fm/MusicBrainz) - blocking, required for track enrichment * 2. Track tags (Last.fm mood tags) - blocking, quick API calls * 3. Audio analysis (Essentia) - NON-BLOCKING, queued to Redis for background processing - * + * * Steps 1 & 2 must complete before enrichment is "done". * Step 3 runs entirely in background via the audio-analyzer Docker container. - * + * * @param fullMode - If true, processes everything. If false, only pending items. */ async function runEnrichmentCycle(fullMode: boolean): Promise<{ @@ -171,25 +219,30 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{ try { // Step 1: Enrich artists (blocking - required for step 2) artistsProcessed = await enrichArtistsBatch(); - + // Step 2: Enrich track tags from Last.fm (blocking - quick API calls) tracksProcessed = await enrichTrackTagsBatch(); - + // Step 3: Queue audio analysis (NON-BLOCKING) // Just adds to Redis queue - actual processing happens in audio-analyzer container // This is intentionally fire-and-forget so it doesn't slow down enrichment audioQueued = await queueAudioAnalysis(); - + // Log progress (only if work was done) if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0) { const progress = await getEnrichmentProgress(); console.log(`\n[Enrichment Progress]`); - console.log(` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)`); - console.log(` Track Tags: ${progress.trackTags.enriched}/${progress.trackTags.total} (${progress.trackTags.progress}%)`); - console.log(` Audio Analysis: ${progress.audioAnalysis.completed}/${progress.audioAnalysis.total} (${progress.audioAnalysis.progress}%) [background]`); + console.log( + ` Artists: ${progress.artists.completed}/${progress.artists.total} (${progress.artists.progress}%)` + ); + console.log( + ` Track Tags: ${progress.trackTags.enriched}/${progress.trackTags.total} (${progress.trackTags.progress}%)` + ); + console.log( + ` Audio Analysis: ${progress.audioAnalysis.completed}/${progress.audioAnalysis.total} (${progress.audioAnalysis.progress}%) [background]` + ); console.log(""); } - } catch (error) { console.error("[Enrichment] Cycle error:", error); } finally { @@ -240,9 +293,13 @@ async function enrichArtistsBatch(): Promise { async function enrichTrackTagsBatch(): Promise { // Note: Nested orderBy on relations doesn't work with isEmpty filtering in Prisma // Track tag enrichment doesn't depend on artist enrichment status, so we just order by recency + // Match both empty array AND null (newly scanned tracks have null, not []) const tracks = await prisma.track.findMany({ where: { - lastfmTags: { equals: [] }, + OR: [ + { lastfmTags: { equals: [] } }, + { lastfmTags: { isEmpty: true } }, + ], }, include: { album: { @@ -262,21 +319,29 @@ async function enrichTrackTagsBatch(): Promise { for (const track of tracks) { try { const artistName = track.album.artist.name; - const trackInfo = await lastFmService.getTrackInfo(artistName, track.title); - + const trackInfo = await lastFmService.getTrackInfo( + artistName, + track.title + ); + if (trackInfo?.toptags?.tag) { const allTags = trackInfo.toptags.tag.map((t: any) => t.name); const moodTags = filterMoodTags(allTags); - + await prisma.track.update({ where: { id: track.id }, - data: { - lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"] + data: { + lastfmTags: + moodTags.length > 0 ? moodTags : ["_no_mood_tags"], }, }); - + if (moodTags.length > 0) { - console.log(` ✓ ${track.title}: [${moodTags.slice(0, 3).join(", ")}...]`); + console.log( + ` ✓ ${track.title}: [${moodTags + .slice(0, 3) + .join(", ")}...]` + ); } } else { await prisma.track.update({ @@ -284,9 +349,9 @@ async function enrichTrackTagsBatch(): Promise { data: { lastfmTags: ["_not_found"] }, }); } - + // Rate limit - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); } catch (error: any) { console.error(` ✗ ${track.title}: ${error?.message || error}`); } @@ -316,7 +381,9 @@ async function queueAudioAnalysis(): Promise { if (tracks.length === 0) return 0; - console.log(`[Audio Analysis] Queueing ${tracks.length} tracks for Essentia...`); + console.log( + `[Audio Analysis] Queueing ${tracks.length} tracks for Essentia...` + ); const redis = getRedis(); let queued = 0; @@ -331,13 +398,13 @@ async function queueAudioAnalysis(): Promise { filePath: track.filePath, }) ); - + // Mark as queued (processing) await prisma.track.update({ where: { id: track.id }, data: { analysisStatus: "processing" }, }); - + queued++; } catch (error) { console.error(` Failed to queue ${track.title}:`, error); @@ -353,7 +420,7 @@ async function queueAudioAnalysis(): Promise { /** * Get comprehensive enrichment progress - * + * * Returns separate progress for: * - Artists & Track Tags: "Core" enrichment (must complete before app is fully usable) * - Audio Analysis: "Background" enrichment (runs in separate container, non-blocking) @@ -364,17 +431,20 @@ export async function getEnrichmentProgress() { by: ["enrichmentStatus"], _count: true, }); - + const artistTotal = artistCounts.reduce((sum, s) => sum + s._count, 0); - const artistCompleted = artistCounts.find(s => s.enrichmentStatus === "completed")?._count || 0; - const artistPending = artistCounts.find(s => s.enrichmentStatus === "pending")?._count || 0; - + const artistCompleted = + artistCounts.find((s) => s.enrichmentStatus === "completed")?._count || + 0; + const artistPending = + artistCounts.find((s) => s.enrichmentStatus === "pending")?._count || 0; + // Track tag progress const trackTotal = await prisma.track.count(); const trackTagsEnriched = await prisma.track.count({ where: { NOT: { lastfmTags: { equals: [] } } }, }); - + // Audio analysis progress (background task) const audioCompleted = await prisma.track.count({ where: { analysisStatus: "completed" }, @@ -391,24 +461,33 @@ export async function getEnrichmentProgress() { // Core enrichment is complete when artists and track tags are done // Audio analysis is separate - it runs in background and doesn't block - const coreComplete = artistPending === 0 && (trackTotal - trackTagsEnriched) === 0; - + const coreComplete = + artistPending === 0 && trackTotal - trackTagsEnriched === 0; + return { // Core enrichment (blocking) artists: { total: artistTotal, completed: artistCompleted, pending: artistPending, - failed: artistCounts.find(s => s.enrichmentStatus === "failed")?._count || 0, - progress: artistTotal > 0 ? Math.round((artistCompleted / artistTotal) * 100) : 0, + failed: + artistCounts.find((s) => s.enrichmentStatus === "failed") + ?._count || 0, + progress: + artistTotal > 0 + ? Math.round((artistCompleted / artistTotal) * 100) + : 0, }, trackTags: { total: trackTotal, enriched: trackTagsEnriched, pending: trackTotal - trackTagsEnriched, - progress: trackTotal > 0 ? Math.round((trackTagsEnriched / trackTotal) * 100) : 0, + progress: + trackTotal > 0 + ? Math.round((trackTagsEnriched / trackTotal) * 100) + : 0, }, - + // Background enrichment (non-blocking, runs in audio-analyzer container) audioAnalysis: { total: trackTotal, @@ -416,13 +495,17 @@ export async function getEnrichmentProgress() { pending: audioPending, processing: audioProcessing, failed: audioFailed, - progress: trackTotal > 0 ? Math.round((audioCompleted / trackTotal) * 100) : 0, + progress: + trackTotal > 0 + ? Math.round((audioCompleted / trackTotal) * 100) + : 0, isBackground: true, // Flag to indicate this runs separately }, - + // Overall status coreComplete, // True when artists + track tags are done - isFullyComplete: coreComplete && audioPending === 0 && audioProcessing === 0, + isFullyComplete: + coreComplete && audioPending === 0 && audioProcessing === 0, }; } @@ -433,9 +516,9 @@ export async function enrichArtistNow(artistId: string) { const artist = await prisma.artist.findUnique({ where: { id: artistId }, }); - + if (!artist) return; - + console.log(`[Enrichment] Enriching artist: ${artist.name}`); await enrichSimilarArtist(artist); } @@ -468,33 +551,35 @@ export async function enrichAlbumTracksNow(albumId: string) { }, }, }); - - console.log(`[Enrichment] Enriching ${tracks.length} tracks for album ${albumId}`); - + + console.log( + `[Enrichment] Enriching ${tracks.length} tracks for album ${albumId}` + ); + for (const track of tracks) { try { const trackInfo = await lastFmService.getTrackInfo( track.album.artist.name, track.title ); - + if (trackInfo?.toptags?.tag) { const allTags = trackInfo.toptags.tag.map((t: any) => t.name); const moodTags = filterMoodTags(allTags); - + await prisma.track.update({ where: { id: track.id }, - data: { - lastfmTags: moodTags.length > 0 ? moodTags : ["_no_mood_tags"], + data: { + lastfmTags: + moodTags.length > 0 ? moodTags : ["_no_mood_tags"], analysisStatus: "pending", // Queue for audio analysis }, }); } - - await new Promise(resolve => setTimeout(resolve, 200)); + + await new Promise((resolve) => setTimeout(resolve, 200)); } catch (error) { console.error(`Failed to enrich track ${track.title}:`, error); } } } - diff --git a/docs/HANDOFF_BUGS.md b/docs/HANDOFF_BUGS.md new file mode 100644 index 0000000..d19ef04 --- /dev/null +++ b/docs/HANDOFF_BUGS.md @@ -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. diff --git a/frontend/components/MixCard.tsx b/frontend/components/MixCard.tsx index f80cacb..52f7bbe 100644 --- a/frontend/components/MixCard.tsx +++ b/frontend/components/MixCard.tsx @@ -32,7 +32,10 @@ const MixCard = memo( {mix.coverUrls.length > 0 ? (
{mix.coverUrls.slice(0, 4).map((url, idx) => { - const proxiedUrl = api.getCoverArtUrl(url, 300); + const proxiedUrl = api.getCoverArtUrl( + url, + 300 + ); return (
(
{ - 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] + ) + ); } ); diff --git a/frontend/components/MoodMixer.tsx b/frontend/components/MoodMixer.tsx index f9d971f..3c255ee 100644 --- a/frontend/components/MoodMixer.tsx +++ b/frontend/components/MoodMixer.tsx @@ -1,10 +1,25 @@ "use client"; import { useState, useEffect } from "react"; -import { api, MoodPreset, MoodMixParams } from "@/lib/api"; +import { api, MoodType, MoodBucketPreset } from "@/lib/api"; import { useAudioControls } from "@/lib/audio-controls-context"; import { Track } from "@/lib/audio-state-context"; -import { Play, Loader2, AudioWaveform, Sliders, X, ChevronDown, ChevronUp } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Play, + Loader2, + AudioWaveform, + X, + Smile, + Frown, + Coffee, + Zap, + PartyPopper, + Brain, + CloudRain, + Flame, + Guitar, +} from "lucide-react"; import { toast } from "sonner"; interface MoodMixerProps { @@ -12,46 +27,92 @@ interface MoodMixerProps { onClose: () => void; } +// Mood configuration with icons and colors +const MOOD_CONFIG: Record< + MoodType, + { + icon: React.ComponentType<{ className?: string }>; + color: string; + label: string; + description: string; + } +> = { + happy: { + icon: Smile, + color: "from-yellow-500 to-orange-500", + label: "Happy", + description: "Uplifting & joyful", + }, + sad: { + icon: Frown, + color: "from-blue-600 to-indigo-700", + label: "Sad", + description: "Melancholic & emotional", + }, + chill: { + icon: Coffee, + color: "from-teal-500 to-cyan-600", + label: "Chill", + description: "Relaxed & mellow", + }, + energetic: { + icon: Zap, + color: "from-orange-500 to-red-500", + label: "Energetic", + description: "High energy & pumped", + }, + party: { + icon: PartyPopper, + color: "from-pink-500 to-purple-600", + label: "Party", + description: "Dance & celebrate", + }, + focus: { + icon: Brain, + color: "from-emerald-500 to-green-600", + label: "Focus", + description: "Concentration & flow", + }, + melancholy: { + icon: CloudRain, + color: "from-slate-500 to-gray-600", + label: "Melancholy", + description: "Bittersweet & reflective", + }, + aggressive: { + icon: Flame, + color: "from-red-600 to-rose-700", + label: "Aggressive", + description: "Intense & powerful", + }, + acoustic: { + icon: Guitar, + color: "from-amber-600 to-yellow-700", + label: "Acoustic", + description: "Organic & unplugged", + }, +}; + +// Order for display in 3x3 grid +const MOOD_ORDER: MoodType[] = [ + "happy", + "energetic", + "party", + "chill", + "focus", + "acoustic", + "melancholy", + "sad", + "aggressive", +]; + export function MoodMixer({ isOpen, onClose }: MoodMixerProps) { const { playTracks } = useAudioControls(); - const [presets, setPresets] = useState([]); + const queryClient = useQueryClient(); + const [presets, setPresets] = useState([]); const [loading, setLoading] = useState(true); - const [generating, setGenerating] = useState(null); - const [showCustom, setShowCustom] = useState(false); + const [generating, setGenerating] = useState(null); const [isVisible, setIsVisible] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); - - // Custom sliders state - basic audio features - const [customParams, setCustomParams] = useState<{ - valence: [number, number]; - energy: [number, number]; - danceability: [number, number]; - bpm: [number, number]; - }>({ - valence: [0, 100], - energy: [0, 100], - danceability: [0, 100], - bpm: [60, 180], - }); - - // ML mood sliders state (Advanced mode) - const [mlMoods, setMlMoods] = useState<{ - moodHappy: [number, number]; - moodSad: [number, number]; - moodRelaxed: [number, number]; - moodAggressive: [number, number]; - moodParty: [number, number]; - moodAcoustic: [number, number]; - moodElectronic: [number, number]; - }>({ - moodHappy: [0, 100], - moodSad: [0, 100], - moodRelaxed: [0, 100], - moodAggressive: [0, 100], - moodParty: [0, 100], - moodAcoustic: [0, 100], - moodElectronic: [0, 100], - }); // Handle visibility animation useEffect(() => { @@ -67,7 +128,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) { const loadPresets = async () => { try { - const data = await api.getMoodPresets(); + const data = await api.getMoodBucketPresets(); setPresets(data); } catch (error) { console.error("Failed to load mood presets:", error); @@ -77,16 +138,16 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) { } }; - const generateMix = async (preset: MoodPreset) => { - setGenerating(preset.id); + const generateMix = async (mood: MoodType) => { + const config = MOOD_CONFIG[mood]; + setGenerating(mood); + try { - const mix = await api.generateMoodMix({ - ...preset.params, - limit: 15, - }); + // Get the mix from pre-computed bucket (instant!) + const mix = await api.getMoodBucketMix(mood); if (mix.tracks && mix.tracks.length > 0) { - const tracks: Track[] = mix.tracks.map((t: any) => ({ + const tracks: Track[] = mix.tracks.map((t) => ({ id: t.id, title: t.title, artist: { @@ -101,153 +162,61 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) { duration: t.duration, })); + // Start playback playTracks(tracks, 0); - toast.success(`Your ${preset.name} Mix`, { + + // Save as user's active mood mix + await api.saveMoodBucketMix(mood); + + toast.success(`${config.label} Mix`, { description: `Playing ${tracks.length} tracks`, }); - // Save these params as user's mood mix preferences (include preset name for mix title) - try { - await api.post('/mixes/mood/save-preferences', { - ...preset.params, - limit: 15, - presetName: preset.name - }); - } catch (err) { - console.error("Failed to save mood preferences:", err); - } + // Force immediate refetch of mixes on home page + // Using refetchQueries instead of invalidateQueries for immediate update + await queryClient.refetchQueries({ queryKey: ["mixes"] }); - // Notify other components to refresh mixes + // Also dispatch events for any other listeners window.dispatchEvent(new CustomEvent("mix-generated")); window.dispatchEvent(new CustomEvent("mixes-updated")); + onClose(); } else { - toast.error("Not enough matching tracks", { + toast.error("Not enough tracks for this mood", { description: - "Try a different mood or wait for more analysis", + "Try analyzing more music or choose a different mood", }); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to generate mood mix:", error); - toast.error(error?.error || "Failed to generate mix"); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to generate mix"; + toast.error(errorMessage); } finally { setGenerating(null); } }; - const generateCustomMix = async () => { - setGenerating("custom"); - try { - const params: MoodMixParams = { - valence: { - min: customParams.valence[0] / 100, - max: customParams.valence[1] / 100, - }, - energy: { - min: customParams.energy[0] / 100, - max: customParams.energy[1] / 100, - }, - danceability: { - min: customParams.danceability[0] / 100, - max: customParams.danceability[1] / 100, - }, - bpm: { min: customParams.bpm[0], max: customParams.bpm[1] }, - limit: 15, - }; - - // Add ML mood params if advanced mode is enabled - if (showAdvanced) { - params.moodHappy = { - min: mlMoods.moodHappy[0] / 100, - max: mlMoods.moodHappy[1] / 100, - }; - params.moodSad = { - min: mlMoods.moodSad[0] / 100, - max: mlMoods.moodSad[1] / 100, - }; - params.moodRelaxed = { - min: mlMoods.moodRelaxed[0] / 100, - max: mlMoods.moodRelaxed[1] / 100, - }; - params.moodAggressive = { - min: mlMoods.moodAggressive[0] / 100, - max: mlMoods.moodAggressive[1] / 100, - }; - params.moodParty = { - min: mlMoods.moodParty[0] / 100, - max: mlMoods.moodParty[1] / 100, - }; - params.moodAcoustic = { - min: mlMoods.moodAcoustic[0] / 100, - max: mlMoods.moodAcoustic[1] / 100, - }; - params.moodElectronic = { - min: mlMoods.moodElectronic[0] / 100, - max: mlMoods.moodElectronic[1] / 100, - }; - } - - const mix = await api.generateMoodMix(params); - - if (mix.tracks && mix.tracks.length > 0) { - const tracks: Track[] = mix.tracks.map((t: any) => ({ - id: t.id, - title: t.title, - artist: { - name: t.album?.artist?.name || "Unknown Artist", - id: t.album?.artist?.id, - }, - album: { - title: t.album?.title || "Unknown Album", - coverArt: t.album?.coverUrl, - id: t.albumId, - }, - duration: t.duration, - })); - - playTracks(tracks, 0); - toast.success("Your Custom Mix", { - description: `Playing ${tracks.length} tracks`, - }); - - // Save these params as user's mood mix preferences - try { - await api.post('/mixes/mood/save-preferences', { - ...params, - presetName: "Custom" - }); - } catch (err) { - console.error("Failed to save mood preferences:", err); - } - - // Notify other components to refresh mixes - window.dispatchEvent(new CustomEvent("mix-generated")); - window.dispatchEvent(new CustomEvent("mixes-updated")); - onClose(); - } else { - toast.error("Not enough matching tracks", { - description: "Try widening your parameters", - }); - } - } catch (error: any) { - console.error("Failed to generate custom mix:", error); - toast.error(error?.error || "Failed to generate mix"); - } finally { - setGenerating(null); - } + // Get track count for a mood + const getTrackCount = (mood: MoodType): number => { + // MoodBucketPreset uses 'id' as the mood identifier + const preset = presets.find((p) => p.id === mood); + return preset?.trackCount || 0; }; if (!isVisible && !isOpen) return null; return (
e.stopPropagation()} @@ -263,7 +232,7 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) { Mood Mixer

- Generate a mix based on your vibe + Pick your vibe

@@ -282,354 +251,78 @@ export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
) : ( - <> - {/* Toggle between presets and custom */} -
- - -
+ /* 3x3 Mood Grid */ +
+ {MOOD_ORDER.map((mood) => { + const config = MOOD_CONFIG[mood]; + const Icon = config.icon; + const trackCount = getTrackCount(mood); + const isDisabled = trackCount < 5; + const isGenerating = generating === mood; - {!showCustom ? ( - /* Preset Grid */ -
- {presets.map((preset) => ( - - ))} -
- ) : ( - /* Custom Sliders */ -
- - setCustomParams((p) => ({ - ...p, - valence: v, - })) - } - min={0} - max={100} - lowLabel="Sad" - highLabel="Happy" - /> - - setCustomParams((p) => ({ - ...p, - energy: v, - })) - } - min={0} - max={100} - lowLabel="Calm" - highLabel="Energetic" - /> - - setCustomParams((p) => ({ - ...p, - danceability: v, - })) - } - min={0} - max={100} - lowLabel="Static" - highLabel="Groovy" - /> - - setCustomParams((p) => ({ - ...p, - bpm: v, - })) - } - min={60} - max={180} - lowLabel="Slow" - highLabel="Fast" - showValues - /> - - {/* Advanced Mode Toggle */} + return ( - - {/* ML Mood Sliders (Advanced Mode) */} - {showAdvanced && ( -
-

- Fine-tune using ML-detected mood predictions -

- - setMlMoods((p) => ({ ...p, moodHappy: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodSad: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodRelaxed: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodAggressive: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodParty: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodAcoustic: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> - - setMlMoods((p) => ({ ...p, moodElectronic: v })) - } - min={0} - max={100} - lowLabel="Low" - highLabel="High" - /> + {/* Icon */} +
+ {isGenerating ? ( + + ) : ( + + )}
- )} - -
- )} - + ); + })} +
)} + + {/* Help text */} +

+ Moods are based on audio analysis of your library +

); } - -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) => { - const newMin = Math.min(Number(e.target.value), value[1] - 1); - onChange([newMin, value[1]]); - }; - - const handleMaxChange = (e: React.ChangeEvent) => { - 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 ( -
-
- {label} - {showValues && ( - - {value[0]} - {value[1]} - - )} -
- -
- {/* Track background - pointer-events-none so inputs receive clicks */} -
- - {/* Active range - pointer-events-none so inputs receive clicks */} -
- - {/* Min slider */} - - - {/* Max slider */} - - - {/* Thumb indicators */} -
-
-
- -
- {lowLabel} - {highLabel} -
-
- ); -} diff --git a/frontend/components/layout/AuthenticatedLayout.tsx b/frontend/components/layout/AuthenticatedLayout.tsx index 98fa34d..7388d16 100644 --- a/frontend/components/layout/AuthenticatedLayout.tsx +++ b/frontend/components/layout/AuthenticatedLayout.tsx @@ -113,7 +113,7 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */} {isMobileOrTablet ? ( diff --git a/frontend/components/player/HowlerAudioElement.tsx b/frontend/components/player/HowlerAudioElement.tsx index 2ae97cd..6aaeab5 100644 --- a/frontend/components/player/HowlerAudioElement.tsx +++ b/frontend/components/player/HowlerAudioElement.tsx @@ -6,7 +6,14 @@ import { useAudioControls } from "@/lib/audio-controls-context"; import { api } from "@/lib/api"; import { howlerEngine } from "@/lib/howler-engine"; import { audioSeekEmitter } from "@/lib/audio-seek-emitter"; -import { useEffect, useLayoutEffect, useRef, memo, useCallback, useMemo } from "react"; +import { + useEffect, + useLayoutEffect, + useRef, + memo, + useCallback, + useMemo, +} from "react"; function podcastDebugEnabled(): boolean { try { @@ -51,6 +58,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { const { isPlaying, setCurrentTime, + setCurrentTimeFromEngine, setDuration, setIsPlaying, isBuffering, @@ -59,6 +67,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { canSeek, setCanSeek, setDownloadProgress, + lockSeek, } = useAudioPlayback(); // Controls context @@ -83,6 +92,11 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { const loadListenerRef = useRef<(() => void) | null>(null); const loadErrorListenerRef = useRef<(() => void) | null>(null); const cachePollingLoadListenerRef = useRef<(() => void) | null>(null); + // Counter to track seek operations and abort stale ones + const seekOperationIdRef = useRef(0); + // Debounce timer for rapid podcast seeks + const seekDebounceRef = useRef(null); + const pendingSeekTimeRef = useRef(null); // Reset duration when nothing is playing useEffect(() => { @@ -94,7 +108,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { // Subscribe to Howler events useEffect(() => { const handleTimeUpdate = (data: { time: number }) => { - setCurrentTime(data.time); + // Use setCurrentTimeFromEngine to respect seek lock + // This prevents stale timeupdate events from overwriting optimistic seek updates + setCurrentTimeFromEngine(data.time); }; const handleLoad = (data: { duration: number }) => { @@ -131,15 +147,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { console.error("[HowlerAudioElement] Playback error:", data.error); setIsPlaying(false); isUserInitiatedRef.current = false; - + if (playbackType === "track") { if (queue.length > 1) { - console.log("[HowlerAudioElement] Track failed, trying next in queue"); + console.log( + "[HowlerAudioElement] Track failed, trying next in queue" + ); lastTrackIdRef.current = null; isLoadingRef.current = false; next(); } else { - console.log("[HowlerAudioElement] Track failed, no more in queue - clearing"); + console.log( + "[HowlerAudioElement] Track failed, no more in queue - clearing" + ); lastTrackIdRef.current = null; isLoadingRef.current = false; setCurrentTrack(null); @@ -164,7 +184,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { const handlePause = () => { if (isLoadingRef.current) return; if (seekReloadInProgressRef.current) return; - + if (!isUserInitiatedRef.current) { setIsPlaying(false); } @@ -188,7 +208,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { howlerEngine.off("play", handlePlay); howlerEngine.off("pause", handlePause); }; - }, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTime, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]); + }, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]); // Save audiobook progress const saveAudiobookProgress = useCallback( @@ -287,10 +307,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { if (isSeekingRef.current) { return; } - + const shouldPlay = lastPlayingStateRef.current || isPlaying; const isCurrentlyPlaying = howlerEngine.isPlaying(); - + if (shouldPlay && !isCurrentlyPlaying) { howlerEngine.seek(0); howlerEngine.play(); @@ -330,7 +350,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { if (streamUrl) { const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying(); - + const fallbackDuration = currentTrack?.duration || currentAudiobook?.duration || @@ -386,7 +406,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { }); } - const shouldAutoPlay = lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad; + const shouldAutoPlay = + lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad; if (shouldAutoPlay) { howlerEngine.play(); @@ -522,6 +543,9 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { // Poll for podcast cache and reload when ready const startCachePolling = useCallback( (podcastId: string, episodeId: string, targetTime: number) => { + // Capture the current seek operation ID + const pollingSeekId = seekOperationIdRef.current; + if (cachePollingRef.current) { clearInterval(cachePollingRef.current); } @@ -530,6 +554,19 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { const maxPolls = 60; cachePollingRef.current = setInterval(async () => { + // Check if a newer seek operation has started + if (seekOperationIdRef.current !== pollingSeekId) { + if (cachePollingRef.current) { + clearInterval(cachePollingRef.current); + cachePollingRef.current = null; + } + podcastDebugLog("cache polling aborted (stale)", { + pollingSeekId, + currentId: seekOperationIdRef.current, + }); + return; + } + pollCount++; try { @@ -537,6 +574,16 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { podcastId, episodeId ); + + // Re-check after async operation + if (seekOperationIdRef.current !== pollingSeekId) { + if (cachePollingRef.current) { + clearInterval(cachePollingRef.current); + cachePollingRef.current = null; + } + return; + } + podcastDebugLog("cache poll", { podcastId, episodeId, @@ -553,14 +600,20 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { cachePollingRef.current = null; } - podcastDebugLog("cache ready -> howlerEngine.reload()", { - podcastId, - episodeId, - targetTime, - }); + podcastDebugLog( + "cache ready -> howlerEngine.reload()", + { + podcastId, + episodeId, + targetTime, + } + ); // Clean up any previous cache polling load listener if (cachePollingLoadListenerRef.current) { - howlerEngine.off("load", cachePollingLoadListenerRef.current); + howlerEngine.off( + "load", + cachePollingLoadListenerRef.current + ); cachePollingLoadListenerRef.current = null; } @@ -570,6 +623,15 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { howlerEngine.off("load", onLoad); cachePollingLoadListenerRef.current = null; + // Check if still current before acting + if (seekOperationIdRef.current !== pollingSeekId) { + podcastDebugLog( + "cache polling load callback aborted (stale)", + { pollingSeekId } + ); + return; + } + howlerEngine.seek(targetTime); setCurrentTime(targetTime); howlerEngine.play(); @@ -594,12 +656,17 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { cachePollingRef.current = null; } - console.warn("[HowlerAudioElement] Cache polling timeout"); + console.warn( + "[HowlerAudioElement] Cache polling timeout" + ); setIsBuffering(false); setTargetSeekPosition(null); } } catch (error) { - console.error("[HowlerAudioElement] Cache polling error:", error); + console.error( + "[HowlerAudioElement] Cache polling error:", + error + ); } }, 2000); }, @@ -608,93 +675,219 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { // Handle seeking via event emitter useEffect(() => { + // Store previous time to detect large skips vs fine scrubbing + let previousTime = howlerEngine.getCurrentTime(); + const handleSeek = async (time: number) => { + // Increment seek operation ID to track this specific seek + seekOperationIdRef.current += 1; + const thisSeekId = seekOperationIdRef.current; + const wasPlayingAtSeekStart = howlerEngine.isPlaying(); - setCurrentTime(time); + // Detect if this is a large skip (like 30s buttons) vs fine scrubbing + const timeDelta = Math.abs(time - previousTime); + const isLargeSkip = timeDelta >= 10; // 10+ seconds = large skip (30s, 15s buttons) + previousTime = time; + + // DON'T set currentTime here for podcasts - the seek() in audio-controls-context + // already did it optimistically. Setting it again causes a race condition. + // We only update it after the seek actually completes. if (playbackType === "podcast" && currentPodcast) { + // Cancel any previous seek-related operations if (seekCheckTimeoutRef.current) { clearTimeout(seekCheckTimeoutRef.current); + seekCheckTimeoutRef.current = null; } + // Cancel any pending cache polling from previous seek + if (cachePollingRef.current) { + clearInterval(cachePollingRef.current); + cachePollingRef.current = null; + } + + // Cancel previous reload listener + if (seekReloadListenerRef.current) { + howlerEngine.off("load", seekReloadListenerRef.current); + seekReloadListenerRef.current = null; + } + + // Cancel previous cache polling load listener + if (cachePollingLoadListenerRef.current) { + howlerEngine.off( + "load", + cachePollingLoadListenerRef.current + ); + cachePollingLoadListenerRef.current = null; + } + + // Cancel any pending debounced seek + if (seekDebounceRef.current) { + clearTimeout(seekDebounceRef.current); + seekDebounceRef.current = null; + } + + // Store the pending seek time - debounce will use the latest value + pendingSeekTimeRef.current = time; + const [podcastId, episodeId] = currentPodcast.id.split(":"); - try { - const status = await api.getPodcastEpisodeCacheStatus( - podcastId, - episodeId - ); - - if (status.cached) { - podcastDebugLog("seek: cached=true, using reload+seek pattern", { - time, - podcastId, - episodeId, - }); - - if (seekReloadListenerRef.current) { - howlerEngine.off("load", seekReloadListenerRef.current); - seekReloadListenerRef.current = null; - } - - seekReloadInProgressRef.current = true; - - howlerEngine.reload(); - - const onLoad = () => { - howlerEngine.off("load", onLoad); - seekReloadListenerRef.current = null; - seekReloadInProgressRef.current = false; - - howlerEngine.seek(time); - setCurrentTime(time); - - if (wasPlayingAtSeekStart) { - howlerEngine.play(); - setIsPlaying(true); - } - }; - - seekReloadListenerRef.current = onLoad; - howlerEngine.on("load", onLoad); + + // Execute the seek logic - immediately for large skips, debounced for fine scrubbing + const executeSeek = async () => { + const seekTime = pendingSeekTimeRef.current ?? time; + pendingSeekTimeRef.current = null; + + // Check if this seek is still current + if (seekOperationIdRef.current !== thisSeekId) { return; } - } catch (e) { - console.warn("[HowlerAudioElement] Could not check cache status:", e); - } - - howlerEngine.seek(time); - - seekCheckTimeoutRef.current = setTimeout(() => { + try { - const actualPos = howlerEngine.getActualCurrentTime(); - const seekFailed = time > 30 && actualPos < 30; - podcastDebugLog("seek check", { - time, - actualPos, - seekFailed, + const status = await api.getPodcastEpisodeCacheStatus( podcastId, - episodeId, - }); + episodeId + ); - if (seekFailed) { - howlerEngine.pause(); - setIsBuffering(true); - setTargetSeekPosition(time); - setIsPlaying(false); - startCachePolling(podcastId, episodeId, time); + // Check if this seek operation is still current + if (seekOperationIdRef.current !== thisSeekId) { + podcastDebugLog("seek: aborted (stale operation)", { + thisSeekId, + currentId: seekOperationIdRef.current, + }); + return; + } + + if (status.cached) { + // For cached podcasts, try direct seek first (faster than reload) + podcastDebugLog( + "seek: cached=true, trying direct seek first", + { + time: seekTime, + podcastId, + episodeId, + } + ); + + // Direct seek - howlerEngine now handles seek locking internally + howlerEngine.seek(seekTime); + + // Verify seek succeeded after a short delay + setTimeout(() => { + if (seekOperationIdRef.current !== thisSeekId) { + return; + } + + const actualPos = howlerEngine.getActualCurrentTime(); + const seekSucceeded = Math.abs(actualPos - seekTime) < 5; // Within 5 seconds + + podcastDebugLog("seek: direct seek result", { + seekTime, + actualPos, + seekSucceeded, + }); + + if (!seekSucceeded) { + // Direct seek failed, fall back to reload pattern + podcastDebugLog("seek: direct seek failed, falling back to reload"); + seekReloadInProgressRef.current = true; + + howlerEngine.reload(); + + const onLoad = () => { + howlerEngine.off("load", onLoad); + seekReloadListenerRef.current = null; + seekReloadInProgressRef.current = false; + + if (seekOperationIdRef.current !== thisSeekId) { + return; + } + + howlerEngine.seek(seekTime); + + if (wasPlayingAtSeekStart) { + howlerEngine.play(); + setIsPlaying(true); + } + }; + + seekReloadListenerRef.current = onLoad; + howlerEngine.on("load", onLoad); + } else { + // Seek succeeded - resume playback if needed + if (wasPlayingAtSeekStart && !howlerEngine.isPlaying()) { + howlerEngine.play(); + } + } + }, 150); + + return; } } catch (e) { - console.error("[HowlerAudioElement] Seek check error:", e); + console.warn( + "[HowlerAudioElement] Could not check cache status:", + e + ); } - }, 1000); + + // Check if still current after async operation + if (seekOperationIdRef.current !== thisSeekId) { + return; + } + + // Not cached - try direct seek + howlerEngine.seek(seekTime); + + seekCheckTimeoutRef.current = setTimeout(() => { + // Check if this seek is still current + if (seekOperationIdRef.current !== thisSeekId) { + return; + } + + try { + const actualPos = howlerEngine.getActualCurrentTime(); + const seekFailed = seekTime > 30 && actualPos < 30; + podcastDebugLog("seek check", { + time: seekTime, + actualPos, + seekFailed, + podcastId, + episodeId, + }); + + if (seekFailed) { + howlerEngine.pause(); + setIsBuffering(true); + setTargetSeekPosition(seekTime); + setIsPlaying(false); + startCachePolling(podcastId, episodeId, seekTime); + } + } catch (e) { + console.error( + "[HowlerAudioElement] Seek check error:", + e + ); + } + }, 1000); + }; + + // For large skips (30s buttons), execute immediately for responsive feel + // For fine scrubbing (progress bar), debounce to prevent spamming + if (isLargeSkip) { + podcastDebugLog("seek: large skip, executing immediately", { timeDelta, time }); + executeSeek(); + } else { + podcastDebugLog("seek: fine scrub, debouncing", { timeDelta, time }); + seekDebounceRef.current = setTimeout(executeSeek, 150); + } + return; } - + // For audiobooks and tracks, set seeking flag to prevent load effect interference isSeekingRef.current = true; howlerEngine.seek(time); - + // Reset seeking flag after a short delay to allow seek to complete setTimeout(() => { isSeekingRef.current = false; @@ -703,7 +896,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { const unsubscribe = audioSeekEmitter.subscribe(handleSeek); return unsubscribe; - }, [setCurrentTime, playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]); + }, [playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]); // Cleanup cache polling, seek timeout, and seek-reload listener on unmount useEffect(() => { @@ -718,6 +911,10 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() { howlerEngine.off("load", seekReloadListenerRef.current); seekReloadListenerRef.current = null; } + if (seekDebounceRef.current) { + clearTimeout(seekDebounceRef.current); + seekDebounceRef.current = null; + } }; }, []); diff --git a/frontend/components/player/MiniPlayer.tsx b/frontend/components/player/MiniPlayer.tsx index 44cc096..a973552 100644 --- a/frontend/components/player/MiniPlayer.tsx +++ b/frontend/components/player/MiniPlayer.tsx @@ -314,11 +314,12 @@ export function MiniPlayer() { return (
- {/* Player content */} + {/* Player content - more spacious padding */}
setPlayerMode("overlay")} > - {/* Album Art */} -
+ {/* Album Art - slightly larger */} +
{coverUrl ? ( {title} ) : (
- +
)}
{/* Track Info */}
-

+

{title}

-

+

{subtitle}

{/* Controls - Vibe & Play/Pause */}
e.stopPropagation()} > {/* Vibe Button */} @@ -382,7 +383,7 @@ export function MiniPlayer() { onClick={handleVibeToggle} disabled={!canSkip || isVibeLoading} className={cn( - "w-9 h-9 flex items-center justify-center rounded-full transition-colors", + "w-10 h-10 flex items-center justify-center rounded-full transition-colors", !canSkip ? "text-gray-600" : vibeMode @@ -396,9 +397,9 @@ export function MiniPlayer() { } > {isVibeLoading ? ( - + ) : ( - + )} @@ -414,7 +415,7 @@ export function MiniPlayer() { } }} className={cn( - "w-9 h-9 rounded-full flex items-center justify-center transition shadow-md", + "w-10 h-10 rounded-full flex items-center justify-center transition shadow-md", isBuffering ? "bg-white/80 text-black" : "bg-white text-black hover:scale-105" @@ -428,11 +429,11 @@ export function MiniPlayer() { } > {isBuffering ? ( - + ) : isPlaying ? ( - + ) : ( - + )}
diff --git a/frontend/features/artist/components/SimilarArtists.tsx b/frontend/features/artist/components/SimilarArtists.tsx index 63bcff1..352fb72 100644 --- a/frontend/features/artist/components/SimilarArtists.tsx +++ b/frontend/features/artist/components/SimilarArtists.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { SimilarArtist } from "../types"; -import { Music } from "lucide-react"; +import { Music, Library } from "lucide-react"; import { api } from "@/lib/api"; interface SimilarArtistsProps { @@ -20,10 +20,11 @@ export function SimilarArtists({ return (
-

- Fans Also Like -

-
+

Fans Also Like

+
{similarArtists.map((artist, index) => { const rawImage = artist.coverArt || artist.image; const imageUrl = rawImage @@ -33,17 +34,22 @@ export function SimilarArtists({ ? Math.round(artist.weight * 100) : null; + // For library artists, use the library ID; otherwise use mbid or name + const navigationId = artist.inLibrary + ? artist.id + : artist.mbid || artist.id; + return (
onNavigate(artist.mbid || artist.id)} + onClick={() => onNavigate(navigationId)} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { e.preventDefault(); - onNavigate(artist.mbid || artist.id); + onNavigate(navigationId); } }} className="bg-transparent hover:bg-white/5 transition-all p-3 rounded-md cursor-pointer group" @@ -64,6 +70,15 @@ export function SimilarArtists({
)} + {/* Library indicator badge */} + {artist.inLibrary && ( +
+ +
+ )}
{/* Artist Name */} @@ -71,13 +86,13 @@ export function SimilarArtists({ {artist.name} - {/* Album Count */} + {/* Album Count - show owned count if in library */}

{artist.ownedAlbumCount && artist.ownedAlbumCount > 0 - ? `${artist.ownedAlbumCount}/${artist.albumCount} albums` - : artist.albumCount && artist.albumCount > 0 - ? `${artist.albumCount} albums` + ? `${artist.ownedAlbumCount} album${ + artist.ownedAlbumCount > 1 ? "s" : "" + } in library` : "Artist"}

diff --git a/frontend/features/artist/types.ts b/frontend/features/artist/types.ts index b1673a2..4e43ad9 100644 --- a/frontend/features/artist/types.ts +++ b/frontend/features/artist/types.ts @@ -56,4 +56,5 @@ export interface SimilarArtist { albumCount?: number; ownedAlbumCount?: number; weight?: number; + inLibrary?: boolean; } diff --git a/frontend/features/home/hooks/useHomeData.ts b/frontend/features/home/hooks/useHomeData.ts index 01efc56..da4f52d 100644 --- a/frontend/features/home/hooks/useHomeData.ts +++ b/frontend/features/home/hooks/useHomeData.ts @@ -5,10 +5,10 @@ * and providing refresh functionality for mixes. */ -import { useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useAuth } from '@/lib/auth-context'; -import { toast } from 'sonner'; +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/lib/auth-context"; +import { toast } from "sonner"; import type { Artist, ListenedItem, @@ -16,7 +16,7 @@ import type { Audiobook, Mix, PopularArtist, -} from '../types'; +} from "../types"; import { useRecentlyListenedQuery, useRecentlyAddedQuery, @@ -28,7 +28,7 @@ import { useRefreshMixesMutation, useBrowseAllQuery, queryKeys, -} from '@/hooks/useQueries'; +} from "@/hooks/useQueries"; interface PlaylistPreview { id: string; @@ -81,27 +81,38 @@ export function useHomeData(): UseHomeDataReturn { const queryClient = useQueryClient(); // Listen for mixes-updated event (fired when user saves mood preferences) + // Use refetchQueries instead of invalidateQueries to force immediate UI update useEffect(() => { const handleMixesUpdated = () => { - queryClient.invalidateQueries({ queryKey: queryKeys.mixes() }); + // refetchQueries forces immediate refetch, unlike invalidateQueries which only marks stale + queryClient.refetchQueries({ queryKey: queryKeys.mixes() }); }; - window.addEventListener('mixes-updated', handleMixesUpdated); - return () => window.removeEventListener('mixes-updated', handleMixesUpdated); + window.addEventListener("mixes-updated", handleMixesUpdated); + return () => + window.removeEventListener("mixes-updated", handleMixesUpdated); }, [queryClient]); // React Query hooks - these automatically handle caching, refetching, and loading states - const { data: recentlyListenedData, isLoading: isLoadingListened } = useRecentlyListenedQuery(10); - const { data: recentlyAddedData, isLoading: isLoadingAdded } = useRecentlyAddedQuery(10); - const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendationsQuery(10); + const { data: recentlyListenedData, isLoading: isLoadingListened } = + useRecentlyListenedQuery(10); + const { data: recentlyAddedData, isLoading: isLoadingAdded } = + useRecentlyAddedQuery(10); + const { data: recommendedData, isLoading: isLoadingRecommended } = + useRecommendationsQuery(10); const { data: mixesData, isLoading: isLoadingMixes } = useMixesQuery(); - const { data: popularData, isLoading: isLoadingPopular } = usePopularArtistsQuery(20); - const { data: podcastsData, isLoading: isLoadingPodcasts } = useTopPodcastsQuery(10); - const { data: audiobooksData, isLoading: isLoadingAudiobooks } = useAudiobooksQuery(); - const { data: browseData, isLoading: isBrowseLoading } = useBrowseAllQuery(); + const { data: popularData, isLoading: isLoadingPopular } = + usePopularArtistsQuery(20); + const { data: podcastsData, isLoading: isLoadingPodcasts } = + useTopPodcastsQuery(10); + const { data: audiobooksData, isLoading: isLoadingAudiobooks } = + useAudiobooksQuery(); + const { data: browseData, isLoading: isBrowseLoading } = + useBrowseAllQuery(); // Mutation for refreshing mixes - const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } = useRefreshMixesMutation(); + const { mutateAsync: refreshMixes, isPending: isRefreshingMixes } = + useRefreshMixesMutation(); /** * Refresh mixes and update cache @@ -109,10 +120,10 @@ export function useHomeData(): UseHomeDataReturn { const handleRefreshMixes = async () => { try { await refreshMixes(); - toast.success('Mixes refreshed! Check out your new daily picks'); + toast.success("Mixes refreshed! Check out your new daily picks"); } catch (error) { - console.error('Failed to refresh mixes:', error); - toast.error('Failed to refresh mixes'); + console.error("Failed to refresh mixes:", error); + toast.error("Failed to refresh mixes"); } }; @@ -136,8 +147,12 @@ export function useHomeData(): UseHomeDataReturn { recommended: recommendedData?.artists || [], mixes: Array.isArray(mixesData) ? mixesData : [], popularArtists: popularData?.artists || [], - recentPodcasts: Array.isArray(podcastsData) ? podcastsData.slice(0, 10) : [], - recentAudiobooks: Array.isArray(audiobooksData) ? audiobooksData.slice(0, 10) : [], + recentPodcasts: Array.isArray(podcastsData) + ? podcastsData.slice(0, 10) + : [], + recentAudiobooks: Array.isArray(audiobooksData) + ? audiobooksData.slice(0, 10) + : [], featuredPlaylists: browseData?.playlists || [], isLoading, isRefreshingMixes, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2f4d3d5..d73be7a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,6 +1,6 @@ const AUTH_TOKEN_KEY = "auth_token"; -// Mood Mix Types +// Mood Mix Types (Legacy - for old presets endpoint) export interface MoodPreset { id: string; name: string; @@ -29,6 +29,43 @@ export interface MoodMixParams { limit?: number; } +// New Mood Bucket Types (simplified mood system) +export type MoodType = + | "happy" + | "sad" + | "chill" + | "energetic" + | "party" + | "focus" + | "melancholy" + | "aggressive" + | "acoustic"; + +export interface MoodBucketPreset { + id: MoodType; + name: string; + color: string; + icon: string; + trackCount: number; +} + +export interface MoodBucketMix { + id: string; + mood: MoodType; + name: string; + description: string; + trackIds: string[]; + coverUrls: string[]; + trackCount: number; + color: string; + tracks?: any[]; +} + +export interface SavedMoodMixResponse { + success: boolean; + mix: MoodBucketMix & { generatedAt: string }; +} + // Dynamically determine API URL based on configuration const getApiBaseUrl = () => { // Server-side rendering @@ -1225,7 +1262,7 @@ class ApiClient { ); } - // Mood on Demand + // Mood on Demand (Legacy) async getMoodPresets() { return this.request("/mixes/mood/presets"); } @@ -1237,6 +1274,30 @@ class ApiClient { }); } + // New Mood Bucket System (simplified, pre-computed) + async getMoodBucketPresets() { + return this.request("/mixes/mood/buckets/presets"); + } + + async getMoodBucketMix(mood: MoodType) { + return this.request(`/mixes/mood/buckets/${mood}`); + } + + async saveMoodBucketMix(mood: MoodType) { + return this.request( + `/mixes/mood/buckets/${mood}/save`, + { method: "POST" } + ); + } + + async backfillMoodBuckets() { + return this.request<{ + success: boolean; + processed: number; + assigned: number; + }>("/mixes/mood/buckets/backfill", { method: "POST" }); + } + // Enrichment async getEnrichmentSettings() { return this.request("/enrichment/settings"); diff --git a/frontend/lib/audio-controls-context.tsx b/frontend/lib/audio-controls-context.tsx index aa4a73f..0bb2f3f 100644 --- a/frontend/lib/audio-controls-context.tsx +++ b/frontend/lib/audio-controls-context.tsx @@ -663,6 +663,10 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) { ? Math.min(Math.max(time, 0), maxDuration) : Math.max(time, 0); + // Lock seek to prevent stale timeupdate events from overwriting optimistic update + // This is especially important for podcasts where seeking may require audio reload + playback.lockSeek(clampedTime); + // Optimistically update local playback time for instant UI feedback playback.setCurrentTime(clampedTime); diff --git a/frontend/lib/audio-playback-context.tsx b/frontend/lib/audio-playback-context.tsx index 36ad00c..b73c7cd 100644 --- a/frontend/lib/audio-playback-context.tsx +++ b/frontend/lib/audio-playback-context.tsx @@ -6,6 +6,7 @@ import { useState, useEffect, useRef, + useCallback, ReactNode, useMemo, } from "react"; @@ -18,13 +19,17 @@ interface AudioPlaybackContextType { targetSeekPosition: number | null; canSeek: boolean; downloadProgress: number | null; // 0-100 for downloading, null for not downloading + isSeekLocked: boolean; // True when a seek operation is in progress setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; + setCurrentTimeFromEngine: (time: number) => void; // For timeupdate events - respects seek lock setDuration: (duration: number) => void; setIsBuffering: (buffering: boolean) => void; setTargetSeekPosition: (position: number | null) => void; setCanSeek: (canSeek: boolean) => void; setDownloadProgress: (progress: number | null) => void; + lockSeek: (targetTime: number) => void; // Lock updates during seek + unlockSeek: () => void; // Unlock after seek completes } const AudioPlaybackContext = createContext< @@ -42,12 +47,73 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isBuffering, setIsBuffering] = useState(false); - const [targetSeekPosition, setTargetSeekPosition] = useState(null); + const [targetSeekPosition, setTargetSeekPosition] = useState( + null + ); const [canSeek, setCanSeek] = useState(true); // Default true for music, false for uncached podcasts - const [downloadProgress, setDownloadProgress] = useState(null); + const [downloadProgress, setDownloadProgress] = useState( + null + ); const [isHydrated, setIsHydrated] = useState(false); const lastSaveTimeRef = useRef(0); + // Seek lock state - prevents stale timeupdate events from overwriting optimistic UI updates + const [isSeekLocked, setIsSeekLocked] = useState(false); + const seekTargetRef = useRef(null); + const seekLockTimeoutRef = useRef(null); + + // Lock the seek state - ignores timeupdate events until audio catches up or timeout + const lockSeek = useCallback((targetTime: number) => { + setIsSeekLocked(true); + seekTargetRef.current = targetTime; + + // Clear any existing timeout + if (seekLockTimeoutRef.current) { + clearTimeout(seekLockTimeoutRef.current); + } + + // Auto-unlock after 500ms as a safety measure + seekLockTimeoutRef.current = setTimeout(() => { + setIsSeekLocked(false); + seekTargetRef.current = null; + seekLockTimeoutRef.current = null; + }, 500); + }, []); + + // Unlock the seek state + const unlockSeek = useCallback(() => { + setIsSeekLocked(false); + seekTargetRef.current = null; + if (seekLockTimeoutRef.current) { + clearTimeout(seekLockTimeoutRef.current); + seekLockTimeoutRef.current = null; + } + }, []); + + // setCurrentTimeFromEngine - for timeupdate events from Howler + // Respects seek lock to prevent stale updates causing flicker + const setCurrentTimeFromEngine = useCallback( + (time: number) => { + if (isSeekLocked && seekTargetRef.current !== null) { + // During seek, only accept updates that are close to our target + // This prevents old positions from briefly showing during seek + const isNearTarget = Math.abs(time - seekTargetRef.current) < 2; + if (!isNearTarget) { + return; // Ignore stale position update + } + // Position is near target - seek completed, unlock + setIsSeekLocked(false); + seekTargetRef.current = null; + if (seekLockTimeoutRef.current) { + clearTimeout(seekLockTimeoutRef.current); + seekLockTimeoutRef.current = null; + } + } + setCurrentTime(time); + }, + [isSeekLocked] + ); + // Restore currentTime from localStorage on mount // NOTE: Do NOT touch isPlaying here - let user actions control it useEffect(() => { @@ -63,6 +129,15 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) { setIsHydrated(true); }, []); + // Cleanup seek lock timeout on unmount + useEffect(() => { + return () => { + if (seekLockTimeoutRef.current) { + clearTimeout(seekLockTimeoutRef.current); + } + }; + }, []); + // Save currentTime to localStorage (throttled to avoid excessive writes) useEffect(() => { if (!isHydrated || typeof window === "undefined") return; @@ -92,15 +167,31 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) { targetSeekPosition, canSeek, downloadProgress, + isSeekLocked, setIsPlaying, setCurrentTime, + setCurrentTimeFromEngine, setDuration, setIsBuffering, setTargetSeekPosition, setCanSeek, setDownloadProgress, + lockSeek, + unlockSeek, }), - [isPlaying, currentTime, duration, isBuffering, targetSeekPosition, canSeek, downloadProgress] + [ + isPlaying, + currentTime, + duration, + isBuffering, + targetSeekPosition, + canSeek, + downloadProgress, + isSeekLocked, + setCurrentTimeFromEngine, + lockSeek, + unlockSeek, + ] ); return ( diff --git a/frontend/lib/howler-engine.ts b/frontend/lib/howler-engine.ts index 6a94ae1..5be413d 100644 --- a/frontend/lib/howler-engine.ts +++ b/frontend/lib/howler-engine.ts @@ -52,6 +52,11 @@ class HowlerEngine { private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes private shouldRetryLoads: boolean = false; // Only retry transient load errors where it helps (Android WebView) private cleanupTimeoutId: NodeJS.Timeout | null = null; // Track cleanup timeout to prevent race conditions + + // Seek state management - prevents stale timeupdate events during seeks + private isSeeking: boolean = false; + private seekTargetTime: number | null = null; + private seekTimeoutId: NodeJS.Timeout | null = null; constructor() { // Initialize event listener maps @@ -105,13 +110,15 @@ class HowlerEngine { this.state.currentSrc = src; // Detect if running in Android WebView (for graceful degradation) - const isAndroidWebView = typeof navigator !== "undefined" && - /wv/.test(navigator.userAgent.toLowerCase()) && + const isAndroidWebView = + typeof navigator !== "undefined" && + /wv/.test(navigator.userAgent.toLowerCase()) && /android/.test(navigator.userAgent.toLowerCase()); this.shouldRetryLoads = isAndroidWebView; // Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support) - const isPodcastOrAudiobook = src.includes("/api/podcasts/") || src.includes("/api/audiobooks/"); + const isPodcastOrAudiobook = + src.includes("/api/podcasts/") || src.includes("/api/audiobooks/"); // Build Howl config // Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes @@ -164,13 +171,24 @@ class HowlerEngine { } }, onloaderror: (id, error) => { - console.error("[HowlerEngine] Load error:", error, "Attempt:", this.retryCount + 1); + console.error( + "[HowlerEngine] Load error:", + error, + "Attempt:", + this.retryCount + 1 + ); this.isLoading = false; // Retry logic for transient errors (common on Android WebView) - if (this.shouldRetryLoads && this.retryCount < this.maxRetries && this.state.currentSrc) { + if ( + this.shouldRetryLoads && + this.retryCount < this.maxRetries && + this.state.currentSrc + ) { this.retryCount++; - console.log(`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`); + console.log( + `[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...` + ); // Save src before cleanup const srcToRetry = this.state.currentSrc; @@ -183,7 +201,12 @@ class HowlerEngine { // Wait a bit before retrying setTimeout(() => { - this.load(srcToRetry, autoplayToRetry, formatToRetry, true); + this.load( + srcToRetry, + autoplayToRetry, + formatToRetry, + true + ); }, 500 * this.retryCount); // Exponential backoff return; } @@ -276,14 +299,45 @@ class HowlerEngine { /** * Seek to a specific time - * Simple seek - UI handles buffering state if needed + * Includes seek locking to prevent stale timeupdate events from causing UI flicker */ seek(time: number): void { if (!this.howl) return; + // Set seek lock - this prevents timeupdate from emitting stale values + this.isSeeking = true; + this.seekTargetTime = time; + + // Clear any existing seek timeout + if (this.seekTimeoutId) { + clearTimeout(this.seekTimeoutId); + } + this.state.currentTime = time; this.howl.seek(time); this.emit("seek", { time }); + + // Release seek lock after audio has time to sync + // This timeout ensures timeupdate doesn't emit stale values during the seek operation + this.seekTimeoutId = setTimeout(() => { + this.isSeeking = false; + this.seekTargetTime = null; + this.seekTimeoutId = null; + }, 300); + } + + /** + * Check if currently in a seek operation + */ + isCurrentlySeeking(): boolean { + return this.isSeeking; + } + + /** + * Get the target seek position (if seeking) + */ + getSeekTarget(): number | null { + return this.seekTargetTime; } /** @@ -416,6 +470,23 @@ class HowlerEngine { if (this.howl && this.state.isPlaying) { const seek = this.howl.seek(); if (typeof seek === "number") { + // During a seek operation, ignore timeupdate events that report stale positions + // This prevents the UI flicker where old position briefly shows during seek + if (this.isSeeking && this.seekTargetTime !== null) { + const isNearTarget = Math.abs(seek - this.seekTargetTime) < 2; + if (!isNearTarget) { + // Stale position - don't emit, use target instead + return; + } + // Position is near target, seek completed - clear seek state + this.isSeeking = false; + this.seekTargetTime = null; + if (this.seekTimeoutId) { + clearTimeout(this.seekTimeoutId); + this.seekTimeoutId = null; + } + } + this.state.currentTime = seek; this.emit("timeupdate", { time: seek }); } @@ -497,6 +568,13 @@ class HowlerEngine { clearTimeout(this.cleanupTimeoutId); this.cleanupTimeoutId = null; } + // Clear seek state + if (this.seekTimeoutId) { + clearTimeout(this.seekTimeoutId); + this.seekTimeoutId = null; + } + this.isSeeking = false; + this.seekTargetTime = null; } } diff --git a/plans/mood-mixer-overhaul.md b/plans/mood-mixer-overhaul.md new file mode 100644 index 0000000..d6e5a34 --- /dev/null +++ b/plans/mood-mixer-overhaul.md @@ -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 diff --git a/plans/player-seek-optimization.md b/plans/player-seek-optimization.md new file mode 100644 index 0000000..5df4e54 --- /dev/null +++ b/plans/player-seek-optimization.md @@ -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(false); +const seekTargetTimeRef = useRef(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 { + 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(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 ✅