import { Router } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { addTracks, createSpotifyPlaylist, ensureValidAccessToken, fetchRecommendations, vibeToTargets } from '../lib/spotify.js'; import { db } from '../lib/db.js'; import { v4 as uuidv4 } from 'uuid'; export const playlistsRouter = Router(); // Helper function to generate unique playlist names function generateUniquePlaylistName() { const adjectives = ['Vibrant', 'Melodic', 'Harmonic', 'Rhythmic', 'Ethereal', 'Dynamic', 'Soulful', 'Electric', 'Acoustic', 'Cosmic', 'Urban', 'Chill', 'Energetic', 'Dreamy', 'Funky']; const nouns = ['Fusion', 'Symphony', 'Vibes', 'Journey', 'Harmony', 'Blend', 'Mix', 'Soundscape', 'Melody', 'Rhythm', 'Groove', 'Beat', 'Tune', 'Sound', 'Wave']; const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const timestamp = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return `${adjective} ${noun} - ${timestamp}`; } // GET /playlists/mixed - Get all mixed playlists for a user playlistsRouter.get('/mixed', requireAuth, async (req, res) => { try { const userId = req.user.uid; const playlists = db.db.prepare(` SELECT * FROM mixed_playlists WHERE creator_id = ? OR partner_id = ? ORDER BY created_at DESC `).all(userId, userId); res.json({ playlists }); } catch (err) { console.error('Failed to fetch mixed playlists:', err); res.status(500).json({ error: 'Failed to fetch playlists' }); } }); // DELETE /playlists/mixed/:id - Delete a mixed playlist playlistsRouter.delete('/mixed/:id', requireAuth, async (req, res) => { try { const userId = req.user.uid; const playlistId = req.params.id; // Check if user owns this playlist const playlist = db.db.prepare('SELECT * FROM mixed_playlists WHERE id = ? AND (creator_id = ? OR partner_id = ?)').get(playlistId, userId, userId); if (!playlist) { return res.status(404).json({ error: 'Playlist not found' }); } // Delete from database db.db.prepare('DELETE FROM mixed_playlists WHERE id = ?').run(playlistId); res.json({ success: true }); } catch (err) { console.error('Failed to delete playlist:', err); res.status(500).json({ error: 'Failed to delete playlist' }); } }); // POST /playlists/mixed // body: { name?: string, description?: string, vibe?: string, genres?: string[], includeKnown?: boolean, partnerId?: string, createForBoth?: boolean, limit?: number } playlistsRouter.post('/mixed', requireAuth, async (req, res) => { try { const creatorId = req.user.uid; const { name, description, vibe, genres, includeKnown = true, partnerId, createForBoth = false, limit = 25 } = req.body || {}; console.log(`[MIXED PLAYLIST] Starting playlist creation process for creator: ${creatorId}, partner: ${partnerId}`); console.log(`[MIXED PLAYLIST] Request parameters:`, { name, description, vibe, genres, includeKnown, createForBoth, limit }); if (!partnerId) return res.status(400).json({ error: 'partnerId required' }); // Collect seeds from both users' top tracks console.log(`[MIXED PLAYLIST] Collecting seed tracks from creator (${creatorId}) and partner (${partnerId})`); const seedRows1 = db.db.prepare('SELECT track_json FROM top_tracks WHERE user_id=? AND time_range=? ORDER BY rank ASC LIMIT 20').all(creatorId, 'short_term'); const seedRows2 = db.db.prepare('SELECT track_json FROM top_tracks WHERE user_id=? AND time_range=? ORDER BY rank ASC LIMIT 20').all(partnerId, 'short_term'); const tracks1 = seedRows1.map((r) => JSON.parse(r.track_json)); const tracks2 = seedRows2.map((r) => JSON.parse(r.track_json)); console.log(`[MIXED PLAYLIST] Found ${tracks1.length} tracks from creator, ${tracks2.length} tracks from partner`); let seedTrackUris = [...tracks1, ...tracks2] .map((t) => t.uri || (t.id ? `spotify:track:${t.id}` : null)) .filter((u) => !!u); console.log(`[MIXED PLAYLIST] Total seed track URIs: ${seedTrackUris.length}`); // Fallback to artist seeds if track seeds are empty let seedArtistIds = []; if (seedTrackUris.length === 0) { const artistIds = [...tracks1, ...tracks2] .flatMap((t) => t.artists || []) .map((a) => a?.id) .filter((id) => !!id); seedArtistIds = Array.from(new Set(artistIds)); } // Use at most 5 seeds of each type seedTrackUris = seedTrackUris.slice(0, 5); seedArtistIds = seedArtistIds.slice(0, 5); const creatorToken = await ensureValidAccessToken(creatorId); const targets = vibeToTargets(vibe); // Always provide fallback genres if none specified let finalGenres = genres; if (!Array.isArray(genres) || genres.length === 0) { // Analyze users' top tracks to determine appropriate genres const allTracks = [...tracks1, ...tracks2]; const genreCounts = {}; // Count genres from top tracks allTracks.forEach(track => { if (track.artists && track.artists.length > 0) { // Use common genres based on artist analysis // This is a simplified approach - in a real implementation, you'd analyze track features genreCounts['pop'] = (genreCounts['pop'] || 0) + 1; genreCounts['rock'] = (genreCounts['rock'] || 0) + 1; genreCounts['indie'] = (genreCounts['indie'] || 0) + 1; genreCounts['alternative'] = (genreCounts['alternative'] || 0) + 1; } }); // Use top genres or fallback to popular ones const sortedGenres = Object.entries(genreCounts) .sort(([, a], [, b]) => b - a) .map(([genre]) => genre) .slice(0, 4); finalGenres = sortedGenres.length > 0 ? sortedGenres : ['pop', 'rock', 'indie', 'alternative']; console.log('Using fallback genres:', finalGenres, 'based on track analysis'); } const recommendedUris = await fetchRecommendations(creatorToken, { seed_tracks: seedTrackUris.length ? seedTrackUris : undefined, seed_artists: seedArtistIds.length ? seedArtistIds : undefined, seed_genres: finalGenres, }, targets, limit); // Optionally remove already listened tracks for both users (recently played) let finalUris = recommendedUris; if (!includeKnown) { const recents1 = db.db.prepare('SELECT track_json FROM recently_played WHERE user_id=? ORDER BY played_at DESC LIMIT 100').all(creatorId); const recents2 = db.db.prepare('SELECT track_json FROM recently_played WHERE user_id=? ORDER BY played_at DESC LIMIT 100').all(partnerId); const recentUris = new Set([...recents1, ...recents2] .map((r) => JSON.parse(r.track_json)) .map((t) => t.uri || (t.id ? `spotify:track:${t.id}` : null)) .filter((u) => !!u)); finalUris = recommendedUris.filter((u) => !recentUris.has(u)); } // Generate unique playlist name if not provided const playlistName = name || generateUniquePlaylistName(); const playlistDesc = description || 'An AI-blended mix with fresh recommendations'; // Always create for the creator console.log(`[MIXED PLAYLIST] Creating Spotify playlist for creator: "${playlistName}"`); const creatorProfile = db.db.prepare('SELECT id FROM users WHERE id=?').get(creatorId); const { id: creatorPlaylistId, url: creatorUrl, imageUrl: creatorImageUrl } = await createSpotifyPlaylist(creatorToken, creatorProfile.id, playlistName, playlistDesc); console.log(`[MIXED PLAYLIST] Adding ${finalUris.length} tracks to creator playlist ${creatorPlaylistId}`); await addTracks(creatorToken, creatorPlaylistId, finalUris); console.log(`[MIXED PLAYLIST] Successfully added tracks to creator playlist`); let partnerResult = {}; if (createForBoth) { console.log(`[MIXED PLAYLIST] Creating playlist for partner as well: ${partnerId}`); try { const partnerToken = await ensureValidAccessToken(partnerId); const partnerProfile = db.db.prepare('SELECT id FROM users WHERE id=?').get(partnerId); if (!partnerProfile) { throw new Error('Partner user not found'); } console.log(`[MIXED PLAYLIST] Creating Spotify playlist for partner: "${playlistName}"`); const partnerPlaylist = await createSpotifyPlaylist(partnerToken, partnerProfile.id, playlistName, playlistDesc); partnerResult = partnerPlaylist; console.log(`[MIXED PLAYLIST] Adding ${finalUris.length} tracks to partner playlist ${partnerResult.id}`); await addTracks(partnerToken, partnerResult.id, finalUris); console.log(`[MIXED PLAYLIST] Successfully created partner playlist: ${partnerResult.id}`); } catch (e) { // Partner creation may fail (revoked consent, user not found, etc.); still return creator playlist const errorMessage = e?.response?.data?.error?.message || e?.message || 'Unknown error'; console.error('[MIXED PLAYLIST] Partner playlist creation failed:', errorMessage); partnerResult.error = errorMessage; } } // Save playlist to database const playlistId = uuidv4(); const now = Date.now(); console.log('[MIXED PLAYLIST] Saving playlist to database:', { id: playlistId, name: playlistName, trackCount: finalUris.length, creatorPlaylistId, partnerPlaylistId: partnerResult.id, imageUrl: creatorImageUrl, trackUris: finalUris.slice(0, 3) // Log first 3 URIs for debugging }); db.db.prepare(` INSERT INTO mixed_playlists ( id, creator_id, partner_id, name, description, vibe, genres, track_uris, creator_spotify_id, partner_spotify_id, creator_spotify_url, partner_spotify_url, creator_spotify_image_url, partner_spotify_image_url, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(playlistId, creatorId, partnerId, playlistName, playlistDesc, vibe || null, JSON.stringify(genres || []), JSON.stringify(finalUris), creatorPlaylistId, partnerResult.id || null, creatorUrl, partnerResult.url || null, creatorImageUrl || null, partnerResult.imageUrl || null, now, now); const response = { id: playlistId, name: playlistName, description: playlistDesc, trackUris: finalUris, spotifyImageUrl: creatorImageUrl, createdFor: { creator: { playlistId: creatorPlaylistId, url: creatorUrl }, partner: partnerResult.id ? { playlistId: partnerResult.id, url: partnerResult.url } : null, partnerError: partnerResult.error || null, }, added: finalUris.length, }; console.log('[MIXED PLAYLIST] Playlist creation completed successfully:', { id: playlistId, name: playlistName, creatorPlaylistId, partnerPlaylistId: partnerResult.id, trackCount: finalUris.length, imageUrl: creatorImageUrl }); res.json(response); } catch (err) { const detail = err?.response?.data || err?.message || 'Unknown error'; console.error('[MIXED PLAYLIST] Error during playlist creation:', detail); console.error('[MIXED PLAYLIST] Full error object:', err); res.status(500).json({ error: 'Failed to create mixed playlist', detail }); } });