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
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
1
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "music"."spotify_auth" ADD COLUMN "lease_holder" text;
|
||||||
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { relativeTime } from '#lib/relative-time.js';
|
import { relativeTime } from '#lib/relative-time.js';
|
||||||
import Music from 'lucide-svelte/icons/music';
|
import Music from 'lucide-svelte/icons/music';
|
||||||
|
import { live, useLive } from './live.svelte';
|
||||||
|
import { estimateProgress, formatClock } from './now-playing';
|
||||||
|
|
||||||
type Listening = {
|
type Listening = {
|
||||||
nowPlaying: boolean;
|
nowPlaying: boolean;
|
||||||
@@ -10,26 +12,75 @@
|
|||||||
album: string | null;
|
album: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
url: 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());
|
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(() => {
|
$effect(() => {
|
||||||
const timer = setInterval(() => (now = Date.now()), 30_000);
|
const timer = setInterval(() => (now = Date.now()), playing ? 1_000 : 30_000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
});
|
});
|
||||||
|
const pct = $derived(
|
||||||
|
view.progress != null && view.durationMs
|
||||||
|
? Math.min(100, (view.progress / view.durationMs) * 100)
|
||||||
|
: null
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
{href}
|
href={linkToSpotify ? (view.url ?? href) : 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)]"
|
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="{listening.nowPlaying
|
aria-label="{playing
|
||||||
? 'Listening to'
|
? '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
|
<img
|
||||||
src={listening.image}
|
src={view.image}
|
||||||
alt=""
|
alt=""
|
||||||
width="64"
|
width="64"
|
||||||
height="64"
|
height="64"
|
||||||
@@ -43,15 +94,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span class="flex items-center gap-2 text-sm text-[var(--site-muted)]">
|
<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
|
<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span> listening now
|
||||||
|
{:else if view.state === 'paused'}
|
||||||
|
paused
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="block truncate font-medium">{listening.track}</span>
|
<span class="block truncate font-medium">{view.track}</span>
|
||||||
<span class="block truncate text-sm text-[var(--site-muted)]">{listening.artists}</span>
|
<span class="block truncate text-sm text-[var(--site-muted)]">{view.artists}</span>
|
||||||
</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>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Spotify poller: one call does what a scrobbler does every minute.
|
// Spotify poller building blocks:
|
||||||
// 1. GET /me/player → music.now_playing (row 1)
|
// 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
|
// 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
|
// 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
|
// /api/spotify/callback. A lease (lease_until + lease_holder) keeps two app containers (e.g.
|
||||||
// optional host timer (scripts/music-poll.ts) from polling at the same time.
|
// 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 { newCache, upsertTrack, insertPlay, type Db } from './store.server';
|
||||||
import { parsePlayer, parseRecentlyPlayed } from './spotify-parse';
|
import { parsePlayer, parseRecentlyPlayed } from './spotify-parse';
|
||||||
|
import type { PlayerSample } from './now-playing';
|
||||||
|
|
||||||
export type SpotifyCredentials = { clientId: string; clientSecret: string };
|
export type SpotifyCredentials = { clientId: string; clientSecret: string };
|
||||||
type Fetch = typeof fetch;
|
type Fetch = typeof fetch;
|
||||||
@@ -25,7 +27,9 @@ export class SpotifyError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
message: string,
|
message: string,
|
||||||
readonly code?: string
|
readonly code?: string,
|
||||||
|
/** Seconds from a 429 Retry-After header. */
|
||||||
|
readonly retryAfter?: number
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,9 @@ export async function spotifyGet(fetchFn: Fetch, access: string, path: string) {
|
|||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new SpotifyError(
|
throw new SpotifyError(
|
||||||
response.status,
|
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();
|
const text = await response.text();
|
||||||
return text ? (JSON.parse(text) as unknown) : null;
|
return text ? (JSON.parse(text) as unknown) : null;
|
||||||
@@ -93,37 +99,74 @@ export type PollResult =
|
|||||||
| { status: 'ok'; playing: boolean; newPlays: number }
|
| { status: 'ok'; playing: boolean; newPlays: number }
|
||||||
| { status: 'error'; error: string };
|
| { status: 'error'; error: string };
|
||||||
|
|
||||||
export async function pollSpotify(
|
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,
|
sql: Db,
|
||||||
creds: SpotifyCredentials,
|
creds: SpotifyCredentials,
|
||||||
|
auth: SpotifyAuthRow,
|
||||||
fetchFn: Fetch = fetch
|
fetchFn: Fetch = fetch
|
||||||
): Promise<PollResult> {
|
) {
|
||||||
const [auth] = await sql`
|
|
||||||
update music.spotify_auth set lease_until = now() + interval '55 seconds'
|
|
||||||
where id = 1 and not needs_reauth and (lease_until is null or lease_until < now())
|
|
||||||
returning refresh_token, access_token, access_expires_at, recent_cursor`;
|
|
||||||
if (!auth) return { status: 'idle' };
|
|
||||||
let error: string | null = null;
|
|
||||||
let result: PollResult;
|
|
||||||
try {
|
|
||||||
let access: string | null = auth.access_token;
|
|
||||||
const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0;
|
const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0;
|
||||||
if (!access || expires < Date.now() + 60_000) {
|
if (auth.access_token && expires >= Date.now() + 60_000) return auth.access_token;
|
||||||
const fresh = await refreshAccessToken(fetchFn, creds, auth.refresh_token);
|
const fresh = await refreshAccessToken(fetchFn, creds, auth.refresh_token);
|
||||||
access = fresh.access_token;
|
|
||||||
await sql`
|
await sql`
|
||||||
update music.spotify_auth set access_token = ${access},
|
update music.spotify_auth set access_token = ${fresh.access_token},
|
||||||
access_expires_at = now() + make_interval(secs => ${fresh.expires_in}),
|
access_expires_at = now() + make_interval(secs => ${fresh.expires_in}),
|
||||||
refresh_token = coalesce(${fresh.refresh_token ?? null}, refresh_token),
|
refresh_token = coalesce(${fresh.refresh_token ?? null}, refresh_token),
|
||||||
scope = coalesce(${fresh.scope ?? null}, scope), updated_at = now()
|
scope = coalesce(${fresh.scope ?? null}, scope), updated_at = now()
|
||||||
where id = 1`;
|
where id = 1`;
|
||||||
}
|
return fresh.access_token;
|
||||||
const cache = newCache();
|
}
|
||||||
|
|
||||||
const player = parsePlayer(
|
export const CURRENTLY_PLAYING = 'me/player/currently-playing?additional_types=episode';
|
||||||
await spotifyGet(fetchFn, access!, 'me/player?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;
|
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`
|
await sql`
|
||||||
insert into music.now_playing (id, track_id, is_playing, progress_ms, device_name, device_type, context_uri, updated_at)
|
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},
|
values (1, ${trackId}, ${!!(player?.isPlaying && trackId)}, ${player?.progressMs ?? null},
|
||||||
@@ -131,14 +174,31 @@ export async function pollSpotify(
|
|||||||
on conflict (id) do update set
|
on conflict (id) do update set
|
||||||
track_id = coalesce(excluded.track_id, music.now_playing.track_id),
|
track_id = coalesce(excluded.track_id, music.now_playing.track_id),
|
||||||
is_playing = excluded.is_playing, progress_ms = excluded.progress_ms,
|
is_playing = excluded.is_playing, progress_ms = excluded.progress_ms,
|
||||||
device_name = excluded.device_name, device_type = excluded.device_type,
|
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()`;
|
context_uri = excluded.context_uri, updated_at = now()`;
|
||||||
|
return {
|
||||||
|
trackId,
|
||||||
|
track: player?.track ?? null,
|
||||||
|
isPlaying: !!(player?.isPlaying && trackId),
|
||||||
|
progressMs: player?.progressMs ?? null,
|
||||||
|
sampledAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const cursor: number | null = auth.recent_cursor === null ? null : Number(auth.recent_cursor);
|
/** 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(
|
const recent = parseRecentlyPlayed(
|
||||||
await spotifyGet(
|
await spotifyGet(
|
||||||
fetchFn,
|
fetchFn,
|
||||||
access!,
|
access,
|
||||||
`me/player/recently-played?limit=50${cursor ? `&after=${cursor}` : ''}`
|
`me/player/recently-played?limit=50${cursor ? `&after=${cursor}` : ''}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -163,18 +223,42 @@ export async function pollSpotify(
|
|||||||
}
|
}
|
||||||
if (newest && newest !== cursor)
|
if (newest && newest !== cursor)
|
||||||
await sql`update music.spotify_auth set recent_cursor = ${newest} where id = 1`;
|
await sql`update music.spotify_auth set recent_cursor = ${newest} where id = 1`;
|
||||||
result = { status: 'ok', playing: !!player?.isPlaying, newPlays };
|
return newPlays;
|
||||||
} catch (e) {
|
}
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
result = { status: 'error', error };
|
/** 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')
|
if (e instanceof SpotifyError && e.code === 'invalid_grant')
|
||||||
// The refresh token was revoked: stop polling until /api/spotify/connect is used again.
|
// 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`;
|
await sql`update music.spotify_auth set needs_reauth = true where id = 1`;
|
||||||
else if (e instanceof SpotifyError && e.status === 401)
|
else if (e instanceof SpotifyError && e.status === 401)
|
||||||
await sql`update music.spotify_auth set access_token = null where id = 1`;
|
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,
|
||||||
|
holder = 'host-timer'
|
||||||
|
): Promise<PollResult> {
|
||||||
|
const auth = await acquireLease(sql, holder, 55);
|
||||||
|
if (!auth) return { status: 'idle' };
|
||||||
|
let error: string | null = null;
|
||||||
|
let result: PollResult;
|
||||||
|
try {
|
||||||
|
const access = await ensureAccess(sql, creds, auth, fetchFn);
|
||||||
|
const cache = newCache();
|
||||||
|
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 = await handlePollError(sql, e);
|
||||||
|
result = { status: 'error', error };
|
||||||
} finally {
|
} finally {
|
||||||
await sql`update music.spotify_auth set lease_until = null, last_poll_at = now(),
|
await recordPoll(sql, error);
|
||||||
last_error = ${error} where id = 1`;
|
await releaseLease(sql, holder);
|
||||||
}
|
}
|
||||||
return result;
|
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' }),
|
recentCursor: bigint('recent_cursor', { mode: 'number' }),
|
||||||
needsReauth: boolean('needs_reauth').notNull().default(false),
|
needsReauth: boolean('needs_reauth').notNull().default(false),
|
||||||
leaseUntil: timestamptz('lease_until'),
|
leaseUntil: timestamptz('lease_until'),
|
||||||
|
leaseHolder: text('lease_holder'),
|
||||||
lastPollAt: timestamptz('last_poll_at'),
|
lastPollAt: timestamptz('last_poll_at'),
|
||||||
lastError: text('last_error'),
|
lastError: text('last_error'),
|
||||||
connectedAt: timestamptz('connected_at').notNull().defaultNow(),
|
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 { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from '$app/env/private';
|
||||||
import { client } from '#lib/server/db/index.js';
|
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
|
// 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
|
// already has the database role (personal_app) and the Spotify client credentials, so no
|
||||||
// no extra role, secret file or host timer is needed. It does nothing until the owner has
|
// 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
|
// connected Spotify at /api/spotify/connect. MUSIC_POLLER=off disables it.
|
||||||
// optional host timer, deploy/music-collector, is used instead).
|
//
|
||||||
const INTERVAL_MS = 60_000;
|
// Spotify has no webhooks, so "now playing" is polled: every 5 s while music plays, backing
|
||||||
let timer: ReturnType<typeof setInterval> | undefined;
|
// off to 30 s when paused or idle, and honouring 429 Retry-After. Changes (new track,
|
||||||
let running = false;
|
// 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 lastLogged = '';
|
||||||
|
let last: PlayerSample | null = null;
|
||||||
|
let idleTicks = 0;
|
||||||
|
let nextRecentAt = 0;
|
||||||
|
let catchUps = 0;
|
||||||
|
let cache = newCache();
|
||||||
|
|
||||||
export const spotifyCredentials = () => ({
|
export const spotifyCredentials = () => ({
|
||||||
clientId: SPOTIFY_CLIENT_ID,
|
clientId: SPOTIFY_CLIENT_ID,
|
||||||
clientSecret: SPOTIFY_CLIENT_SECRET
|
clientSecret: SPOTIFY_CLIENT_SECRET
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function pollNow() {
|
function log(message: string) {
|
||||||
if (running || !SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
|
|
||||||
running = true;
|
|
||||||
try {
|
|
||||||
const result = await pollSpotify(client, spotifyCredentials());
|
|
||||||
const message = result.status === 'error' ? result.error : '';
|
|
||||||
if (message && message !== lastLogged) console.error(`[music] poll failed: ${message}`);
|
if (message && message !== lastLogged) console.error(`[music] poll failed: ${message}`);
|
||||||
lastLogged = 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 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) {
|
} catch (e) {
|
||||||
// e.g. the music schema is not migrated on this database yet; log once, keep trying.
|
// 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);
|
log(e instanceof Error ? e.message : String(e));
|
||||||
if (message !== lastLogged) console.error(`[music] poll failed: ${message}`);
|
|
||||||
lastLogged = message;
|
|
||||||
} finally {
|
} 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() {
|
export function startMusicPoller() {
|
||||||
if (timer || process.env.MUSIC_POLLER === 'off') return;
|
if (timer || process.env.MUSIC_POLLER === 'off') return;
|
||||||
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
|
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
|
||||||
timer = setInterval(pollNow, INTERVAL_MS);
|
timer = setTimeout(loop, 3_000);
|
||||||
setTimeout(pollNow, 5_000);
|
}
|
||||||
|
|
||||||
|
export function stopMusicPoller() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { client as sql } from '#lib/server/db/index.js';
|
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
|
// 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
|
// 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;
|
image: string | null;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
device: 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)
|
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,
|
album: (row.album as string) ?? null,
|
||||||
image: (row.image as string) ?? null,
|
image: (row.image as string) ?? null,
|
||||||
url: row.spotify_id ? `https://open.spotify.com/track/${row.spotify_id}` : 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. */
|
/** 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 {
|
try {
|
||||||
const [now] = await sql`
|
const [now] = await sql`
|
||||||
select np.updated_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
|
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
|
from music.now_playing np
|
||||||
join music.tracks t on t.id = np.track_id
|
join music.tracks t on t.id = np.track_id
|
||||||
left join music.artists a on a.id = t.artist_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);
|
if (now) return toListening(now, true);
|
||||||
const [last] = await sql`
|
const [last] = await sql`
|
||||||
select p.played_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
|
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
|
from music.plays p
|
||||||
join music.tracks t on t.id = p.track_id
|
join music.tracks t on t.id = p.track_id
|
||||||
left join music.artists a on a.id = t.artist_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) {
|
export async function getMusicStats(range: Range) {
|
||||||
const days = RANGES[range];
|
const days = RANGES[range];
|
||||||
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();
|
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();
|
||||||
|
|||||||
99
src/routes/api/listening/stream/+server.ts
Normal file
99
src/routes/api/listening/stream/+server.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
{#if data.listening}
|
{#if data.listening}
|
||||||
<div class="mb-8 max-w-md">
|
<div class="mb-8 max-w-md">
|
||||||
<ListeningCard listening={data.listening} href={data.listening.url ?? '/trackers/music'} />
|
<ListeningCard listening={data.listening} linkToSpotify />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user