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

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

View File

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

View File

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