feat: player + search + M2 events foundation #19
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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