123 lines
4.5 KiB
JavaScript
123 lines
4.5 KiB
JavaScript
|
|
// Host-side Spotify connection — Authorization Code flow, refresh token
|
||
|
|
// persisted server-side. Same shape as goonk's api/routes/spotify.mjs
|
||
|
|
// (a proven pattern in this family), not the guest PKCE flow used
|
||
|
|
// elsewhere in this app — this is a single, long-lived connection to
|
||
|
|
// the host's own account, not a per-visitor login.
|
||
|
|
import { Router } from 'express';
|
||
|
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||
|
|
import { join, dirname } from 'path';
|
||
|
|
import { fileURLToPath } from 'url';
|
||
|
|
import crypto from 'crypto';
|
||
|
|
|
||
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
|
||
|
|
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
|
||
|
|
const REDIRECT_URI = process.env.SPOTIFY_HOST_REDIRECT_URI || 'http://127.0.0.1:3010/api/spotify/callback';
|
||
|
|
// playlist-read-*: list/read the host's own playlists as a song pool.
|
||
|
|
// user-read-currently-playing + user-read-playback-state: the live
|
||
|
|
// now-playing auto-call poller.
|
||
|
|
const SCOPES = 'playlist-read-private playlist-read-collaborative user-read-currently-playing user-read-playback-state';
|
||
|
|
|
||
|
|
const TOKEN_FILE = join(__dirname, '..', 'data', 'host-token.json');
|
||
|
|
|
||
|
|
let accessToken = null;
|
||
|
|
let accessTokenExpiresAt = 0;
|
||
|
|
let refreshToken = null;
|
||
|
|
|
||
|
|
async function loadRefreshToken() {
|
||
|
|
if (refreshToken) return refreshToken;
|
||
|
|
try {
|
||
|
|
const raw = await readFile(TOKEN_FILE, 'utf8');
|
||
|
|
refreshToken = JSON.parse(raw).refresh_token;
|
||
|
|
} catch {
|
||
|
|
refreshToken = null;
|
||
|
|
}
|
||
|
|
return refreshToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveRefreshToken(token) {
|
||
|
|
refreshToken = token;
|
||
|
|
await mkdir(dirname(TOKEN_FILE), { recursive: true });
|
||
|
|
await writeFile(TOKEN_FILE, JSON.stringify({ refresh_token: token }, null, 2), 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function tokenRequest(params) {
|
||
|
|
const res = await fetch('https://accounts.spotify.com/api/token', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||
|
|
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
|
||
|
|
},
|
||
|
|
body: new URLSearchParams(params).toString(),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error(`Spotify token request → ${res.status}: ${await res.text()}`);
|
||
|
|
return res.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function getHostAccessToken() {
|
||
|
|
if (accessToken && Date.now() < accessTokenExpiresAt) return accessToken;
|
||
|
|
|
||
|
|
const rt = await loadRefreshToken();
|
||
|
|
if (!rt) throw new Error('host not connected — visit /api/spotify/login first');
|
||
|
|
|
||
|
|
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: rt });
|
||
|
|
accessToken = data.access_token;
|
||
|
|
accessTokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
|
||
|
|
if (data.refresh_token) await saveRefreshToken(data.refresh_token);
|
||
|
|
return accessToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function isHostConnected() {
|
||
|
|
return Boolean(await loadRefreshToken());
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function spotifyAsHost(path) {
|
||
|
|
const token = await getHostAccessToken();
|
||
|
|
const res = await fetch(`https://api.spotify.com/v1${path}`, {
|
||
|
|
headers: { Authorization: `Bearer ${token}` },
|
||
|
|
});
|
||
|
|
if (res.status === 204) return null;
|
||
|
|
if (!res.ok) throw new Error(`Spotify ${path} → ${res.status}`);
|
||
|
|
return res.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
router.get('/login', (req, res) => {
|
||
|
|
const state = crypto.randomBytes(16).toString('hex');
|
||
|
|
res.cookie('fh_host_auth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000 });
|
||
|
|
const url = new URL('https://accounts.spotify.com/authorize');
|
||
|
|
url.search = new URLSearchParams({
|
||
|
|
response_type: 'code',
|
||
|
|
client_id: CLIENT_ID,
|
||
|
|
scope: SCOPES,
|
||
|
|
redirect_uri: REDIRECT_URI,
|
||
|
|
state,
|
||
|
|
}).toString();
|
||
|
|
res.redirect(url.toString());
|
||
|
|
});
|
||
|
|
|
||
|
|
router.get('/callback', async (req, res) => {
|
||
|
|
const { code, state, error } = req.query;
|
||
|
|
if (error) return res.status(400).send(`Spotify auth error: ${error}`);
|
||
|
|
if (!state || state !== req.cookies?.fh_host_auth_state) {
|
||
|
|
return res.status(400).send('State mismatch — try /api/spotify/login again');
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const data = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI });
|
||
|
|
await saveRefreshToken(data.refresh_token);
|
||
|
|
accessToken = data.access_token;
|
||
|
|
accessTokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
|
||
|
|
res.clearCookie('fh_host_auth_state');
|
||
|
|
res.send('Host Spotify connected — you can close this tab and start a session.');
|
||
|
|
} catch (e) {
|
||
|
|
res.status(502).send(`Token exchange failed: ${e.message}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
router.get('/status', async (req, res) => {
|
||
|
|
res.json({ connected: await isHostConnected() });
|
||
|
|
});
|
||
|
|
|
||
|
|
export default router;
|