181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
// 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=<cursor> → 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<string, string>
|
|
): Promise<TokenResponse> {
|
|
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<string, unknown>;
|
|
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<PollResult> {
|
|
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;
|
|
}
|