Files
alice-backend/server/spotify.mjs
T

97 lines
5.6 KiB
JavaScript

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