139 lines
8.0 KiB
JavaScript
139 lines
8.0 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 playlist-read-private playlist-read-collaborative playlist-modify-private playlist-modify-public' });
|
|
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, type) {
|
|
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: 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, ' ');
|
|
if (type) {
|
|
const words = normalize(query).match(/[\p{L}\p{N}]+/gu) || [];
|
|
return results.filter(item => item.type === type && words.every(word => normalize([item.name, ...item.artists].join(' ')).includes(word)));
|
|
}
|
|
const exactArtist = results.find(item => item.type === 'artist' && normalize(item.name) === normalize(query));
|
|
if (exactArtist) return [exactArtist, ...results.filter(item => item !== exactArtist)];
|
|
return results;
|
|
}
|
|
|
|
export async function spotifyApi(path, { method = 'GET', body, signal } = {}) {
|
|
const token = await accessToken();
|
|
const response = await fetch('https://api.spotify.com/v1/' + path, {
|
|
method, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(7000)]) : AbortSignal.timeout(7000),
|
|
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
if (response.status === 403) return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' };
|
|
if (!response.ok) return { error: 'spotify_request_failed' };
|
|
return response.status === 204 ? { ok: true } : response.json();
|
|
}
|
|
|
|
export async function playlistTool(args, signal) {
|
|
if (args.action === 'list') {
|
|
const data = await spotifyApi('me/playlists?limit=30', { signal });
|
|
return data.error ? data : { playlists: (data.items || []).map(p => ({ name: p.name, uri: p.uri })) };
|
|
}
|
|
if (args.action === 'create') {
|
|
const data = await spotifyApi('me/playlists', { method: 'POST', signal, body: { name: args.name, public: false } });
|
|
return data.error ? data : { ok: true, name: data.name, uri: data.uri };
|
|
}
|
|
const id = args.playlistUri.split(':').at(-1);
|
|
if (args.action === 'read') {
|
|
const data = await spotifyApi(`playlists/${id}/items?limit=15`, { signal });
|
|
return data.error ? data : { tracks: (data.items || []).map(entry => {
|
|
const item = entry.item || entry.track;
|
|
return { name: item?.name, uri: item?.uri, artists: item?.artists?.map(a => a.name) };
|
|
}) };
|
|
}
|
|
let uri = args.trackUri;
|
|
if (!uri) uri = (await searchSpotify(args.trackQuery, signal, 'track'))[0]?.uri;
|
|
if (!uri) return { error: 'spotify_not_found' };
|
|
return spotifyApi(`playlists/${id}/items`, {
|
|
method: args.action === 'add' ? 'POST' : 'DELETE', signal,
|
|
body: args.action === 'add' ? { uris: [uri] } : { items: [{ uri }] },
|
|
});
|
|
}
|