spotify/server/dist/routes/playlists.js
2025-10-16 13:07:44 +02:00

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 });
}
});