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:
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}
|
||||
<div class="mb-8 max-w-md">
|
||||
<ListeningCard listening={data.listening} href={data.listening.url ?? '/trackers/music'} />
|
||||
<ListeningCard listening={data.listening} linkToSpotify />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user