# 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/likes` endpoint, Subsonic `star/unstar` wiring — own M2 sub-plan. - `contextual_likes`, `artist_preferences` — M3 (algorithm consumers). - `session_vector_at_play` JSONB population — M3 (the algorithm computes it). The column ships in this slice as nullable. - Background janitor for abandoned `ended_at IS NULL` rows. 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 filter `ended_at IS NOT NULL`). - Multi-device session merging. Each `play_started` opens 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`) ```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` ```ts 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/invalid `type`; required fields missing or malformed; UUIDs not parseable; `duration_played_ms` or `position_ms` negative. - `404 not_found` — `track_id` doesn't exist, or `play_event_id` doesn't exist. - `403 forbidden` — `play_event_id` belongs 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` → calls `playevents.RecordPlayStarted(userID, trackID, clientID=c, at=T)` then returns the standard Subsonic `ok` envelope. Replaces the in-memory `nowPlayingMap`. - `submission=true&id=X&time=T` → calls `playevents.RecordSyntheticCompletedPlay(userID, trackID, clientID=c, at=T)` which writes `play_started` and `play_ended` together 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 `submission` values: ignored, ack with `ok` (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(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:** 1. User clicks track → store mutates: `_state = 'loading'`, `_queue = tracks`, `_index = 0`, `_position = 0`. 2. `