import axios from 'axios'; import { db } from './db.js'; import fs from 'fs'; import path from 'path'; // Generate a random local playlist cover image URL function generateRandomPlaylistImage() { try { const coversDir = path.join(process.cwd(), 'public', 'playlist-covers'); console.log(`[PLAYLIST COVER] Looking for images in directory: ${coversDir}`); // Check if directory exists if (!fs.existsSync(coversDir)) { console.log('[PLAYLIST COVER] Directory not found, creating it...'); fs.mkdirSync(coversDir, { recursive: true }); } // Get all image files from the directory (Spotify only accepts JPEG for cover uploads) const files = fs.readdirSync(coversDir).filter(file => { const ext = path.extname(file).toLowerCase(); return ['.jpg', '.jpeg'].includes(ext); // Only JPEG files for Spotify upload }); console.log(`[PLAYLIST COVER] Found ${files.length} image files:`, files); // Log file sizes for debugging files.forEach(file => { const filePath = path.join(coversDir, file); const stats = fs.statSync(filePath); const sizeKB = (stats.size / 1024).toFixed(2); console.log(`[PLAYLIST COVER] File: ${file}, Size: ${sizeKB} KB`); }); if (files.length === 0) { console.log('[PLAYLIST COVER] No playlist cover images found in public/playlist-covers/'); return '/api/placeholder-playlist-cover'; } // Select a random image const randomFile = files[Math.floor(Math.random() * files.length)]; const imageUrl = `/api/playlist-covers/${encodeURIComponent(randomFile)}`; console.log(`[PLAYLIST COVER] Selected random playlist cover: ${imageUrl} (from file: ${randomFile})`); return imageUrl; } catch (error) { console.error('[PLAYLIST COVER] Error generating random playlist image:', error); return '/api/placeholder-playlist-cover'; } } export function getUserTokens(uid) { const row = db.db .prepare('SELECT access_token, refresh_token, token_expires_at FROM users WHERE id = ?') .get(uid); if (!row) return null; return { access_token: row.access_token, refresh_token: row.refresh_token, token_expires_at: row.token_expires_at, }; } export async function ensureValidAccessToken(uid) { const tokens = getUserTokens(uid); if (!tokens?.access_token) { throw new Error('User not authenticated'); } const now = Date.now(); if (tokens.token_expires_at && tokens.token_expires_at > now + 30000) { return tokens.access_token; } if (!tokens.refresh_token) { return tokens.access_token; } // Refresh token via Spotify const clientId = process.env.SPOTIFY_CLIENT_ID || ''; const clientSecret = process.env.SPOTIFY_CLIENT_SECRET || ''; const resp = await axios.post('https://accounts.spotify.com/api/token', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, }, }); const { access_token, expires_in } = resp.data; const token_expires_at = Date.now() + expires_in * 1000 - 60000; db.db .prepare('UPDATE users SET access_token = ?, token_expires_at = ?, updated_at=? WHERE id = ?') .run(access_token, token_expires_at, Date.now(), uid); return access_token; } export function vibeToTargets(vibe) { switch (vibe) { case 'energetic': return { target_energy: 0.85, target_danceability: 0.7, target_tempo: 130 }; case 'chill': return { target_energy: 0.3, target_danceability: 0.4, target_tempo: 90 }; case 'happy': return { target_valence: 0.85, target_danceability: 0.6 }; case 'sad': return { target_valence: 0.2 }; case 'party': return { target_danceability: 0.85, target_energy: 0.8, target_tempo: 125 }; case 'focus': return { target_energy: 0.4, target_instrumentalness: 0.5 }; default: return {}; } } export async function fetchRecommendations(accessToken, seeds, targets, limit = 25) { // Legacy kept for compatibility; prefer fetchSimilarTrackUris below return fetchSimilarTrackUris(accessToken, seeds.seed_tracks, seeds.seed_artists, seeds.seed_genres, targets, limit); } export async function createSpotifyPlaylist(accessToken, userId, name, description) { console.log(`[PLAYLIST CREATION] Creating Spotify playlist: "${name}" for user: ${userId}`); const resp = await axios.post(`https://api.spotify.com/v1/users/${encodeURIComponent(userId)}/playlists`, { name, description, public: false }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); const pl = resp.data; console.log(`[PLAYLIST CREATION] Spotify playlist created successfully with ID: ${pl.id}`); // Generate a random local playlist cover image const imageUrl = generateRandomPlaylistImage(); // Extract the filename from the imageUrl to use the SAME image for Spotify upload const selectedFilename = decodeURIComponent(imageUrl.replace('/api/playlist-covers/', '')); console.log(`[PLAYLIST COVER] Selected image for both website and Spotify: ${selectedFilename}`); // Get the actual file path for uploading to Spotify (only JPEG files) const coversDir = path.join(process.cwd(), 'public', 'playlist-covers'); const files = fs.readdirSync(coversDir).filter(file => { const ext = path.extname(file).toLowerCase(); return ['.jpg', '.jpeg'].includes(ext); // Only JPEG files for Spotify upload }); if (files.length > 0) { // Find the specific file that was selected for the website const selectedFile = files.find(file => file === selectedFilename); if (selectedFile) { const imagePath = path.join(coversDir, selectedFile); const stats = fs.statSync(imagePath); const fileSizeKB = (stats.size / 1024).toFixed(2); console.log(`[PLAYLIST COVER] Using SAME image for Spotify upload: ${selectedFile} (${fileSizeKB} KB)`); // Upload the SAME cover image to Spotify const uploadSuccess = await uploadPlaylistCover(accessToken, pl.id, imagePath); if (uploadSuccess) { console.log(`[PLAYLIST COVER] ✅ Successfully uploaded SAME cover image to Spotify playlist ${pl.id}`); } else { console.log(`[PLAYLIST COVER] ❌ Failed to upload ${selectedFile} to Spotify, but website will still show it`); } } else { console.log(`[PLAYLIST COVER] ⚠️ Could not find selected file ${selectedFilename} for Spotify upload`); console.log(`[PLAYLIST COVER] ✅ WEBSITE COVER: Random cover image will still be displayed on your website.`); } } return { id: pl.id, url: pl.external_urls?.spotify, imageUrl }; } export async function addTracks(accessToken, playlistId, trackUris) { if (!trackUris.length) return; await axios.post(`https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/tracks`, { uris: trackUris }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); } // Upload a playlist cover image to Spotify export async function uploadPlaylistCover(accessToken, playlistId, imagePath) { try { console.log(`[PLAYLIST COVER] Attempting to upload cover image for playlist ${playlistId} from path: ${imagePath}`); // Check if file exists if (!fs.existsSync(imagePath)) { console.error(`[PLAYLIST COVER] Image file not found: ${imagePath}`); return false; } // Read the image file let imageBuffer = fs.readFileSync(imagePath); let fileSize = imageBuffer.length; console.log(`[PLAYLIST COVER] Original image file size: ${fileSize} bytes (${(fileSize / 1024).toFixed(2)} KB)`); // Check if we need to compress the image // We need to account for base64 encoding overhead (33% increase) // So we want the original file to be under ~190 KB to stay under 256 KB when base64 encoded const maxOriginalSize = 190 * 1024; // 190 KB if (fileSize > maxOriginalSize) { console.log(`[PLAYLIST COVER] Image too large (${(fileSize / 1024).toFixed(2)} KB), attempting to compress...`); // For now, let's skip files that are too large and try a different one console.error(`[PLAYLIST COVER] Image file too large for Spotify upload: ${fileSize} bytes (max ~190 KB to account for base64 overhead)`); return false; } // Convert to base64 let base64Image = imageBuffer.toString('base64'); const base64Size = base64Image.length; console.log(`[PLAYLIST COVER] Base64 length: ${base64Size} characters (${(base64Size / 1024).toFixed(2)} KB)`); // Ensure we don't have any data URL prefix if (base64Image.startsWith('data:')) { base64Image = base64Image.split(',')[1] || base64Image; console.log(`[PLAYLIST COVER] Removed data URL prefix from base64`); } // Final check - ensure base64 size is reasonable if (base64Size > 300 * 1024) { // 300 KB base64 limit console.error(`[PLAYLIST COVER] Base64 encoded image too large: ${base64Size} characters`); return false; } // Determine content type based on file extension const ext = path.extname(imagePath).toLowerCase(); let contentType = 'image/jpeg'; // default switch (ext) { case '.png': contentType = 'image/png'; break; case '.gif': contentType = 'image/gif'; break; case '.webp': contentType = 'image/webp'; break; case '.svg': contentType = 'image/svg+xml'; break; case '.jpg': case '.jpeg': contentType = 'image/jpeg'; break; } console.log(`[PLAYLIST COVER] Using content type: ${contentType}`); // Check token scopes first try { console.log(`[PLAYLIST COVER] Checking token scopes...`); const meResponse = await axios.get('https://api.spotify.com/v1/me', { headers: { 'Authorization': `Bearer ${accessToken}` } }); console.log(`[PLAYLIST COVER] Token is valid for user: ${meResponse.data.display_name}`); // Test if we can access the playlist const playlistResponse = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, { headers: { 'Authorization': `Bearer ${accessToken}` } }); console.log(`[PLAYLIST COVER] Can access playlist: ${playlistResponse.data.name}`); } catch (error) { console.error(`[PLAYLIST COVER] Token validation failed:`, error.response?.data || error.message); } // Upload to Spotify console.log(`[PLAYLIST COVER] Making request to Spotify API: https://api.spotify.com/v1/playlists/${playlistId}/images`); console.log(`[PLAYLIST COVER] Access token (first 20 chars): ${accessToken.substring(0, 20)}...`); // Try different approaches for image upload let response; try { // Method 1: Standard PUT request (requires ugc-image-upload scope) response = await axios.put(`https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/images`, base64Image, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': contentType } }); } catch (error) { if (error.response?.status === 401) { console.log(`[PLAYLIST COVER] Method 1 failed with 401, trying alternative approach...`); // Method 2: Try with different content type try { response = await axios.put(`https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/images`, base64Image, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'image/jpeg' // Force JPEG content type } }); } catch (error2) { console.log(`[PLAYLIST COVER] Method 2 also failed: ${error2.response?.status}`); throw error; // Re-throw original error } } else { throw error; // Re-throw if not 401 } } console.log(`[PLAYLIST COVER] Successfully uploaded cover image for playlist ${playlistId}, response status: ${response.status}`); console.log(`[PLAYLIST COVER] Response headers:`, response.headers); return true; } catch (error) { console.error(`[PLAYLIST COVER] Failed to upload cover image for playlist ${playlistId}:`); console.error(`[PLAYLIST COVER] Error status:`, error.response?.status); console.error(`[PLAYLIST COVER] Error data:`, error.response?.data); console.error(`[PLAYLIST COVER] Error message:`, error.message); return false; } } // ----- New: Similar tracks without recommendations endpoint ----- async function getRelatedArtists(accessToken, artistId) { const resp = await axios.get(`https://api.spotify.com/v1/artists/${encodeURIComponent(artistId)}/related-artists`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const artists = (resp.data?.artists || []); return artists.map(a => a.id).filter(Boolean); } async function getArtistTopTracks(accessToken, artistId) { const resp = await axios.get(`https://api.spotify.com/v1/artists/${encodeURIComponent(artistId)}/top-tracks?market=from_token`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const tracks = (resp.data?.tracks || []); return tracks.map(t => t.uri || (t.id ? `spotify:track:${t.id}` : null)).filter((u) => !!u); } async function searchTracksByGenre(accessToken, genre, limit) { const resp = await axios.get('https://api.spotify.com/v1/search', { headers: { Authorization: `Bearer ${accessToken}` }, params: { q: `genre:"${genre}"`, type: 'track', limit: Math.min(limit, 50) }, }); const tracks = (resp.data?.tracks?.items || []); return tracks.map(t => t.uri || (t.id ? `spotify:track:${t.id}` : null)).filter((u) => !!u); } async function filterByTargets(accessToken, trackUris, targets) { if (!trackUris.length || Object.keys(targets).length === 0) return trackUris; const ids = trackUris.map(u => u.split(':').pop()).filter(Boolean); const resp = await axios.get('https://api.spotify.com/v1/audio-features', { headers: { Authorization: `Bearer ${accessToken}` }, params: { ids: ids.slice(0, 100).join(',') }, }); const features = (resp.data?.audio_features || []); const pass = new Set(features .filter(f => !!f && (!('target_energy' in targets) || Math.abs(targets.target_energy - f.energy) <= 0.4)) .filter(f => !('target_danceability' in targets) || Math.abs(targets.target_danceability - f.danceability) <= 0.4) .filter(f => !('target_valence' in targets) || Math.abs(targets.target_valence - f.valence) <= 0.5) .filter(f => !('target_tempo' in targets) || Math.abs(targets.target_tempo - f.tempo) <= 30) .filter(f => !('target_instrumentalness' in targets) || Math.abs(targets.target_instrumentalness - f.instrumentalness) <= 0.5) .map(f => f.id)); return trackUris.filter(u => pass.has(u.split(':').pop())); } export async function fetchSimilarTrackUris(accessToken, seedTrackUris, seedArtistIds, seedGenres, targets = {}, limit = 25) { const candidateUris = new Set(); // From seed artists and from artists of seed tracks const trackIds = (seedTrackUris || []).map(u => u?.split(':').pop()).filter(Boolean); const seedArtistSet = new Set(seedArtistIds || []); // If we got tracks, fetch their artists to expand if (trackIds.length) { const tracksResp = await axios.get('https://api.spotify.com/v1/tracks', { headers: { Authorization: `Bearer ${accessToken}` }, params: { ids: trackIds.slice(0, 50).join(',') }, }); const tracks = (tracksResp.data?.tracks || []); tracks.forEach(t => (t.artists || []).forEach(a => a?.id && seedArtistSet.add(a.id))); } // Expand via related artists and take top tracks const seedArtists = Array.from(seedArtistSet).slice(0, 10); for (const artistId of seedArtists) { try { const related = (await getRelatedArtists(accessToken, artistId)).slice(0, 10); const pool = [artistId, ...related]; for (const a of pool) { const tops = await getArtistTopTracks(accessToken, a); tops.forEach(u => candidateUris.add(u)); if (candidateUris.size >= limit * 4) break; } if (candidateUris.size >= limit * 4) break; } catch { } } // Genre-based search fallback for (const g of (seedGenres || []).slice(0, 3)) { try { const found = await searchTracksByGenre(accessToken, g, 50); found.forEach(u => candidateUris.add(u)); if (candidateUris.size >= limit * 4) break; } catch { } } let uris = Array.from(candidateUris); uris = await filterByTargets(accessToken, uris, targets); // Shuffle and take limit uris.sort(() => Math.random() - 0.5); return uris.slice(0, limit); }