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,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);
});
});