186 lines
10 KiB
JavaScript
186 lines
10 KiB
JavaScript
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 || {};
|
|
if (!partnerId)
|
|
return res.status(400).json({ error: 'partnerId required' });
|
|
// Collect seeds from both users' top tracks
|
|
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));
|
|
let seedTrackUris = [...tracks1, ...tracks2]
|
|
.map((t) => t.uri || (t.id ? `spotify:track:${t.id}` : null))
|
|
.filter((u) => !!u);
|
|
// 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
|
|
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);
|
|
await addTracks(creatorToken, creatorPlaylistId, finalUris);
|
|
let partnerResult = {};
|
|
if (createForBoth) {
|
|
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');
|
|
}
|
|
const partnerPlaylist = await createSpotifyPlaylist(partnerToken, partnerProfile.id, playlistName, playlistDesc);
|
|
partnerResult = partnerPlaylist;
|
|
await addTracks(partnerToken, partnerResult.id, finalUris);
|
|
console.log(`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('Partner playlist creation failed:', errorMessage);
|
|
partnerResult.error = errorMessage;
|
|
}
|
|
}
|
|
// Save playlist to database
|
|
const playlistId = uuidv4();
|
|
const now = Date.now();
|
|
console.log('Saving playlist to database:', {
|
|
id: playlistId,
|
|
name: playlistName,
|
|
trackCount: finalUris.length,
|
|
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);
|
|
res.json({
|
|
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,
|
|
});
|
|
}
|
|
catch (err) {
|
|
const detail = err?.response?.data || err?.message || 'Unknown error';
|
|
console.error('Mixed playlist error:', detail);
|
|
res.status(500).json({ error: 'Failed to create mixed playlist', detail });
|
|
}
|
|
});
|