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
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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 gatingaudioEl.play()onstate==='playing', but the state machine progressesloading → DOM play() → 'playing' event → state='playing'. Fixed by triggering play() for theloading ∪ playingunion.Search slice (commits
1e66910→7fff293)Persistent header search box (debounced URL sync),
/searchpage with three sections, three overflow routes backed bycreateInfiniteQuery. Track click triggersplayRadio(seedTrackId)via stubbed/api/radioendpoint (M4 fills it later — response shape final). New+ queueaffordance onTrackRowandAlbumCard.TrackRowrefactored to<div role="button">so a real<button>can nest. Spec atdocs/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_sessionsset that M3's recommendation engine will consume. Spec atdocs/superpowers/specs/2026-04-25-m2-events-design.md.0005_eventsmigration addsplay_sessions,play_events,skip_events. Table is namedplay_sessions(notsessions— that's already taken by HTTP auth from migration 0004). M3-only columns ship nullable.internal/playsessions—FindOrCreatewith 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 onplay_ended(AND of completion < 0.5 and duration < 30s).POST /api/events— discriminated union (play_started/play_ended/play_skipped); 403 on ownership mismatch./rest/scrobble— wired into the same writer.submission=false→play_started;submission=true→ synthetic completed play. The in-memorynowPlayingMapis gone;play_events WHERE ended_at IS NULLis the live "now playing" source.useEventsDispatcher— single-$effectstate machine inweb/src/lib/player/events.svelte.tswatches(player.current?.id, player.state)and posts events.pagehideusesnavigator.sendBeaconso graceful tab-close still completes the row. Mounted once from+layout.sveltealongsideuseMediaSession().getOrCreateClientId— stable per-tab UUID in sessionStorage; sent asclient_idon every event.Test plan
go test -short -race ./...— all packages passgolangci-lint run ./...— cleancd web && npm run check— 0 errors / 0 warningscd web && npm test— 160 tests across 26 filescd web && npm run build— adapter-static emitsweb/build/docker build -t minstrel:m2-events-smoke .+ binary smoke — okplay_eventsrow appears (ended_at IS NULL); finish track → row closed (was_skipped=false); skip mid-track within 30s → previous row markedwas_skipped=true,skip_eventsrow exists; wait > 30 min → next play creates newplay_sessionsrow; 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
playRadioaction. 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.localStorageglobal;web/vitest.setup.tsinstalls an in-memoryStoragepolyfill.internal/api/Mountsignature changed: now takes a shared*playevents.Writerinstead ofEventsConfig, so the same writer instance feeds both/api/*and/rest/scrobble.internal/library/TestScanner_Integration) is flaky in-racemode (pre-existing, unrelated to this PR — happens with the duration-extraction code path from the M1 backfill commit). CI runs-shortwhich skips it; local runs may need a re-run.🤖 Generated with Claude Code