feat: player + search + M2 events foundation #19

Merged
bvandeusen merged 262 commits from dev into main 2026-04-26 11:51:53 -04:00
bvandeusen commented 2026-04-24 21:59:37 -04:00 (Migrated from git.fabledsword.com)

Bundles three sequential sub-plans on dev. Each builds on the previous; splitting them post-hoc would require non-trivial rebasing.

Player slice (commits 431f3334264ecf)

Queue-based audio playback with shuffle/repeat, persistent bottom bar, MediaSession glue. Spec at docs/superpowers/specs/2026-04-24-web-ui-player-design.md. Bug fixed mid-PR: layout play-intent effect was gating audioEl.play() on state==='playing', but the state machine progresses loading → DOM play() → 'playing' event → state='playing'. Fixed by triggering play() for the loading ∪ playing union.

Search slice (commits 1e669107fff293)

Persistent header search box (debounced URL sync), /search page with three sections, three overflow routes backed by createInfiniteQuery. Track click triggers playRadio(seedTrackId) via stubbed /api/radio endpoint (M4 fills it later — response shape final). New + queue affordance on TrackRow and AlbumCard. TrackRow refactored to <div role="button"> so a real <button> can nest. Spec at docs/superpowers/specs/2026-04-25-web-ui-search-design.md.

M2 events foundation (commits f3b28db6ff00ea)

Play-event ingestion pipeline end-to-end. Output is a populated play_events / skip_events / play_sessions set that M3's recommendation engine will consume. Spec at docs/superpowers/specs/2026-04-25-m2-events-design.md.

  • Schema: new 0005_events migration adds play_sessions, play_events, skip_events. Table is named play_sessions (not sessions — that's already taken by HTTP auth from migration 0004). M3-only columns ship nullable.
  • internal/playsessionsFindOrCreate with the 30-min rolling-window rule from spec §6.
  • internal/playevents.Writer — owns the four write paths: start, end, skipped, synthetic-completed. Auto-close-prior step caps each user at one open row at a time. Skip rule applied on play_ended (AND of completion < 0.5 and duration < 30s).
  • POST /api/events — discriminated union (play_started / play_ended / play_skipped); 403 on ownership mismatch.
  • Subsonic /rest/scrobble — wired into the same writer. submission=falseplay_started; submission=true → synthetic completed play. The in-memory nowPlayingMap is gone; play_events WHERE ended_at IS NULL is the live "now playing" source.
  • Web useEventsDispatcher — single-$effect state machine in web/src/lib/player/events.svelte.ts watches (player.current?.id, player.state) and posts events. pagehide uses navigator.sendBeacon so graceful tab-close still completes the row. Mounted once from +layout.svelte alongside useMediaSession().
  • getOrCreateClientId — stable per-tab UUID in sessionStorage; sent as client_id on every event.

Test plan

  • go test -short -race ./... — all packages pass
  • golangci-lint run ./... — clean
  • cd web && npm run check — 0 errors / 0 warnings
  • cd web && npm test — 160 tests across 26 files
  • cd web && npm run build — adapter-static emits web/build/
  • docker build -t minstrel:m2-events-smoke . + binary smoke — ok
  • Manual player pass (player slice)
  • Manual search pass (search slice)
  • Manual M2 events pass: play track → play_events row appears (ended_at IS NULL); finish track → row closed (was_skipped=false); skip mid-track within 30s → previous row marked was_skipped=true, skip_events row exists; wait > 30 min → next play creates new play_sessions row; play from Feishin → synthetic completed row written; close tab mid-track → row closed via sendBeacon; force-quit browser → next play auto-closes the abandoned row.

Notes

  • Bundling rationale: the search slice depends on player-store actions added in this branch; the M2 dispatcher depends on the player state machine and search slice's playRadio action. Splitting would require non-trivial rebasing for marginal review benefit. Future cadence: merge each slice's PR before starting the next slice's brainstorming if one-PR-per-slice is desired.
  • Test-infra fix for player slice: Node 25 ships a partial localStorage global; web/vitest.setup.ts installs an in-memory Storage polyfill.
  • internal/api/Mount signature changed: now takes a shared *playevents.Writer instead of EventsConfig, so the same writer instance feeds both /api/* and /rest/scrobble.
  • The library scanner integration test (internal/library/TestScanner_Integration) is flaky in -race mode (pre-existing, unrelated to this PR — happens with the duration-extraction code path from the M1 backfill commit). CI runs -short which skips it; local runs may need a re-run.

🤖 Generated with Claude Code

Bundles three sequential sub-plans on `dev`. Each builds on the previous; splitting them post-hoc would require non-trivial rebasing. ## Player slice (commits `431f333` → `4264ecf`) Queue-based audio playback with shuffle/repeat, persistent bottom bar, MediaSession glue. Spec at `docs/superpowers/specs/2026-04-24-web-ui-player-design.md`. Bug fixed mid-PR: layout play-intent effect was gating `audioEl.play()` on `state==='playing'`, but the state machine progresses `loading → DOM play() → 'playing' event → state='playing'`. Fixed by triggering play() for the `loading ∪ playing` union. ## Search slice (commits `1e66910` → `7fff293`) Persistent header search box (debounced URL sync), `/search` page with three sections, three overflow routes backed by `createInfiniteQuery`. Track click triggers `playRadio(seedTrackId)` via stubbed `/api/radio` endpoint (M4 fills it later — response shape final). New `+ queue` affordance on `TrackRow` and `AlbumCard`. `TrackRow` refactored to `<div role="button">` so a real `<button>` can nest. Spec at `docs/superpowers/specs/2026-04-25-web-ui-search-design.md`. ## M2 events foundation (commits `f3b28db` → `6ff00ea`) Play-event ingestion pipeline end-to-end. Output is a populated `play_events / skip_events / play_sessions` set that M3's recommendation engine will consume. Spec at `docs/superpowers/specs/2026-04-25-m2-events-design.md`. - **Schema:** new `0005_events` migration adds `play_sessions`, `play_events`, `skip_events`. Table is named `play_sessions` (not `sessions` — that's already taken by HTTP auth from migration 0004). M3-only columns ship nullable. - **`internal/playsessions`** — `FindOrCreate` with the 30-min rolling-window rule from spec §6. - **`internal/playevents.Writer`** — owns the four write paths: start, end, skipped, synthetic-completed. Auto-close-prior step caps each user at one open row at a time. Skip rule applied on `play_ended` (AND of completion < 0.5 and duration < 30s). - **`POST /api/events`** — discriminated union (`play_started` / `play_ended` / `play_skipped`); 403 on ownership mismatch. - **Subsonic `/rest/scrobble`** — wired into the same writer. `submission=false` → `play_started`; `submission=true` → synthetic completed play. The in-memory `nowPlayingMap` is gone; `play_events WHERE ended_at IS NULL` is the live "now playing" source. - **Web `useEventsDispatcher`** — single-`$effect` state machine in `web/src/lib/player/events.svelte.ts` watches `(player.current?.id, player.state)` and posts events. `pagehide` uses `navigator.sendBeacon` so graceful tab-close still completes the row. Mounted once from `+layout.svelte` alongside `useMediaSession()`. - **`getOrCreateClientId`** — stable per-tab UUID in sessionStorage; sent as `client_id` on every event. ## Test plan - [x] `go test -short -race ./...` — all packages pass - [x] `golangci-lint run ./...` — clean - [x] `cd web && npm run check` — 0 errors / 0 warnings - [x] `cd web && npm test` — 160 tests across 26 files - [x] `cd web && npm run build` — adapter-static emits `web/build/` - [x] `docker build -t minstrel:m2-events-smoke .` + binary smoke — ok - [x] Manual player pass (player slice) - [ ] Manual search pass (search slice) - [ ] Manual M2 events pass: play track → `play_events` row appears (`ended_at IS NULL`); finish track → row closed (`was_skipped=false`); skip mid-track within 30s → previous row marked `was_skipped=true`, `skip_events` row exists; wait > 30 min → next play creates new `play_sessions` row; play from Feishin → synthetic completed row written; close tab mid-track → row closed via sendBeacon; force-quit browser → next play auto-closes the abandoned row. ## Notes - **Bundling rationale:** the search slice depends on player-store actions added in this branch; the M2 dispatcher depends on the player state machine and search slice's `playRadio` action. Splitting would require non-trivial rebasing for marginal review benefit. Future cadence: merge each slice's PR before starting the next slice's brainstorming if one-PR-per-slice is desired. - Test-infra fix for player slice: Node 25 ships a partial `localStorage` global; `web/vitest.setup.ts` installs an in-memory `Storage` polyfill. - `internal/api/Mount` signature changed: now takes a shared `*playevents.Writer` instead of `EventsConfig`, so the same writer instance feeds both `/api/*` and `/rest/scrobble`. - The library scanner integration test (`internal/library/TestScanner_Integration`) is flaky in `-race` mode (pre-existing, unrelated to this PR — happens with the duration-extraction code path from the M1 backfill commit). CI runs `-short` which skips it; local runs may need a re-run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#19