// Spotify poller: one call does what a scrobbler does every minute. // 1. GET /me/player → music.now_playing (row 1) // 2. GET /me/player/recently-played?after= → music.plays (Spotify lists plays of // 30 s or more, max 50 per call, so polling every minute never misses one) // The authorization (refresh token) lives in music.spotify_auth, written by // /api/spotify/callback. A lease column keeps two app containers (e.g. mid-deploy) or the // optional host timer (scripts/music-poll.ts) from polling at the same time. import { newCache, upsertTrack, insertPlay, type Db } from './store.server'; import { parsePlayer, parseRecentlyPlayed } from './spotify-parse'; export type SpotifyCredentials = { clientId: string; clientSecret: string }; type Fetch = typeof fetch; export const SPOTIFY_SCOPES = [ 'user-read-currently-playing', 'user-read-recently-played', 'user-read-playback-state', 'user-top-read', // Map finder: liked songs and Stan's own playlists. 'user-library-read', 'playlist-read-private' ] as const; export class SpotifyError extends Error { constructor( readonly status: number, message: string, readonly code?: string ) { super(message); } } type TokenResponse = { access_token: string; expires_in: number; refresh_token?: string; scope?: string; }; async function token( fetchFn: Fetch, creds: SpotifyCredentials, body: Record ): Promise { const response = await fetchFn('https://accounts.spotify.com/api/token', { method: 'POST', headers: { Authorization: `Basic ${btoa(`${creds.clientId}:${creds.clientSecret}`)}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body), signal: AbortSignal.timeout(15_000) }); const json = (await response.json().catch(() => ({}))) as Record; if (!response.ok || typeof json.access_token !== 'string') throw new SpotifyError( response.status, `Spotify token request failed (${response.status} ${String(json.error ?? '')})`, typeof json.error === 'string' ? json.error : undefined ); return json as unknown as TokenResponse; } export const exchangeCode = ( fetchFn: Fetch, creds: SpotifyCredentials, code: string, redirectUri: string ) => token(fetchFn, creds, { grant_type: 'authorization_code', code, redirect_uri: redirectUri }); export const refreshAccessToken = (fetchFn: Fetch, creds: SpotifyCredentials, refresh: string) => token(fetchFn, creds, { grant_type: 'refresh_token', refresh_token: refresh }); /** GET a Web API path; null for 204 No Content. */ export async function spotifyGet(fetchFn: Fetch, access: string, path: string) { const response = await fetchFn(`https://api.spotify.com/v1/${path}`, { headers: { Authorization: `Bearer ${access}` }, signal: AbortSignal.timeout(15_000) }); if (response.status === 204) return null; if (!response.ok) throw new SpotifyError( response.status, `Spotify GET /${path.split('?')[0]} → ${response.status}` ); const text = await response.text(); return text ? (JSON.parse(text) as unknown) : null; } export type PollResult = | { status: 'idle' } | { status: 'ok'; playing: boolean; newPlays: number } | { status: 'error'; error: string }; export async function pollSpotify( sql: Db, creds: SpotifyCredentials, fetchFn: Fetch = fetch ): Promise { const [auth] = await sql` update music.spotify_auth set lease_until = now() + interval '55 seconds' where id = 1 and not needs_reauth and (lease_until is null or lease_until < now()) returning refresh_token, access_token, access_expires_at, recent_cursor`; if (!auth) return { status: 'idle' }; let error: string | null = null; let result: PollResult; try { let access: string | null = auth.access_token; const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0; if (!access || expires < Date.now() + 60_000) { const fresh = await refreshAccessToken(fetchFn, creds, auth.refresh_token); access = fresh.access_token; await sql` update music.spotify_auth set access_token = ${access}, access_expires_at = now() + make_interval(secs => ${fresh.expires_in}), refresh_token = coalesce(${fresh.refresh_token ?? null}, refresh_token), scope = coalesce(${fresh.scope ?? null}, scope), updated_at = now() where id = 1`; } const cache = newCache(); const player = parsePlayer( await spotifyGet(fetchFn, access!, 'me/player?additional_types=episode') ); const trackId = player?.track ? await upsertTrack(sql, player.track, true, cache) : null; await sql` insert into music.now_playing (id, track_id, is_playing, progress_ms, device_name, device_type, context_uri, updated_at) values (1, ${trackId}, ${!!(player?.isPlaying && trackId)}, ${player?.progressMs ?? null}, ${player?.deviceName ?? null}, ${player?.deviceType ?? null}, ${player?.contextUri ?? null}, now()) on conflict (id) do update set track_id = coalesce(excluded.track_id, music.now_playing.track_id), is_playing = excluded.is_playing, progress_ms = excluded.progress_ms, device_name = excluded.device_name, device_type = excluded.device_type, context_uri = excluded.context_uri, updated_at = now()`; const cursor: number | null = auth.recent_cursor === null ? null : Number(auth.recent_cursor); const recent = parseRecentlyPlayed( await spotifyGet( fetchFn, access!, `me/player/recently-played?limit=50${cursor ? `&after=${cursor}` : ''}` ) ); let newPlays = 0; let newest = cursor ?? 0; for (const play of recent) { const at = play.playedAt.getTime(); if (cursor && at <= cursor) continue; const id = await upsertTrack(sql, play.track, true, cache); if ( await insertPlay(sql, { trackId: id, playedAt: play.playedAt, source: 'spotify', origin: 'api', contextType: play.contextType, contextUri: play.contextUri }) ) newPlays++; newest = Math.max(newest, at); } if (newest && newest !== cursor) await sql`update music.spotify_auth set recent_cursor = ${newest} where id = 1`; result = { status: 'ok', playing: !!player?.isPlaying, newPlays }; } catch (e) { error = e instanceof Error ? e.message : String(e); result = { status: 'error', error }; if (e instanceof SpotifyError && e.code === 'invalid_grant') // The refresh token was revoked: stop polling until /api/spotify/connect is used again. await sql`update music.spotify_auth set needs_reauth = true where id = 1`; else if (e instanceof SpotifyError && e.status === 401) await sql`update music.spotify_auth set access_token = null where id = 1`; } finally { await sql`update music.spotify_auth set lease_until = null, last_poll_at = now(), last_error = ${error} where id = 1`; } return result; }