Specifies POST /api/events with start+end semantics, session service (30-min rolling), play/skip classification (spec §6 boundaries), sendBeacon + auto-close-on-next-start for abandoned plays, and Subsonic /rest/scrobble integration that writes synthetic completed plays so third-party clients feed the recommendation pipeline. Schema migration ships the full M2 set (play_events, skip_events, sessions) including M3-only nullable columns so M3 doesn't need a follow-up migration. General likes deferred to a separate M2 sub-plan; this slice covers spec §13 step 5 only.
17 KiB
M2 Events Sub-Plan — Design Spec
Status: approved 2026-04-25 Slice: M2 (Events, sessions, general likes), spec §13 step 5. General likes (step 6) deferred to a follow-up sub-plan. Fable tasks: #322 (schema), #323 (session service), #324 (events API), #325 (web UI wiring) — bundled into a single PR for this slice.
Goal
Ship the play-event ingestion pipeline end-to-end: schema, session service, POST /api/events handler, Subsonic /rest/scrobble integration, and the SvelteKit player wiring that emits the events. Output is a populated play_events / skip_events / sessions set that M3's recommendation engine can read from. No recommendation logic in this slice; this is the data foundation.
Non-goals
- General likes table,
/api/likesendpoint, Subsonicstar/unstarwiring — own M2 sub-plan. contextual_likes,artist_preferences— M3 (algorithm consumers).session_vector_at_playJSONB population — M3 (the algorithm computes it). The column ships in this slice as nullable.- Background janitor for abandoned
ended_at IS NULLrows. Auto-close-on-next-play handles the common case; user-never-comes-back leaves at most one open row per user, which doesn't impact recommendation queries (they filterended_at IS NOT NULL). - Multi-device session merging. Each
play_startedopens a new row regardless of whether one is already open elsewhere; M3 decides what to do when interleaved. - Listen-history UI. The Web UI surfaces nothing about events in this slice — the wiring is invisible to the user.
Architecture
A new database migration adds play_events, skip_events, and sessions per spec §5. A small Go package internal/sessions exposes a FindOrCreate helper implementing the 30-minute rolling-window rule from spec §6. A second package internal/playevents owns the write paths (start, end, skip, synthetic-completed) so both the JSON API handler and the existing Subsonic scrobble handler share one source of truth. A new HTTP handler POST /api/events accepts a discriminated-union JSON payload covering the three client-side event types. The web SPA gains a sibling module to the player store that watches state transitions and dispatches events.
Event lifecycle: start + end (option 1 from brainstorm). Each track produces one play_events row inserted at start (ended_at NULL) and updated at end. The skip-classification rule (spec §6) runs at end-time: a play is a SKIP if completion_ratio < 0.5 AND duration_played_ms < 30000. Both conditions must fail (i.e., either ≥50% OR ≥30s) for it to count as a play.
Subsonic scrobble integration: Third-party Subsonic clients call /rest/scrobble to record plays. Today the handler is a no-op stub. After this slice, submission=false writes a play_started (replacing the in-memory nowPlayingMap); submission=true writes a synthetic completed play (play_started + play_ended in one transaction). This puts mobile/desktop Subsonic clients on equal footing with the web SPA in M3's recommendation pipeline.
Abandoned plays: The web SPA uses navigator.sendBeacon('/api/events', play_skipped) on pagehide so graceful tab-close still completes the row. For ungraceful cases (network drop, browser crash, OS kill), the events handler's play_started path begins by auto-closing any prior ended_at IS NULL row for the same user. The close uses ended_at = now(), duration_played_ms = min(now() - started_at, track.duration_ms) (in ms), and was_skipped = true. The completion ratio is recomputed and the skip rule from spec §6 is not applied (auto-closed rows are flagged as skipped regardless, since we don't know how much the user actually heard). At most one open row per user at any time.
Schema (migration 0005_events.up.sql)
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
last_event_at timestamptz NOT NULL,
track_count integer NOT NULL DEFAULT 0,
client_id text
);
CREATE INDEX sessions_user_last_event_idx ON sessions (user_id, last_event_at DESC);
CREATE TABLE play_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_played_ms integer,
completion_ratio double precision,
was_skipped boolean NOT NULL DEFAULT false,
client_id text,
session_vector_at_play jsonb,
scrobbled_at timestamptz
);
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
CREATE TABLE skip_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
skipped_at timestamptz NOT NULL,
position_ms integer NOT NULL
);
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
session_vector_at_play lands now as nullable so M3 doesn't need a follow-up migration; M2 writes NULL.
API contracts
POST /api/events
type EventRequest =
| { type: 'play_started'; track_id: string; at?: string; client_id?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number; at?: string }
| { type: 'play_skipped'; play_event_id: string; position_ms: number; at?: string };
type EventResponse =
| { play_event_id: string; session_id: string } // for play_started
| { ok: true }; // for play_ended / play_skipped
at defaults to server now() if omitted. client_id defaults to null.
Errors:
400 bad_request— missing/invalidtype; required fields missing or malformed; UUIDs not parseable;duration_played_msorposition_msnegative.404 not_found—track_iddoesn't exist, orplay_event_iddoesn't exist.403 forbidden—play_event_idbelongs to a different user (event UUIDs aren't secrets, but writes must respect ownership).500 server_error— DB issue.
/rest/scrobble (Subsonic — existing endpoint, behavior change only)
Request shape unchanged. Behavior:
submission=false&id=X&time=T→ callsplayevents.RecordPlayStarted(userID, trackID, clientID=c, at=T)then returns the standard Subsonicokenvelope. Replaces the in-memorynowPlayingMap.submission=true&id=X&time=T→ callsplayevents.RecordSyntheticCompletedPlay(userID, trackID, clientID=c, at=T)which writesplay_startedandplay_endedtogether in a single transaction.started_at = T,ended_at = T + track.duration_ms,duration_played_ms = track.duration_ms,was_skipped = false(Subsonic clients don't tell us if a play was skipped — assume completion).- Other
submissionvalues: ignored, ack withok(matches current behavior).
Response stays the standard Subsonic ok envelope; no new fields.
Components & files
New server files
| Path | Responsibility |
|---|---|
internal/db/migrations/0005_events.up.sql + .down.sql |
Schema for play_events, skip_events, sessions. |
internal/db/dbq/events.sql (sqlc input) + generated events.sql.go |
InsertSession, UpdateSessionLastEvent, GetMostRecentSessionForUser, InsertPlayEvent, UpdatePlayEventEnded, InsertSkipEvent, CloseAbandonedPlayEventsForUser, GetOpenPlayEventByUser. |
internal/sessions/service.go |
FindOrCreate(ctx, tx, userID, eventTime, clientID, timeout) (sessionID, error). Composable into a larger transaction. |
internal/sessions/service_test.go |
Live-DB tests covering the four scenarios above. |
internal/playevents/writer.go |
RecordPlayStarted, RecordPlayEnded, RecordPlaySkipped, RecordSyntheticCompletedPlay. Owns the auto-close-prior step, skip classification rule, skip_events writes. |
internal/playevents/writer_test.go |
Direct tests of rule edges (49/50% × 29/30s), auto-close logic, synthetic write. |
internal/api/events.go |
handleEvents HTTP handler — JSON decode, dispatch by type, call into playevents.writer. |
internal/api/events_test.go |
HTTP-level coverage for happy path, validation errors, ownership-mismatch. |
Modified server files
| Path | Change |
|---|---|
internal/api/api.go |
Register authed.Post("/events", h.handleEvents). |
internal/subsonic/stream.go |
handleScrobble calls playevents.writer instead of nowPlayingMap. The nowPlayingMap struct + newNowPlayingMap + the nowPlaying field are deleted. The mediaHandlers constructor signature simplifies. |
internal/subsonic/stream_test.go |
Update existing scrobble test for the new behavior; add a submission=true case. |
internal/config/config.go + config.example.yaml |
New events: section: session_timeout_minutes (default 30), skip_max_completion_ratio (default 0.5), skip_max_duration_played_ms (default 30000). |
New web files
| Path | Responsibility |
|---|---|
web/src/lib/player/events.svelte.ts |
useEventsDispatcher() — $effect-based watcher on player.current / player.state; owns _playEventId state; calls api.post; installs pagehide listener that uses navigator.sendBeacon. |
web/src/lib/player/events.svelte.test.ts |
State-machine tests with mocked api.post and spied navigator.sendBeacon. |
web/src/lib/player/clientId.ts |
getOrCreateClientId(): string — sessionStorage-backed UUID per tab. |
Modified web files
| Path | Change |
|---|---|
web/src/lib/api/types.ts |
Add EventRequest, EventResponse types matching the server contracts. |
web/src/lib/api/client.ts |
Add api.post<T>(path, body) helper if not already present. |
web/src/routes/+layout.svelte |
Call useEventsDispatcher() once at mount, alongside the existing useMediaSession(). |
Data flow
Web SPA path:
- User clicks track → store mutates:
_state = 'loading',_queue = tracks,_index = 0,_position = 0. <audio>loads → firesplaying→ store transitions to_state = 'playing'.- Events module's
$effectsees(player.current, player.state === 'playing')for the first time on this track →POST /api/events { type: 'play_started', track_id, client_id, at }→ server returns{ play_event_id }→ cached in module state. - Track plays. No emissions during playback.
- Track ends naturally → audio fires
ended→ store advances to next track or pauses → events module sees the transition out ofplayingfor THIS track →POST { type: 'play_ended', play_event_id, duration_played_ms, at }→ server updates the row, applies skip classification, optionally writes askip_eventsrow. - Cycle repeats.
Skip mid-track: skipNext() advances queue → audio src changes → events module sees player.current change while _playEventId is still set → POST { type: 'play_skipped', play_event_id, position_ms = _position, at } → then on the new track's playing event, fires play_started for the new track.
Tab close: pagehide listener fires → navigator.sendBeacon('/api/events', { type: 'play_skipped', play_event_id, position_ms = _position, at }).
Crash / network drop: No play_ended ever sent. Row stays ended_at IS NULL. Next time the user starts ANY track on any client, the events handler's auto-close step closes the abandoned row before inserting the new one. Per the formula above: ended_at = now(), duration_played_ms = min(now() - started_at, track.duration_ms), was_skipped = true.
Subsonic path:
scrobble?submission=false→ handler callsplayevents.RecordPlayStarted. Server has live "now playing" viaplay_events WHERE ended_at IS NULL(not used yet but available for M3+).scrobble?submission=true→ handler callsplayevents.RecordSyntheticCompletedPlaywhich writes both rows in one transaction.
Idempotency: play_ended / play_skipped are naturally idempotent (each carries the row id). play_started is not — re-sending creates a new row. The auto-close-prior step protects the table from leaking open rows.
Testing
Server (go test)
- Migration smoke (
internal/db/db_test.goextension): applying0005_eventson a fresh test DB succeeds; round-trip insert+select for each new table. - Session service (
internal/sessions/service_test.go):- No prior session → creates one with
started_at = eventTime,track_count = 0. - Within window → returns existing session id, updates
last_event_at. - Beyond window → creates a new session.
- Concurrent inserts (
t.Parallelwith shared user, transactional fixture) don't double-create. - Uses a
Clockinterface so tests fast-forward withouttime.Sleep.
- No prior session → creates one with
- playevents writer (
internal/playevents/writer_test.go):- Skip rule boundaries:
completion=0.49, duration=29s→ skip;completion=0.5, duration=29s→ not skip;completion=0.49, duration=30s→ not skip. - Auto-close-prior: prior open row gets closed with computed
ended_atandwas_skipped=truewhen a newplay_startedarrives. - Synthetic completed play writes both rows + the
was_skipped=falseflag in a single transaction.
- Skip rule boundaries:
- Events handler (
internal/api/events_test.go):- Happy path for all three event types.
- 400 on missing
type; 400 onplay_endedreferencing a malformed UUID; 400 on negativeduration_played_ms. - 404 on
track_idthat doesn't exist; 404 onplay_event_idthat doesn't exist. - 403 on
play_event_idbelonging to a different user.
- Subsonic scrobble update (
internal/subsonic/stream_test.goextension):- Existing
submission=falsetest now asserts aplay_eventsrow was inserted. - New
submission=truetest asserts bothplay_started + play_endedrows are written and the response is the standard Subsonic envelope. - Test-only code that referenced the deleted
nowPlayingMapis removed.
- Existing
Web (vitest)
events.svelte.test.tswithvi.mock('$lib/api/client', ...)andvi.spyOn(navigator, 'sendBeacon'):- Track 1 starts → exactly one
POSTwithtype: 'play_started'. - Server returns
play_event_id: 'pe1', then track ends → exactly one POST withplay_endedcarryingpe1. - User skips mid-track 2 → POST with
play_skippedcarrying that row's id. - Loading a new track via
playRadio()while track 2 is still playing → POSTsplay_skippedfor track 2 BEFOREplay_startedfor the new track. pagehideevent with an active play →navigator.sendBeaconcalled once withplay_skippedpayload.
- Track 1 starts → exactly one
End-to-end manual
Final task in the implementation plan, against docker compose up --build -d:
- Sign in to web SPA. Play a track.
psql -c "SELECT * FROM play_events"shows one row,ended_at IS NULL, correct user/track/session. - Let track finish. Same row now has
ended_at,was_skipped=false. - Skip mid-track (within 30s of start). New row created on next track; previous row's
was_skipped=trueandskip_eventshas the matching row. - Wait > 30 minutes. Play. New
sessionsrow with different id from the first session. - Open Feishin/Symfonium pointed at the same instance. Play a track to completion.
play_eventsshows a synthetic row. - Close the web tab mid-track.
play_eventslast row was closed via sendBeacon (timestamp ≈ tab close). - Disconnect network mid-play, force-quit browser. Reconnect, sign back in, start a new track. Previous abandoned row gets auto-closed.
Risks & mitigations
- Event spam from buggy clients. A misbehaving client could fire
play_startedin a loop, leaking rows. Mitigation: the auto-close-prior step caps each user at one open row at a time. Worst-case is one closed-row insert perplay_startedcall, which is bounded by client-side sanity. Future M2.5 could add per-user rate limiting. - Clock skew between client and server. The client sends
attimestamps; if the client's clock is wildly off, sessions could be assigned incorrectly (same user might see a "new session" for events that should extend the current one). Mitigation: server uses its ownnow()ifatis omitted, and the events module always omitsat(lets the server timestamp). Theatfield exists for the Subsonic compatibility path (Subsonic clients sendtime=and we honor it). - Subsonic synthetic completed plays are too coarse. A client that scrobbles after a 90% play is treated identically to a 100% play. Mitigation: Subsonic protocol doesn't expose finer info; this is a known limitation. M3's recommendation engine should weight Subsonic-sourced plays slightly lower if it ever matters in practice.
nowPlayingMapdeletion breaks an unknown caller. Verified there are no consumers of the existing in-memory map outsidehandleScrobbleitself; the constructor change is local. Migration is safe.- JSONB
session_vector_at_playships nullable but is referenced in M3 plans. No M3 task starts in this slice; the column is purely forward-compatibility. Documented in the schema comment.