import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const pending = new Map(); let refreshInFlight; const clientId = () => process.env.SPOTIFY_CLIENT_ID || ''; const redirectUri = () => process.env.SPOTIFY_REDIRECT_URI || 'https://miniapp.pepega.systems/api/spotify/callback'; const tokenPath = () => join(process.env.DATA_DIR || './data', 'spotify.json'); const base64url = bytes => bytes.toString('base64url'); function loadToken() { try { const token = JSON.parse(readFileSync(tokenPath(), 'utf8')); return typeof token.refresh_token === 'string' && token.refresh_token ? token : null; } catch (error) { if (error.code === 'ENOENT') return null; throw error; } } function saveToken(token) { const path = tokenPath(); mkdirSync(process.env.DATA_DIR || './data', { recursive: true, mode: 0o700 }); const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`; try { writeFileSync(temp, JSON.stringify(token), { mode: 0o600, flag: 'wx' }); renameSync(temp, path); chmodSync(path, 0o600); } catch (error) { try { unlinkSync(temp); } catch {} throw error; } } export function spotifyStatus() { return { configured: Boolean(clientId()), connected: Boolean(loadToken()) }; } export function startSpotify() { if (!clientId()) throw new Error('spotify_not_configured'); const now = Date.now(); for (const [key, entry] of pending) if (entry.expires <= now) pending.delete(key); // One owner: a new attempt invalidates previous attempts. pending.clear(); const state = base64url(randomBytes(32)); const verifier = base64url(randomBytes(64)); pending.set(state, { verifier, expires: now + 600_000 }); const url = new URL('https://accounts.spotify.com/authorize'); url.search = new URLSearchParams({ client_id: clientId(), response_type: 'code', redirect_uri: redirectUri(), state, code_challenge_method: 'S256', code_challenge: base64url(createHash('sha256').update(verifier).digest()), scope: 'user-read-playback-state user-modify-playback-state' }); return url.toString(); } async function tokenRequest(params, signal) { const response = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: clientId(), ...params }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error('spotify_token_error'); const data = await response.json(); if (typeof data.access_token !== 'string' || !Number.isFinite(data.expires_in) || data.expires_in <= 0) throw new Error('spotify_token_error'); return data; } export async function finishSpotify(state, code) { if (typeof state !== 'string' || !state || typeof code !== 'string' || !code || code.length > 4096) throw new Error('spotify_callback_invalid'); const entry = pending.get(state); if (!entry || entry.expires <= Date.now()) { pending.delete(state); throw new Error('spotify_callback_invalid'); } pending.delete(state); const data = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri(), code_verifier: entry.verifier }); if (typeof data.refresh_token !== 'string' || !data.refresh_token) throw new Error('spotify_token_error'); saveToken({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000 }); } async function accessToken() { const current = loadToken(); if (!current) throw new Error('spotify_not_connected'); if (current.access_token && current.expires_at > Date.now() + 60_000) return current.access_token; if (!refreshInFlight) { refreshInFlight = (async () => { const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refresh_token }); const next = { access_token: data.access_token, refresh_token: data.refresh_token || current.refresh_token, expires_at: Date.now() + data.expires_in * 1000 }; // A new authorization can complete while refresh is in flight. const latest = loadToken(); if (!latest || latest.refresh_token !== current.refresh_token) return latest?.access_token || next.access_token; saveToken(next); return next.access_token; })().finally(() => { refreshInFlight = undefined; }); } return refreshInFlight; } export async function searchSpotify(query, signal) { if (typeof query !== 'string' || !query.trim() || query.length > 500) throw new Error('spotify_invalid_query'); const token = await accessToken(); if (signal?.aborted) throw new Error('aborted'); const url = new URL('https://api.spotify.com/v1/search'); url.search = new URLSearchParams({ q: query.trim(), type: 'track,artist,album,playlist', limit: '5' }); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal }); if (!response.ok) throw new Error('spotify_search_error'); const data = await response.json(); const results = ['tracks', 'artists', 'albums', 'playlists'].flatMap(key => (data[key]?.items || []).filter(item => item?.uri && item?.name).map(item => ({ uri: item.uri, name: item.name, type: item.type, artists: (item.artists || []).map(artist => artist.name), album: item.album?.name || null }))); const normalize = value => value.normalize('NFKC').trim().toLocaleLowerCase().replace(/\s+/g, ' '); const exactArtist = results.find(item => item.type === 'artist' && normalize(item.name) === normalize(query)); if (exactArtist) return [exactArtist, ...results.filter(item => item !== exactArtist)]; return results; }