music: near-realtime now playing (currently-playing every 5 s while playing, 30 s idle backoff, Retry-After, recent plays ~10 s after a track change, holder-based lease) and a public SSE stream /api/listening/stream feeding the listening card live with client-side progress
All checks were successful
Deploy production / deploy (push) Successful in 2m35s

This commit is contained in:
2026-10-24 17:28:33 +00:00
parent 7636f0b2bc
commit fac49b57ba
13 changed files with 21036 additions and 114 deletions

View File

@@ -164,6 +164,7 @@ export const musicSpotifyAuth = musicSchema.table(
recentCursor: bigint('recent_cursor', { mode: 'number' }),
needsReauth: boolean('needs_reauth').notNull().default(false),
leaseUntil: timestamptz('lease_until'),
leaseHolder: text('lease_holder'),
lastPollAt: timestamptz('last_poll_at'),
lastError: text('last_error'),
connectedAt: timestamptz('connected_at').notNull().defaultNow(),

View File

@@ -0,0 +1,26 @@
import { EventEmitter } from 'node:events';
import type { LiveListening } from '#lib/music/now-playing.js';
// One in-process source of truth for what is playing: the poller publishes, every open
// /api/listening/stream connection subscribes. No per-client database polling. Kept on
// globalThis so dev-server module reloads do not orphan subscribers.
type Bus = { emitter: EventEmitter; current: LiveListening | null };
const g = globalThis as typeof globalThis & { __musicLive?: Bus };
const bus: Bus = (g.__musicLive ??= { emitter: new EventEmitter(), current: null });
bus.emitter.setMaxListeners(0);
/** Remember the latest state; `notify` pushes it to every subscriber. */
export function publishLive(state: LiveListening, notify: boolean) {
bus.current = state;
if (notify) bus.emitter.emit('now', state);
}
export const currentLive = () => bus.current;
export const liveSubscribers = () => bus.emitter.listenerCount('now');
export function subscribeLive(listener: (state: LiveListening) => void) {
bus.emitter.on('now', listener);
return () => {
bus.emitter.off('now', listener);
};
}

View File

@@ -1,43 +1,143 @@
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from '$app/env/private';
import { client } from '#lib/server/db/index.js';
import { pollSpotify } from '#lib/music/collector.server.js';
import {
acquireLease,
ensureAccess,
handlePollError,
pollPlayer,
pollRecent,
recordPoll,
SpotifyError
} from '#lib/music/collector.server.js';
import { newCache } from '#lib/music/store.server.js';
import { detectChange, nextDelay, toLive, type PlayerSample } from '#lib/music/now-playing.js';
import { currentLive, liveSubscribers, publishLive } from './live';
import { getLiveFromDb } from './stats';
// The Spotify poller runs inside the app (started from hooks.server.ts `init`): the app
// already has the database role (personal_app) and the Spotify client credentials, so
// no extra role, secret file or host timer is needed. It does nothing until the owner has
// connected Spotify at /api/spotify/connect. MUSIC_POLLER=off disables it (e.g. when the
// optional host timer, deploy/music-collector, is used instead).
const INTERVAL_MS = 60_000;
let timer: ReturnType<typeof setInterval> | undefined;
let running = false;
// already has the database role (personal_app) and the Spotify client credentials, so no
// extra role, secret file or host timer is needed. It does nothing until the owner has
// connected Spotify at /api/spotify/connect. MUSIC_POLLER=off disables it.
//
// Spotify has no webhooks, so "now playing" is polled: every 5 s while music plays, backing
// off to 30 s when paused or idle, and honouring 429 Retry-After. Changes (new track,
// play/pause, seek) are pushed to browsers via ./live. Recently played (the play log) runs
// every minute, plus ~10 s after a track change so a finished track lands quickly.
const LEASE_SECONDS = 20; // renewed every tick; another container takes over 20 s after we stop
const RECENT_MS = 60_000;
const CATCH_UP_MS = 10_000;
const HOLDER = `app-${crypto.randomUUID().slice(0, 8)}`;
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
let lastLogged = '';
let last: PlayerSample | null = null;
let idleTicks = 0;
let nextRecentAt = 0;
let catchUps = 0;
let cache = newCache();
export const spotifyCredentials = () => ({
clientId: SPOTIFY_CLIENT_ID,
clientSecret: SPOTIFY_CLIENT_SECRET
});
export async function pollNow() {
if (running || !SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
running = true;
function log(message: string) {
if (message && message !== lastLogged) console.error(`[music] poll failed: ${message}`);
lastLogged = message;
}
/** Another process holds the lease (e.g. the old container mid-deploy): mirror its writes. */
async function followDatabase() {
if (!liveSubscribers()) return;
const state = await getLiveFromDb();
const prev = currentLive();
if (!state) return;
const changed =
!prev ||
prev.track !== state.track ||
prev.isPlaying !== state.isPlaying ||
Math.abs(
(state.progressMs ?? 0) - (prev.progressMs ?? 0) - (state.sampledAt - prev.sampledAt)
) > 4_000;
publishLive(state, changed);
}
/** One tick; returns the delay until the next. */
export async function tick(): Promise<number> {
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return 60_000;
const auth = await acquireLease(client, HOLDER, LEASE_SECONDS);
if (!auth) {
last = null;
await followDatabase().catch(() => {});
return 10_000;
}
let error: string | null = null;
try {
const result = await pollSpotify(client, spotifyCredentials());
const message = result.status === 'error' ? result.error : '';
if (message && message !== lastLogged) console.error(`[music] poll failed: ${message}`);
lastLogged = message;
const access = await ensureAccess(client, spotifyCredentials(), auth);
if (cache.size > 2_000) cache = newCache();
const sample = await pollPlayer(client, access, cache);
const change = detectChange(last, sample);
publishLive(toLive(sample), change.any);
if (change.track && last) {
// The previous track just ended or was skipped: fetch its play soon.
nextRecentAt = Math.min(nextRecentAt, Date.now() + CATCH_UP_MS);
catchUps = 2;
}
last = sample;
idleTicks = sample.isPlaying ? 0 : idleTicks + 1;
if (Date.now() >= nextRecentAt) {
const added = await pollRecent(client, access, cache);
// Spotify can take a little while to log a finished track: retry twice, 20 s apart.
if (!added && catchUps > 0) {
catchUps--;
nextRecentAt = Date.now() + 20_000;
} else {
catchUps = 0;
nextRecentAt = Date.now() + RECENT_MS;
}
}
return nextDelay(sample.isPlaying, idleTicks);
} catch (e) {
error = await handlePollError(client, e).catch(() => String(e));
if (e instanceof SpotifyError && e.status === 429)
return Math.min(600, Math.max(5, e.retryAfter ?? 30)) * 1000;
return 30_000;
} finally {
log(error ?? '');
await recordPoll(client, error).catch(() => {});
}
}
let inTick = false;
async function loop() {
let delay = 30_000;
inTick = true;
try {
delay = await tick();
} catch (e) {
// e.g. the music schema is not migrated on this database yet; log once, keep trying.
const message = e instanceof Error ? e.message : String(e);
if (message !== lastLogged) console.error(`[music] poll failed: ${message}`);
lastLogged = message;
log(e instanceof Error ? e.message : String(e));
} finally {
running = false;
inTick = false;
}
if (!stopped) timer = setTimeout(loop, delay);
}
/** Poll right away (e.g. right after connecting Spotify) instead of at the next tick. */
export function pollNow() {
if (inTick || !timer || stopped) return;
clearTimeout(timer);
timer = setTimeout(loop, 0);
}
export function startMusicPoller() {
if (timer || process.env.MUSIC_POLLER === 'off') return;
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
timer = setInterval(pollNow, INTERVAL_MS);
setTimeout(pollNow, 5_000);
timer = setTimeout(loop, 3_000);
}
export function stopMusicPoller() {
stopped = true;
clearTimeout(timer);
}

View File

@@ -1,4 +1,5 @@
import { client as sql } from '#lib/server/db/index.js';
import type { LiveListening } from '#lib/music/now-playing.js';
// Read side of the listening tracker. Everything reads music.plays_deduped, which hides a
// Last.fm scrobble when Spotify (live API or export) has the same play, so nothing is
@@ -22,6 +23,9 @@ export type Listening = {
image: string | null;
url: string | null;
device: string | null;
/** Estimated position when the page was rendered (now playing only). */
progressMs: number | null;
durationMs: number | null;
};
const artistsOf = () => sql`coalesce((select string_agg(ar.name, ', ' order by ta.position)
@@ -36,7 +40,9 @@ const toListening = (row: Record<string, unknown>, nowPlaying: boolean): Listeni
album: (row.album as string) ?? null,
image: (row.image as string) ?? null,
url: row.spotify_id ? `https://open.spotify.com/track/${row.spotify_id}` : null,
device: (row.device as string) ?? null
device: (row.device as string) ?? null,
progressMs: row.progress_ms == null ? null : Number(row.progress_ms),
durationMs: row.duration_ms == null ? null : Number(row.duration_ms)
});
/** Homepage card: what is playing now (fresh within 3 min), else the last play. Never throws. */
@@ -44,7 +50,10 @@ export async function getListening(): Promise<Listening | null> {
try {
const [now] = await sql`
select np.updated_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
al.name as album, coalesce(al.image_url_small, al.image_url) as image, np.device_name as device
al.name as album, coalesce(al.image_url_small, al.image_url) as image, np.device_name as device,
t.duration_ms,
least(np.progress_ms + (extract(epoch from now() - np.updated_at) * 1000)::int,
coalesce(t.duration_ms, 2147483647)) as progress_ms
from music.now_playing np
join music.tracks t on t.id = np.track_id
left join music.artists a on a.id = t.artist_id
@@ -53,7 +62,8 @@ export async function getListening(): Promise<Listening | null> {
if (now) return toListening(now, true);
const [last] = await sql`
select p.played_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
al.name as album, coalesce(al.image_url_small, al.image_url) as image, null as device
al.name as album, coalesce(al.image_url_small, al.image_url) as image, null as device,
t.duration_ms, null as progress_ms
from music.plays p
join music.tracks t on t.id = p.track_id
left join music.artists a on a.id = t.artist_id
@@ -65,6 +75,39 @@ export async function getListening(): Promise<Listening | null> {
}
}
/**
* The live state as stored by whichever process polls (fresh within 3 minutes), for SSE
* clients of a process that is not polling itself. Never throws.
*/
export async function getLiveFromDb(): Promise<LiveListening | null> {
try {
const [row] = await sql`
select np.is_playing, np.progress_ms, np.updated_at, t.name as track, t.spotify_id,
${artistsOf()} as artists, al.name as album,
coalesce(al.image_url_small, al.image_url) as image, t.duration_ms
from music.now_playing np
left join music.tracks t on t.id = np.track_id
left join music.artists a on a.id = t.artist_id
left join music.albums al on al.id = t.album_id
where np.id = 1 and np.updated_at > now() - interval '3 minutes'`;
if (!row) return null;
const has = !!row.track; // paused keeps the track, like the poller's own state
return {
isPlaying: has && !!row.is_playing,
track: has ? (row.track as string) : null,
artists: has ? (row.artists as string) : '',
album: has ? ((row.album as string) ?? null) : null,
image: has ? ((row.image as string) ?? null) : null,
url: has && row.spotify_id ? `https://open.spotify.com/track/${row.spotify_id}` : null,
progressMs: has && row.progress_ms != null ? Number(row.progress_ms) : null,
durationMs: has && row.duration_ms != null ? Number(row.duration_ms) : null,
sampledAt: new Date(row.updated_at as string).getTime()
};
} catch {
return null;
}
}
export async function getMusicStats(range: Range) {
const days = RANGES[range];
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();