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
All checks were successful
Deploy production / deploy (push) Successful in 2m35s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
57
src/lib/music/live.svelte.ts
Normal file
57
src/lib/music/live.svelte.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
80
src/lib/music/now-playing.test.ts
Normal file
80
src/lib/music/now-playing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
src/lib/music/now-playing.ts
Normal file
81
src/lib/music/now-playing.ts
Normal 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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
26
src/lib/server/music/live.ts
Normal file
26
src/lib/server/music/live.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user