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

@@ -1,6 +1,8 @@
<script lang="ts">
import { relativeTime } from '#lib/relative-time.js';
import Music from 'lucide-svelte/icons/music';
import { live, useLive } from './live.svelte';
import { estimateProgress, formatClock } from './now-playing';
type Listening = {
nowPlaying: boolean;
@@ -10,26 +12,75 @@
album: string | null;
image: string | null;
url: string | null;
progressMs?: number | null;
durationMs?: number | null;
};
let { listening, href = '/trackers/music' }: { listening: Listening; href?: string } = $props();
let {
listening,
href = '/trackers/music',
linkToSpotify = false
}: { listening: Listening; href?: string; linkToSpotify?: boolean } = $props();
// Server-rendered state first (fast first paint), then live updates over SSE.
const renderedAt = Date.now();
let now = $state(Date.now());
$effect(() => useLive());
const view = $derived.by(() => {
const l = live.value;
if (l?.track)
return {
state: l.isPlaying ? 'playing' : 'paused',
at: new Date(l.sampledAt).toISOString(),
track: l.track,
artists: l.artists,
image: l.image,
url: l.url,
progress: estimateProgress(l, l.receivedAt, now),
durationMs: l.durationMs
};
const nowPlaying = listening.nowPlaying && !l; // a live "nothing playing" overrides SSR
return {
state: nowPlaying ? 'playing' : 'last',
at: listening.at,
track: listening.track,
artists: listening.artists,
image: listening.image,
url: listening.url,
progress:
nowPlaying && listening.progressMs != null
? Math.min(
listening.progressMs + Math.max(0, now - renderedAt),
listening.durationMs ?? Infinity
)
: null,
durationMs: listening.durationMs ?? null
};
});
const playing = $derived(view.state === 'playing');
$effect(() => {
const timer = setInterval(() => (now = Date.now()), 30_000);
const timer = setInterval(() => (now = Date.now()), playing ? 1_000 : 30_000);
return () => clearInterval(timer);
});
const pct = $derived(
view.progress != null && view.durationMs
? Math.min(100, (view.progress / view.durationMs) * 100)
: null
);
</script>
<a
{href}
class="flex items-center gap-4 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] p-3 hover:border-[var(--site-text)]"
aria-label="{listening.nowPlaying
href={linkToSpotify ? (view.url ?? href) : href}
class="relative flex items-center gap-4 overflow-hidden rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] p-3 hover:border-[var(--site-text)]"
aria-label="{playing
? 'Listening to'
: 'Last played'} {listening.track} by {listening.artists}"
: view.state === 'paused'
? 'Paused'
: 'Last played'} {view.track} by {view.artists}"
>
{#if listening.image}
{#if view.image}
<img
src={listening.image}
src={view.image}
alt=""
width="64"
height="64"
@@ -43,15 +94,27 @@
{/if}
<span class="min-w-0">
<span class="flex items-center gap-2 text-sm text-[var(--site-muted)]">
{#if listening.nowPlaying}
{#if playing}
<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span> listening now
{:else if view.state === 'paused'}
paused
{:else}
last played {relativeTime(listening.at, now)}
last played {relativeTime(view.at, now)}
{/if}
{#if view.state !== 'last' && view.progress != null && view.durationMs}
<span class="tabular-nums" aria-hidden="true"
>· {formatClock(view.progress)} / {formatClock(view.durationMs)}</span
>
{/if}
</span>
<span class="block truncate font-medium">{listening.track}</span>
<span class="block truncate text-sm text-[var(--site-muted)]">{listening.artists}</span>
<span class="block truncate font-medium">{view.track}</span>
<span class="block truncate text-sm text-[var(--site-muted)]">{view.artists}</span>
</span>
{#if pct !== null && view.state !== 'last'}
<span class="absolute inset-x-0 bottom-0 h-0.5 bg-[var(--site-border)]" aria-hidden="true"
><span class="block h-full bg-[#1db954]" style="width: {pct}%"></span></span
>
{/if}
</a>
<style>

View File

@@ -1,12 +1,14 @@
// Spotify poller: one call does what a scrobbler does every minute.
// 1. GET /me/player → music.now_playing (row 1)
// Spotify poller building blocks:
// 1. GET /me/player/currently-playing → music.now_playing (row 1); the app polls this every
// 5 s while music plays (see src/lib/server/music/poller.ts) and pushes changes live
// 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)
// 30 s or more, max 50 per call), every minute and shortly after each track change
// 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.
// /api/spotify/callback. A lease (lease_until + lease_holder) keeps two app containers (e.g.
// mid-deploy) or the optional host timer (scripts/music-poll.ts) from polling at once.
import { newCache, upsertTrack, insertPlay, type Db } from './store.server';
import { parsePlayer, parseRecentlyPlayed } from './spotify-parse';
import type { PlayerSample } from './now-playing';
export type SpotifyCredentials = { clientId: string; clientSecret: string };
type Fetch = typeof fetch;
@@ -25,7 +27,9 @@ export class SpotifyError extends Error {
constructor(
readonly status: number,
message: string,
readonly code?: string
readonly code?: string,
/** Seconds from a 429 Retry-After header. */
readonly retryAfter?: number
) {
super(message);
}
@@ -82,7 +86,9 @@ export async function spotifyGet(fetchFn: Fetch, access: string, path: string) {
if (!response.ok)
throw new SpotifyError(
response.status,
`Spotify GET /${path.split('?')[0]} → ${response.status}`
`Spotify GET /${path.split('?')[0]} → ${response.status}`,
undefined,
Number(response.headers.get('retry-after')) || undefined
);
const text = await response.text();
return text ? (JSON.parse(text) as unknown) : null;
@@ -93,88 +99,166 @@ export type PollResult =
| { status: 'ok'; playing: boolean; newPlays: number }
| { status: 'error'; error: string };
export type SpotifyAuthRow = {
refresh_token: string;
access_token: string | null;
access_expires_at: string | Date | null;
recent_cursor: string | number | null;
};
/**
* Take or renew the single-poller lease. Null when Spotify is not connected (or needs
* re-auth) or another poller holds an unexpired lease.
*/
export async function acquireLease(
sql: Db,
holder: string,
seconds: number
): Promise<SpotifyAuthRow | null> {
const [auth] = await sql`
update music.spotify_auth
set lease_until = now() + make_interval(secs => ${seconds}), lease_holder = ${holder}
where id = 1 and not needs_reauth
and (lease_until is null or lease_until < now() or lease_holder = ${holder})
returning refresh_token, access_token, access_expires_at, recent_cursor`;
return (auth as SpotifyAuthRow | undefined) ?? null;
}
export async function releaseLease(sql: Db, holder: string) {
await sql`update music.spotify_auth set lease_until = null
where id = 1 and lease_holder = ${holder}`;
}
export async function recordPoll(sql: Db, error: string | null) {
await sql`update music.spotify_auth set last_poll_at = now(), last_error = ${error} where id = 1`;
}
/** A valid access token, refreshing (and storing) it when it expires within a minute. */
export async function ensureAccess(
sql: Db,
creds: SpotifyCredentials,
auth: SpotifyAuthRow,
fetchFn: Fetch = fetch
) {
const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0;
if (auth.access_token && expires >= Date.now() + 60_000) return auth.access_token;
const fresh = await refreshAccessToken(fetchFn, creds, auth.refresh_token);
await sql`
update music.spotify_auth set access_token = ${fresh.access_token},
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`;
return fresh.access_token;
}
export const CURRENTLY_PLAYING = 'me/player/currently-playing?additional_types=episode';
export const PLAYER = 'me/player?additional_types=episode';
/** Sample the player and store it in music.now_playing. */
export async function pollPlayer(
sql: Db,
access: string,
cache = newCache(),
path = CURRENTLY_PLAYING,
fetchFn: Fetch = fetch
): Promise<PlayerSample> {
const sampledAt = Date.now();
const player = parsePlayer(await spotifyGet(fetchFn, access, path));
const trackId = player?.track ? await upsertTrack(sql, player.track, true, cache) : null;
// currently-playing has no device: keep the last known one (from /me/player) then.
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 = coalesce(excluded.device_name, music.now_playing.device_name),
device_type = coalesce(excluded.device_type, music.now_playing.device_type),
context_uri = excluded.context_uri, updated_at = now()`;
return {
trackId,
track: player?.track ?? null,
isPlaying: !!(player?.isPlaying && trackId),
progressMs: player?.progressMs ?? null,
sampledAt
};
}
/** Store plays newer than the cursor; returns how many were new. */
export async function pollRecent(
sql: Db,
access: string,
cache = newCache(),
fetchFn: Fetch = fetch
): Promise<number> {
const [row] = await sql`select recent_cursor from music.spotify_auth where id = 1`;
const cursor: number | null = row?.recent_cursor == null ? null : Number(row.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`;
return newPlays;
}
/** Record a failed poll: revoked grants stop polling, a stale access token is dropped. */
export async function handlePollError(sql: Db, e: unknown) {
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`;
return e instanceof Error ? e.message : String(e);
}
/** One full poll (player + recent plays) for the optional host timer, scripts/music-poll.ts. */
export async function pollSpotify(
sql: Db,
creds: SpotifyCredentials,
fetchFn: Fetch = fetch
fetchFn: Fetch = fetch,
holder = 'host-timer'
): 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`;
const auth = await acquireLease(sql, holder, 55);
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 access = await ensureAccess(sql, creds, auth, fetchFn);
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 };
const sample = await pollPlayer(sql, access, cache, PLAYER, fetchFn);
const newPlays = await pollRecent(sql, access, cache, fetchFn);
result = { status: 'ok', playing: sample.isPlaying, newPlays };
} catch (e) {
error = e instanceof Error ? e.message : String(e);
error = await handlePollError(sql, 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`;
await recordPoll(sql, error);
await releaseLease(sql, holder);
}
return result;
}

View File

@@ -0,0 +1,57 @@
import type { LiveListening } from './now-playing';
// One EventSource per tab, shared by every component showing what is playing. It closes
// while the tab is hidden (after a grace period) and reopens when it is visible again.
type Received = LiveListening & { receivedAt: number };
export const live = $state<{ value: Received | null; connected: boolean }>({
value: null,
connected: false
});
let source: EventSource | null = null;
let users = 0;
let hiddenTimer: ReturnType<typeof setTimeout> | undefined;
function open() {
if (source || typeof EventSource === 'undefined') return;
source = new EventSource('/api/listening/stream');
source.addEventListener('now', (event) => {
try {
const data = JSON.parse((event as MessageEvent<string>).data) as LiveListening & {
ageMs?: number;
};
live.value = { ...data, receivedAt: Date.now() - (data.ageMs ?? 0) };
} catch {
// ignore malformed events
}
});
source.onopen = () => (live.connected = true);
source.onerror = () => (live.connected = false);
}
function close() {
source?.close();
source = null;
live.connected = false;
}
function onVisibility() {
clearTimeout(hiddenTimer);
if (document.visibilityState === 'hidden') hiddenTimer = setTimeout(close, 60_000);
else open();
}
/** Start listening; returns the stop function (use as an $effect cleanup). */
export function useLive() {
if (typeof window === 'undefined') return () => {};
if (users++ === 0) {
open();
document.addEventListener('visibilitychange', onVisibility);
}
return () => {
if (--users > 0) return;
document.removeEventListener('visibilitychange', onVisibility);
clearTimeout(hiddenTimer);
close();
};
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
detectChange,
estimateProgress,
formatClock,
nextDelay,
sseEvent,
toLive,
type PlayerSample
} from './now-playing';
const track = (id: string) => ({
spotifyId: id,
name: `Song ${id}`,
durationMs: 200_000,
explicit: false,
artists: [
{ spotifyId: null, name: 'A' },
{ spotifyId: null, name: 'B' }
],
album: {
spotifyId: null,
name: 'Album',
imageUrl: 'https://i/640',
imageUrlSmall: 'https://i/300',
releaseDate: null
}
});
const sample = (over: Partial<PlayerSample> = {}): PlayerSample => ({
trackId: 1,
track: track('x'),
isPlaying: true,
progressMs: 10_000,
sampledAt: 1_000_000,
...over
});
describe('now playing', () => {
it('builds the public payload', () => {
expect(toLive(sample())).toEqual({
isPlaying: true,
track: 'Song x',
artists: 'A, B',
album: 'Album',
image: 'https://i/300',
url: 'https://open.spotify.com/track/x',
progressMs: 10_000,
durationMs: 200_000,
sampledAt: 1_000_000
});
expect(toLive(sample({ track: null, trackId: null }))).toMatchObject({
isPlaying: false,
track: null
});
});
it('detects track changes, play/pause and seeks, not normal progress', () => {
const a = sample();
expect(detectChange(null, a).any).toBe(true);
expect(detectChange(a, sample({ progressMs: 15_000, sampledAt: 1_005_000 })).any).toBe(false);
expect(detectChange(a, sample({ progressMs: 90_000, sampledAt: 1_005_000 })).seek).toBe(true);
expect(detectChange(a, sample({ isPlaying: false })).playing).toBe(true);
expect(detectChange(a, sample({ track: track('y') })).track).toBe(true);
});
it('estimates progress between events and clamps to the duration', () => {
const l = toLive(sample());
expect(estimateProgress(l, 0, 5_000)).toBe(15_000);
expect(estimateProgress(l, 0, 10_000_000)).toBe(200_000);
expect(estimateProgress({ ...l, isPlaying: false }, 0, 5_000)).toBe(10_000);
expect(formatClock(75_000)).toBe('1:15');
});
it('formats SSE events with the sample age, and backs off when idle', () => {
const event = sseEvent(toLive(sample()), 1_002_000);
expect(event.startsWith('event: now\ndata: ')).toBe(true);
expect(event.endsWith('\n\n')).toBe(true);
expect(JSON.parse(event.split('data: ')[1]).ageMs).toBe(2_000);
expect(nextDelay(true, 9)).toBe(5_000);
expect(nextDelay(false, 0)).toBe(10_000);
expect(nextDelay(false, 50)).toBe(30_000);
});
});

View File

@@ -0,0 +1,81 @@
// Live "now playing" state pushed to browsers over /api/listening/stream. Pure (no I/O):
// shared by the poller, the SSE endpoint, the client store and the tests. Only public
// fields: what is playing and how far along, never device or account details.
import type { ParsedTrack } from './spotify-parse';
export type LiveListening = {
isPlaying: boolean;
/** Null when nothing is playing (or a podcast/ad). */
track: string | null;
artists: string;
album: string | null;
image: string | null;
url: string | null;
progressMs: number | null;
durationMs: number | null;
/** Server time (ms) when Spotify was sampled. */
sampledAt: number;
};
export type PlayerSample = {
trackId: number | null;
track: ParsedTrack | null;
isPlaying: boolean;
progressMs: number | null;
sampledAt: number;
};
export function toLive(sample: PlayerSample): LiveListening {
const t = sample.track;
return {
isPlaying: sample.isPlaying && !!t,
track: t?.name ?? null,
artists: t ? t.artists.map((a) => a.name).join(', ') : '',
album: t?.album?.name ?? null,
image: t?.album?.imageUrlSmall ?? t?.album?.imageUrl ?? null,
url: t?.spotifyId ? `https://open.spotify.com/track/${t.spotifyId}` : null,
progressMs: t ? sample.progressMs : null,
durationMs: t?.durationMs ?? null,
sampledAt: sample.sampledAt
};
}
const SEEK_TOLERANCE_MS = 4_000;
/** What changed between two samples; `any` means browsers should hear about it. */
export function detectChange(prev: PlayerSample | null, next: PlayerSample) {
const key = (s: PlayerSample) => s.track?.spotifyId ?? s.track?.name ?? null;
const track = !prev || key(prev) !== key(next);
const playing = !prev || prev.isPlaying !== next.isPlaying;
let seek = false;
if (!track && !playing && next.isPlaying && prev?.progressMs != null && next.progressMs != null) {
const expected = prev.progressMs + (next.sampledAt - prev.sampledAt);
seek = Math.abs(next.progressMs - expected) > SEEK_TOLERANCE_MS;
}
return { track, playing, seek, any: track || playing || seek };
}
/** Estimated playback position at `now` (client side, between events). */
export function estimateProgress(live: LiveListening, receivedAt: number, now: number) {
if (live.progressMs === null) return null;
const progress = live.isPlaying
? live.progressMs + Math.max(0, now - receivedAt)
: live.progressMs;
return live.durationMs ? Math.min(progress, live.durationMs) : progress;
}
export function formatClock(ms: number) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
/** One Server-Sent Event; `ageMs` lets late joiners correct for the sample's age. */
export function sseEvent(live: LiveListening, now = Date.now()) {
return `event: now\ndata: ${JSON.stringify({ ...live, ageMs: Math.max(0, now - live.sampledAt) })}\n\n`;
}
/** Polling cadence: fast while music plays, backing off to 30 s when idle. */
export function nextDelay(isPlaying: boolean, idleTicks: number) {
if (isPlaying) return 5_000;
return Math.min(30_000, 10_000 + idleTicks * 5_000);
}

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();

View File

@@ -0,0 +1,99 @@
import type { RequestHandler } from './$types';
import { currentLive, subscribeLive } from '#lib/server/music/live.js';
import { getLiveFromDb } from '#lib/server/music/stats.js';
import { sseEvent } from '#lib/music/now-playing.js';
// Public Server-Sent Events stream of what Stan is listening to (track, artists, album art,
// playing/paused, progress; nothing else). Every connection shares the poller's in-process
// state (#lib/server/music/live), so clients cost no database queries after connecting.
const MAX_CONNECTIONS = 300;
const MAX_PER_CLIENT = 4;
// Under Bun's 10 s idle timeout (adapter-bun default) and Cloudflare's proxy timeouts.
const HEARTBEAT_MS = 8_000;
// Recycle long-lived connections; EventSource reconnects on its own after `retry`.
const MAX_AGE_MS = 30 * 60_000;
let open = 0;
const perClient = new Map<string, number>();
function clientKey(request: Request, getClientAddress: () => string) {
const forwarded =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (forwarded) return forwarded;
try {
return getClientAddress();
} catch {
return 'unknown';
}
}
export const GET: RequestHandler = async ({ request, getClientAddress }) => {
const key = clientKey(request, getClientAddress);
if (open >= MAX_CONNECTIONS || (perClient.get(key) ?? 0) >= MAX_PER_CLIENT)
return new Response('Too many listeners, try again shortly.', {
status: 429,
headers: { 'retry-after': '30', 'cache-control': 'no-store' }
});
open++;
perClient.set(key, (perClient.get(key) ?? 0) + 1);
let released = false;
const release = () => {
if (released) return;
released = true;
open--;
const n = (perClient.get(key) ?? 1) - 1;
if (n > 0) perClient.set(key, n);
else perClient.delete(key);
};
const initial = currentLive() ?? (await getLiveFromDb());
const encoder = new TextEncoder();
let cleanup = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const send = (text: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(text));
} catch {
cleanup();
}
};
const unsubscribe = subscribeLive((state) => send(sseEvent(state)));
const heartbeat = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
const maxAge = setTimeout(() => {
cleanup();
try {
controller.close();
} catch {
// already closed
}
}, MAX_AGE_MS);
cleanup = () => {
if (closed) return;
closed = true;
unsubscribe();
clearInterval(heartbeat);
clearTimeout(maxAge);
request.signal.removeEventListener('abort', cleanup);
release();
};
request.signal.addEventListener('abort', cleanup);
// Padding first so proxies that buffer small responses start streaming at once.
send(`:${' '.repeat(2048)}\nretry: 5000\n\n`);
if (initial) send(sseEvent(initial));
},
cancel() {
cleanup();
}
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-store, no-transform',
'x-accel-buffering': 'no'
}
});
};

View File

@@ -66,7 +66,7 @@
{#if data.listening}
<div class="mb-8 max-w-md">
<ListeningCard listening={data.listening} href={data.listening.url ?? '/trackers/music'} />
<ListeningCard listening={data.listening} linkToSpotify />
</div>
{/if}