196 lines
9.1 KiB
JavaScript
196 lines
9.1 KiB
JavaScript
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');
|
|
// Check if directory exists
|
|
if (!fs.existsSync(coversDir)) {
|
|
console.log('Playlist covers directory not found, creating it...');
|
|
fs.mkdirSync(coversDir, { recursive: true });
|
|
}
|
|
// Get all image files from the directory
|
|
const files = fs.readdirSync(coversDir).filter(file => {
|
|
const ext = path.extname(file).toLowerCase();
|
|
return ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext);
|
|
});
|
|
if (files.length === 0) {
|
|
console.log('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(`Selected random playlist cover: ${imageUrl}`);
|
|
return imageUrl;
|
|
}
|
|
catch (error) {
|
|
console.error('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) {
|
|
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;
|
|
// Generate a random local playlist cover image
|
|
const imageUrl = generateRandomPlaylistImage();
|
|
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' } });
|
|
}
|
|
// ----- 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);
|
|
}
|