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