Commit Graph

250 Commits

Author SHA1 Message Date
bvandeusen 1f08ee39bd fix(web): add likes/QueryClient mocks to route tests rendering LikeButton
Pages that render TrackRow/AlbumCard/ArtistRow/PlayerBar now indirectly
mount LikeButton, which calls useQueryClient(). Route tests need the
same mocks the component-level tests have.

- Added createLikedIdsQuery mock to 7 route test files
- Added useQueryClient mock to all affected route tests
- Added readable store import where needed
2026-04-26 17:18:39 -04:00
bvandeusen 457b94005f feat(web): add /library/liked page with three sections
Each section is its own infinite query against /api/likes/{type}.
Empty sections show 'No liked X yet' (gated on total === 0). Reuses
ArtistRow / AlbumCard / TrackRow for rendering — same components as
the search overflow pages.
2026-04-26 17:08:06 -04:00
bvandeusen de5320a7b4 feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
Heart appears on every entity rendering surface. ArtistRow restructured
to a <div> with the link as a positioned overlay so the heart button
can nest without invalid <button>-in-<a> markup. Shell nav grows a
'Liked' entry pointing at /library/liked.
2026-04-26 16:56:07 -04:00
bvandeusen 6ab1fef07e feat(web): add LikeButton component
Heart icon reading from createLikedIdsQuery; click toggles via
likeEntity/unlikeEntity. Click stops propagation so it can nest
inside TrackRow's role=button div without triggering row activation.
2026-04-26 16:47:51 -04:00
bvandeusen dd2b9318a0 feat(web): add likes TanStack Query helpers + mutations
createLikedIdsQuery is the single source for heart-button state across
the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
do optimistic setQueryData updates with rollback on error. Per-entity
infinite queries back the /library/liked sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:46:39 -04:00
bvandeusen c7f4adbcc3 feat(subsonic): add getStarred and getStarred2 handlers
Both return user's starred artists/albums/songs sorted liked_at DESC.
Cap at 500 entries per category for v1 (Subsonic spec doesn't define
pagination on these endpoints; M3+ can revisit if needed).
2026-04-26 16:45:04 -04:00
bvandeusen 32fb3fec20 feat(subsonic): add /rest/star and /rest/unstar handlers
Validate-all-first atomicity on star: a missing entity refuses the whole
call with Subsonic error 70. Unstar is a best-effort delete (missing
entities are no-ops, matching client expectations after library moves).
2026-04-26 16:43:20 -04:00
bvandeusen 61d96bb288 feat(api): add /api/likes/{tracks,albums,artists} endpoints
Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
on repeats). List endpoints return Page<TrackRef|AlbumRef|ArtistRef>
sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
client-side heart-button cache.
2026-04-26 16:23:04 -04:00
bvandeusen 780beaa248 feat(db): add M2 likes schema (general_likes, _albums, _artists)
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
indexes on (user_id, liked_at DESC) for the recently-liked feed.
sqlc queries cover like/unlike/list-rows/count/list-ids per entity.
2026-04-26 16:20:08 -04:00
bvandeusen a3c05aeb34 docs(plan): add M2 likes implementation plan
9 tasks: schema + sqlc, native API, Subsonic star/unstar,
Subsonic getStarred/getStarred2, web likes.ts factory + mutations,
LikeButton component, wire LikeButton into existing components,
/library/liked page, final verification.
2026-04-26 15:52:42 -04:00
bvandeusen f91257a79e docs(spec): add M2 likes sub-plan design
Three new tables (general_likes, general_likes_albums,
general_likes_artists), seven native /api/likes/* endpoints, four
Subsonic handlers (star/unstar/getStarred/getStarred2), and web UI
heart buttons on TrackRow/AlbumCard/ArtistRow/PlayerBar plus a new
/library/liked page. Closes M2 (with the events foundation already
in main).

Liked state is computed client-side via O(1) Set lookups against a
single createLikedIdsQuery cache; server stays simple per request
(no joins added to entity reads). Optimistic updates with rollback.
2026-04-26 15:44:45 -04:00
bvandeusen 6ff00ea24d feat(web): mount useEventsDispatcher() in root layout
Single mount point alongside useMediaSession. Once called, the
dispatcher's \$effect watchers run for the lifetime of the SPA.
2026-04-26 09:44:16 -04:00
bvandeusen b6f8a0c390 feat(web): events dispatcher posts to /api/events on player transitions
useEventsDispatcher subscribes to player.current/state via \$effect:
on first transition to playing for a new track, POSTs play_started
and caches the id; on track-id change while a play is open, closes
the prior row as play_skipped; on natural completion (state
playing→paused with position >= duration > 0), POSTs play_ended;
on pagehide, navigator.sendBeacon fires a final play_skipped.
Stable per-tab client_id from sessionStorage.
2026-04-26 09:42:11 -04:00
bvandeusen 599a19f780 feat(subsonic): wire /rest/scrobble into playevents.Writer
submission=false → play_started (replaces in-memory nowPlayingMap).
submission=true (default) → synthetic completed play. The nowPlayingMap
struct + factory + the nowPlaying field on mediaHandlers are deleted;
play_events WHERE ended_at IS NULL is now the source of truth for
'currently playing,' reachable from M3+ work.

api.Mount now takes the shared *playevents.Writer instead of cfg so
the same writer instance feeds both surfaces.
2026-04-26 00:23:20 -04:00
bvandeusen f4e73b81b4 feat(api): add POST /api/events handler
Discriminated-union JSON over three event types: play_started,
play_ended, play_skipped. Auth via existing RequireUser. play_started
returns play_event_id + session_id; the other two return { ok: true }.
Validates ownership: play_ended/play_skipped on another user's row
returns 403. Mount signature now takes EventsConfig and constructs
the playevents.Writer; server.New propagates it through.
2026-04-26 00:12:06 -04:00
bvandeusen 0c7e1b0408 feat(playevents): add Writer with start/end/skipped/synthetic paths
Owns the auto-close-prior step (caps each user at one open row),
spec §6 skip classification rule on play_ended (AND of completion <
0.5 and duration_played < 30s), and skip_events row writes. Synthetic
completed play wires the Subsonic /rest/scrobble?submission=true path
into the same store as native events.
2026-04-26 00:10:02 -04:00
bvandeusen 73d3962fff feat(playsessions): add FindOrCreate session service (30-min rolling window)
Per spec §6: returns existing session id when last_event_at is within
timeout; inserts a new session otherwise. Touch updates last_event_at
and increments track_count so callers can assume FindOrCreate == one
play started.
2026-04-25 22:30:12 -04:00
bvandeusen 722a6784a7 feat(config): add events section (session timeout + skip rule thresholds) 2026-04-25 20:46:23 -04:00
bvandeusen 4fcf2c9616 feat(db): add M2 schema — play_sessions, play_events, skip_events
Tables and indexes per spec §5. session_vector_at_play ships nullable
so M3 doesn't need a follow-up migration. Table is named play_sessions
to avoid collision with the existing sessions (HTTP auth) table from
migration 0004.
2026-04-25 20:45:44 -04:00
bvandeusen 7df48ee9bc docs(plan): add M2 events sub-plan implementation plan
9 tasks: schema migration, events config, playsessions service,
playevents writer, /api/events handler, Subsonic scrobble integration,
web clientId + events dispatcher, layout wiring, final verification.
Tightly TDD-shaped with full code in every step.
2026-04-25 19:56:58 -04:00
bvandeusen f3b28db9d1 docs(spec): add M2 events sub-plan design
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.
2026-04-25 19:39:16 -04:00
bvandeusen 7fff293054 feat(web): add three search overflow pages
/search/artists, /search/albums, /search/tracks each read ?q= and run
their own createSearchInfiniteQuery (limit=50). Load more pulls the
next offset; back link returns to /search?q=. Empty q shows a 'no
query' prompt and fires no request. Tracks overflow uses the onPlay
prop on TrackRow to call playRadio(track.id).
2026-04-25 16:19:09 -04:00
bvandeusen e55520dab6 feat(web): /search page with three sections + see-all overflow links
Reads ?q= reactively; runs createSearchQuery (limit=10) when q is
non-empty. Renders Artists/Albums/Tracks sections reusing ArtistRow,
AlbumCard, TrackRow. Track click uses onPlay prop to call
playRadio(track.id) instead of the default playQueue. Empty facets
hide; 'See all N →' link points to overflow pages when total > items.
Empty q shows 'Start typing'; error → ApiErrorBanner; loading →
SearchSkeleton via useDelayed.
2026-04-25 16:16:55 -04:00
bvandeusen ad9c984b62 feat(web): mount SearchInput in Shell header
Header grows a flex-1 column between the brand and the user menu.
Existing Shell tests remain green (the search input has no impact on
nav/menu behavior).
2026-04-25 16:12:19 -04:00
bvandeusen 3be0dc241c feat(web): add SearchSkeleton placeholder for the search page 2026-04-25 16:12:19 -04:00
bvandeusen 251d165e0b feat(web): add SearchInput header component (debounced URL sync)
Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms
then goto('/search?q=…'); first nav from elsewhere pushes, subsequent
typing on /search replaces. Empty input on /search drops the param.
Escape clears the input. URL → value re-sync via \$effect uses
untrack() on the value comparison so it doesn't loop with typing.
2026-04-25 16:01:25 -04:00
bvandeusen a4a5a20308 feat(web): AlbumCard + queue overlay button
Wraps the existing link in a relative container; adds an absolutely
positioned + button. On click stops propagation, fetches /api/albums/:id
once, and feeds tracks into enqueueTracks.
2026-04-25 15:54:18 -04:00
bvandeusen 80b39f1713 feat(web): TrackRow div+role button with onPlay prop and + queue button
Outer <button> becomes <div role="button" tabindex="0"> with keyboard
handlers (Enter/Space) so a real <button> for + queue can nest without
invalid HTML. New optional onPlay prop overrides the default
playQueue(tracks, index); used by the search Tracks section to call
playRadio(track.id) instead. + queue button calls enqueueTrack and
stops propagation so the row click does not fire.
2026-04-25 15:37:11 -04:00
bvandeusen 7eb051c6f7 feat(web): add search query helpers (summary + 3 facet infinite queries)
createSearchQuery hits /api/search with limit=10 (the main /search
page summary). createSearchArtistsInfiniteQuery /
createSearchAlbumsInfiniteQuery / createSearchTracksInfiniteQuery each
hit /api/search with limit=50 and project to their own facet, used by
the per-facet overflow pages. All four are gated by q.length > 0 so
empty queries don't fire.
2026-04-25 15:30:06 -04:00
bvandeusen 7dd77a4723 feat(web): add enqueueTrack, enqueueTracks, playRadio to player store
enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone.
2026-04-25 15:22:03 -04:00
bvandeusen 8e80b6e0de feat(web): add SearchResponse and RadioResponse types 2026-04-25 15:02:56 -04:00
bvandeusen 1e66910823 feat(api): add /api/radio stub returning the seed track only
M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final.
2026-04-25 15:01:39 -04:00
bvandeusen 8fd193e186 docs(plan): add web UI search implementation plan
12 tasks: server radio stub, web types, player-store enqueue/playRadio,
query helpers, TrackRow div+role refactor, AlbumCard +queue overlay,
SearchInput header component, SearchSkeleton, Shell wiring, /search
main page, three overflow pages, final verification.
2026-04-25 14:59:08 -04:00
bvandeusen 3588af83e5 docs(spec): add web UI search design
Specifies header search bar (debounced, URL-synced ?q=), /search page
with three sections of top-10 reusing library components, three
overflow routes (/search/artists, /search/albums, /search/tracks)
backed by createInfiniteQuery. Track click triggers playRadio with
seed via a new /api/radio stub that returns the seed alone (M4 fills
in real similarity-based selection later, response shape final). Adds
+queue button on TrackRow and AlbumCard via new enqueueTrack /
enqueueTracks player-store actions.
2026-04-25 14:49:01 -04:00
bvandeusen 4264ecf538 fix(web): trigger audio.play() during 'loading' state, not only 'playing'
The layout's play-intent effect gated audioEl.play() on player.isPlaying
(state === 'playing'), but the state progression is loading → DOM play()
→ 'playing' event → state='playing'. Without calling play() during
loading, the DOM never starts, the event never fires, and state is
pinned to loading forever. Call play() for the intent-to-play union
(loading + playing), matching the store's design.
2026-04-24 22:12:34 -04:00
bvandeusen 571eb2ec53 feat(web): mount <audio> at root layout and wire it to the player store
Single hidden <audio> survives navigation. Effects drive it from the
store (src, volume, play/pause) and DOM events report back (timeupdate,
loadedmetadata, playing, pause, waiting, ended, error). useMediaSession
is called once here so OS media controls light up as soon as the user
signs in.
2026-04-24 21:43:03 -04:00
bvandeusen ccef9be2d3 feat(web): add PlayerBar slot to Shell
Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns
in the last row, rendering only when player.current is defined so the
row collapses before first play.
2026-04-24 21:42:10 -04:00
bvandeusen ed082226a0 feat(web): add PlayerBar component
Bottom-bar UI with left: cover+title+linked artist, center: stacked
seek row (elapsed / slider / duration) + transport (prev/play/next),
right: shuffle/repeat/volume. Loading shows spinner; error replaces
transport with retry card.
2026-04-24 21:41:24 -04:00
bvandeusen 4bff8cc99a feat(web): make TrackRow click-to-play; pass tracks+index from album page
TrackRow becomes a <button> that dispatches playQueue(tracks, index).
Album page supplies the full track list + each index so the rest of
the album enqueues automatically when the user clicks a track.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:39:34 -04:00
bvandeusen 4189ef9899 feat(web): add MediaSession glue for OS media controls
useMediaSession() wires navigator.mediaSession metadata + action
handlers (play/pause/previous/next/seekto) + positionState to the
player store via $effect. Exports nothing stateful — callers just
invoke it once at root-layout mount.
2026-04-24 21:03:01 -04:00
bvandeusen 1476d5081b feat(web): add shuffle, repeat, and audio-event reporting to player store
toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'.
2026-04-24 17:56:00 -04:00
bvandeusen 431f333e41 feat(web): add player rune store with basic actions
Core state (queue, index, state, position, duration, volume, shuffle,
repeat, error) plus actions: playQueue, togglePlay, linear skipNext,
skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted),
registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat
logic and audio-event reporting land in follow-up commits.

Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25
exposes a partial localStorage global that lacks getItem/setItem/clear
(requires --localstorage-file with a path), and jsdom defers to the
Node global when present, leaving tests unable to exercise storage.
2026-04-24 17:54:07 -04:00
bvandeusen 7c19fe8a6a docs(plan): add web UI player implementation plan
8 TDD tasks: player rune store (2 tasks covering basic actions then
shuffle/repeat/audio-reporting), MediaSession glue, click-to-play
wiring in TrackRow + album page, PlayerBar component, Shell 3rd-row
integration, audio element wiring in root layout, and a final
verification + branch-finish step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:57:52 -04:00
bvandeusen d6ea734152 docs(spec): add web UI player design
Queue-based playback on top of HTMLAudioElement: rune store + pure
action functions, single <audio> mounted in the root layout driven
via $effect, MediaSession metadata/action handlers, and a persistent
bottom bar with shuffle/repeat/volume. Non-goals: server-side listen
history (awaiting /api/events), cross-refresh persistence, keyboard
shortcuts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:50:58 -04:00
bvandeusen 9b67bb7521 fix(web): restore album-page back link; loosen test matchers
The initial Task 13 pass dropped the back-chevron link above the hero
to satisfy a strict getByRole('link', {name: /Miles Davis/}) matcher
that was finding both the back link and the inline hero-artist link.

Better fix: keep the back affordance users expect, and use matchers
that accept ambiguity — getAllByRole for the hero check, and a
strict '^← …' regex for the back-link assertion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:52:28 -04:00
bvandeusen 9d28c9d9de feat(web): add album detail page at /albums/:id
Hero with cover + title + linked artist + metadata; TrackRow list
below. Back link targets the artist page (not Library) so drilling
up feels correct. 404 renders a distinct non-retryable state.
2026-04-23 20:21:50 -04:00
bvandeusen f9b02c4051 docs(plan): Task 13 — match the type-safety fixes applied in Task 12
page.params.id is string|undefined in SvelteKit types; use ?? ''.
TanStack Query's error is typed as Error; cast through unknown to ApiError.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:13:20 -04:00
bvandeusen 2e49335aab feat(web): add artist detail page at /artists/:id
Renders the artist name + album count and a responsive AlbumCard grid
from the nested ArtistDetail response. 404 surfaces a distinct
non-retryable 'not found' state; other errors share the retry banner.
2026-04-23 20:12:57 -04:00
bvandeusen 25d44a180c docs(plan): update Tasks 12/13 to unwrap TanStack Query's Readable store
TanStack Query 5's Svelte adapter returns a Readable<QueryObserverResult>
store, not a plain object. Pages need to destructure with $-subscription:
  const queryStore = $derived(createXQuery(id));
  const query = $derived($queryStore);

Applied to artist + album detail page templates. Task 11 already
landed with this fix (and readable-wrapped mocks in test-utils).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:01:46 -04:00
bvandeusen 5174b093b9 feat(web): replace / placeholder with real artists list
Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load
more pagination, delayed skeleton, and shared error banner. Also
introduces the test-utils/query.ts helpers for subsequent page tests.
2026-04-23 19:43:37 -04:00