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:
@@ -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