From f3b28db9d18d79e27ba87d94590503e8caf050c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 19:39:16 -0400 Subject: [PATCH] docs(spec): add M2 events sub-plan design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../specs/2026-04-25-m2-events-design.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-25-m2-events-design.md diff --git a/docs/superpowers/specs/2026-04-25-m2-events-design.md b/docs/superpowers/specs/2026-04-25-m2-events-design.md new file mode 100644 index 00000000..796334ac --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-m2-events-design.md @@ -0,0 +1,221 @@ +# 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. `