From b6f8a0c390c43abe0a3418bc53ab1503fd72648a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Apr 2026 09:42:11 -0400 Subject: [PATCH] feat(web): events dispatcher posts to /api/events on player transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useEventsDispatcher subscribes to player.current/state via \$effect: on first transition to playing for a new track, POSTs play_started and caches the id; on track-id change while a play is open, closes the prior row as play_skipped; on natural completion (state playing→paused with position >= duration > 0), POSTs play_ended; on pagehide, navigator.sendBeacon fires a final play_skipped. Stable per-tab client_id from sessionStorage. --- web/src/lib/api/types.ts | 8 ++ web/src/lib/player/clientId.test.ts | 19 +++ web/src/lib/player/clientId.ts | 13 +++ web/src/lib/player/events.svelte.test.ts | 142 +++++++++++++++++++++++ web/src/lib/player/events.svelte.ts | 107 +++++++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 web/src/lib/player/clientId.test.ts create mode 100644 web/src/lib/player/clientId.ts create mode 100644 web/src/lib/player/events.svelte.test.ts create mode 100644 web/src/lib/player/events.svelte.ts diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index d2288404..4725753b 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -56,3 +56,11 @@ export type SearchResponse = { export type RadioResponse = { tracks: TrackRef[]; }; + +export type EventRequest = + | { type: 'play_started'; track_id: string; client_id?: string } + | { type: 'play_ended'; play_event_id: string; duration_played_ms: number } + | { type: 'play_skipped'; play_event_id: string; position_ms: number }; + +export type PlayStartedResponse = { play_event_id: string; session_id: string }; +export type EventOkResponse = { ok: true }; diff --git a/web/src/lib/player/clientId.test.ts b/web/src/lib/player/clientId.test.ts new file mode 100644 index 00000000..ca0e934e --- /dev/null +++ b/web/src/lib/player/clientId.test.ts @@ -0,0 +1,19 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { getOrCreateClientId } from './clientId'; + +beforeEach(() => sessionStorage.clear()); +afterEach(() => sessionStorage.clear()); + +describe('getOrCreateClientId', () => { + test('generates a UUID on first call and persists it in sessionStorage', () => { + const id = getOrCreateClientId(); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + expect(sessionStorage.getItem('minstrel.client_id')).toBe(id); + }); + + test('returns the same id on subsequent calls', () => { + const a = getOrCreateClientId(); + const b = getOrCreateClientId(); + expect(a).toBe(b); + }); +}); diff --git a/web/src/lib/player/clientId.ts b/web/src/lib/player/clientId.ts new file mode 100644 index 00000000..84c73809 --- /dev/null +++ b/web/src/lib/player/clientId.ts @@ -0,0 +1,13 @@ +const KEY = 'minstrel.client_id'; + +export function getOrCreateClientId(): string { + try { + const existing = sessionStorage.getItem(KEY); + if (existing) return existing; + } catch { + // sessionStorage unavailable (non-browser env). Generate ephemerally. + } + const id = crypto.randomUUID(); + try { sessionStorage.setItem(KEY, id); } catch { /* ignore */ } + return id; +} diff --git a/web/src/lib/player/events.svelte.test.ts b/web/src/lib/player/events.svelte.test.ts new file mode 100644 index 00000000..bcb657d9 --- /dev/null +++ b/web/src/lib/player/events.svelte.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { flushSync } from 'svelte'; + +vi.mock('$lib/api/client', () => ({ + api: { + post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' }) + } +})); + +vi.mock('./clientId', () => ({ + getOrCreateClientId: () => 'test-client' +})); + +import { useEventsDispatcher } from './events.svelte'; +import { + playQueue, + reportStateFromAudio, + reportTimeUpdate, + reportDuration, + skipNext, +} from './store.svelte'; +import { api } from '$lib/api/client'; +import type { TrackRef } from '$lib/api/types'; + +function track(id: string, dur = 200): TrackRef { + return { + id, title: `T ${id}`, + album_id: 'a', album_title: 'A', + artist_id: 'ar', artist_name: 'Ar', + track_number: Number(id), disc_number: 1, + duration_sec: dur, + stream_url: `/api/tracks/${id}/stream` + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset the player store to idle. + playQueue([]); +}); + +afterEach(() => { + // sendBeacon spies are restored automatically per test. +}); + +describe('useEventsDispatcher', () => { + test('first transition into playing for a new track POSTs play_started', async () => { + const cleanup = $effect.root(() => useEventsDispatcher()); + + playQueue([track('1')]); + flushSync(); + reportStateFromAudio('playing'); + flushSync(); + // Microtask drain so the api.post promise settles. + await Promise.resolve(); + await Promise.resolve(); + + const calls = (api.post as ReturnType).mock.calls; + const startedCalls = calls.filter((c) => (c[1] as { type: string }).type === 'play_started'); + expect(startedCalls.length).toBe(1); + expect(startedCalls[0][0]).toBe('/api/events'); + expect(startedCalls[0][1]).toMatchObject({ + type: 'play_started', + track_id: '1', + client_id: 'test-client' + }); + + cleanup(); + }); + + test('track end (duration reached, paused) POSTs play_ended', async () => { + const cleanup = $effect.root(() => useEventsDispatcher()); + + playQueue([track('1', 200)]); + flushSync(); + reportDuration(200); + reportStateFromAudio('playing'); + flushSync(); + await Promise.resolve(); + await Promise.resolve(); + // Reach end of track, then natural pause. + reportTimeUpdate(200); + flushSync(); + reportStateFromAudio('ended'); + flushSync(); + await Promise.resolve(); + + const calls = (api.post as ReturnType).mock.calls; + const types = calls.map((c) => (c[1] as { type: string }).type); + expect(types).toContain('play_started'); + expect(types).toContain('play_ended'); + + cleanup(); + }); + + test('user-initiated skip mid-track POSTs play_skipped', async () => { + const cleanup = $effect.root(() => useEventsDispatcher()); + + playQueue([track('1'), track('2')]); + flushSync(); + reportStateFromAudio('playing'); + flushSync(); + await Promise.resolve(); + await Promise.resolve(); + skipNext(); + flushSync(); + await Promise.resolve(); + + const calls = (api.post as ReturnType).mock.calls; + const types = calls.map((c) => (c[1] as { type: string }).type); + expect(types).toContain('play_skipped'); + + cleanup(); + }); + + test('pagehide fires sendBeacon when a play is open', async () => { + // jsdom doesn't ship navigator.sendBeacon — install a stub before spy. + if (typeof navigator.sendBeacon !== 'function') { + Object.defineProperty(navigator, 'sendBeacon', { + value: () => true, + configurable: true + }); + } + const cleanup = $effect.root(() => useEventsDispatcher()); + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + + playQueue([track('1')]); + flushSync(); + reportStateFromAudio('playing'); + flushSync(); + await Promise.resolve(); + await Promise.resolve(); + + window.dispatchEvent(new Event('pagehide')); + + expect(beacon).toHaveBeenCalled(); + expect(beacon.mock.calls[0][0]).toBe('/api/events'); + + cleanup(); + beacon.mockRestore(); + }); +}); diff --git a/web/src/lib/player/events.svelte.ts b/web/src/lib/player/events.svelte.ts new file mode 100644 index 00000000..60d82236 --- /dev/null +++ b/web/src/lib/player/events.svelte.ts @@ -0,0 +1,107 @@ +import { player } from './store.svelte'; +import { api } from '$lib/api/client'; +import { getOrCreateClientId } from './clientId'; +import type { PlayStartedResponse, EventOkResponse } from '$lib/api/types'; + +// useEventsDispatcher installs $effect-based watchers that emit play events +// to /api/events. Call once from +layout.svelte's mount. +// +// The dispatcher is a small state machine over (current track id, state). +// Per spec data-flow: +// - First transition into 'playing' for a new track → POST play_started. +// - Track id changes while a play is open → POST play_skipped on the prior. +// - Natural completion (state moves from 'playing' to 'paused' AND +// position >= duration > 0) → POST play_ended on the open row. +// - pagehide with a live row → navigator.sendBeacon a play_skipped. +export function useEventsDispatcher(): void { + let openPlayEventId: string | null = null; + let openTrackId: string | null = null; + let lastPositionMs = 0; + let prevTrackId: string | null = null; + let prevState: string | null = null; + const clientId = getOrCreateClientId(); + + // Track the latest position for use by close events. Cheap; no API call. + $effect(() => { + lastPositionMs = Math.round(player.position * 1000); + }); + + // Main state machine. Reacts only to (current track id, state) transitions. + $effect(() => { + const t = player.current; + const s = player.state; + const tid = t?.id ?? null; + + // Track changed: close any prior open play as a skip. + if (tid !== prevTrackId && openPlayEventId) { + const id = openPlayEventId; + const pos = lastPositionMs; + openPlayEventId = null; + openTrackId = null; + api.post('/api/events', { + type: 'play_skipped', + play_event_id: id, + position_ms: pos + }).catch(() => {}); + } + + // Entered playing for a new track (no open row yet for this track). + if (tid && s === 'playing' && openTrackId !== tid) { + void startNew(tid); + } + + // Natural end: state went playing -> paused while at/past duration. + if ( + prevState === 'playing' && + s === 'paused' && + openPlayEventId && + player.duration > 0 && + player.position >= player.duration + ) { + const id = openPlayEventId; + const dur = lastPositionMs; + openPlayEventId = null; + openTrackId = null; + api.post('/api/events', { + type: 'play_ended', + play_event_id: id, + duration_played_ms: dur + }).catch(() => {}); + } + + prevTrackId = tid; + prevState = s; + }); + + // Page close: sendBeacon a play_skipped if an open play exists. + if (typeof window !== 'undefined') { + const onPageHide = () => { + if (!openPlayEventId) return; + const payload = JSON.stringify({ + type: 'play_skipped', + play_event_id: openPlayEventId, + position_ms: lastPositionMs + }); + navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' })); + }; + window.addEventListener('pagehide', onPageHide); + } + + async function startNew(trackId: string): Promise { + try { + const res = await api.post('/api/events', { + type: 'play_started', + track_id: trackId, + client_id: clientId + }); + // The user may have moved on by the time the response arrives. Only + // adopt the id if we're still on the same track and still playing. + if (player.current?.id === trackId && player.state === 'playing') { + openPlayEventId = res.play_event_id; + openTrackId = trackId; + } + } catch { + // Best-effort; missed events are OK in v1. + } + } +}