feat: M2 likes — full track/album/artist starring (closes M2) #20

Merged
bvandeusen merged 262 commits from dev into main 2026-04-26 23:53:43 -04:00
bvandeusen commented 2026-04-26 20:30:08 -04:00 (Migrated from git.fabledsword.com)

Closes M2. Single-slice PR (per the cadence we agreed on after #19 grew unwieldy): brainstormed → spec → plan → subagent-driven implementation, all on a fresh dev after #19 merged.

What this ships

Liking end-to-end across all three entity types (track / album / artist), wired through both the native /api/* surface and the Subsonic /rest/* compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated /library/liked page.

Server

  • Schema (migration 0006_likes) — three tables: general_likes (track), general_likes_albums, general_likes_artists. Composite PKs on (user_id, entity_id), FK cascades from both users and the entity table, indexed on (user_id, liked_at DESC).
  • Native /api/likes/... (10 routes):
    • POST/DELETE /api/likes/{tracks,albums,artists}/:id — idempotent like/unlike, 204 on success, 404 on missing entity.
    • GET /api/likes/{tracks,albums,artists} — paginated Page<Ref>, sorted liked_at DESC.
    • GET /api/likes/ids — flat { track_ids, album_ids, artist_ids } for client-side heart-button caching.
  • Subsonic/rest/star, /rest/unstar, /rest/getStarred, /rest/getStarred2. Star/unstar accept any combination of id/albumId/artistId; star uses validate-all-first atomicity (a missing entity refuses the whole call); unstar is best-effort. getStarred returns <starred> (and getStarred2 returns starred2 JSON) with song/album/artist arrays sorted liked_at DESC, capped at 500 per category.

Web

  • createLikedIdsQuery() in web/src/lib/api/likes.ts — single TanStack Query that returns { track_ids, album_ids, artist_ids }. Every heart in the app subscribes to the same cache.
  • likeEntity / unlikeEntity mutations with optimistic setQueryData updates and rollback on error.
  • LikeButton.svelte — heart icon, takes (entityType, entityId), click toggles via mutation, event.stopPropagation so it nests inside TrackRow's <div role="button"> without triggering row activation.
  • Heart buttons everywhereTrackRow, AlbumCard, ArtistRow, PlayerBar. Each uses the same LikeButton component.
  • /library/liked page — three sections (Artists / Albums / Tracks) backed by infinite queries; "Load more" per section; "No liked X yet" when empty.
  • Nav — "Liked" entry added to Shell.svelte left rail.
  • ArtistRow refactor — outer element changed from <a> to <div> with the link as a positioned overlay, so the heart's <button> can nest without invalid <button>-in-<a> markup.

Test plan

  • go test -short -race ./... — all packages pass
  • golangci-lint run ./... — clean
  • Full integration suite (MINSTREL_TEST_DATABASE_URL=... go test -p 1 ./...) — all packages including playevents, playsessions, subsonic, api pass
  • cd web && npm run check — 0 errors / 0 warnings
  • cd web && npm test — 174 tests across 29 files
  • cd web && npm run build — adapter-static emits web/build/
  • docker build -t minstrel:m2-likes-smoke . + binary smoke — ok
  • Manual end-to-end: heart on track/album/artist persists; refresh shows it; Subsonic star round-trips to web heart; /library/liked populates correctly; PlayerBar heart toggles in sync with TrackRow heart for the same track.

Notes

  • No liked flag on entity refs — server stays simple per request (no joins added to /api/artists, /api/albums/:id, etc.). Liked state is computed client-side via O(1) Set lookups against the cached id query. Single source of truth; cache shared across all hearts; mutations invalidate it once and every component re-renders.
  • Atomic star semantics — when a Subsonic client calls star?id=X&albumId=Y and Y doesn't exist, the whole call fails with Subsonic error 70 (data not found) and X is NOT starred. Predictability over partial success.
  • getStarred cap = 500 — Subsonic spec doesn't define pagination on these endpoints; v1 caps each category. M3+ can revisit if libraries get large.
  • Mid-implementation regression — Task 7 (wiring LikeButton into existing components) caused 13 route tests to fail because the indirectly-mounted LikeButton calls useQueryClient(). Fixed in commit 1f08ee3 by adding the vi.mock('@tanstack/svelte-query', …) and vi.mock('$lib/api/likes', …) blocks to 7 route test files.

🤖 Generated with Claude Code

Closes M2. Single-slice PR (per the cadence we agreed on after #19 grew unwieldy): brainstormed → spec → plan → subagent-driven implementation, all on a fresh `dev` after #19 merged. ## What this ships Liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page. ### Server - **Schema (migration `0006_likes`)** — three tables: `general_likes` (track), `general_likes_albums`, `general_likes_artists`. Composite PKs on `(user_id, entity_id)`, FK cascades from both `users` and the entity table, indexed on `(user_id, liked_at DESC)`. - **Native `/api/likes/...`** (10 routes): - `POST/DELETE /api/likes/{tracks,albums,artists}/:id` — idempotent like/unlike, 204 on success, 404 on missing entity. - `GET /api/likes/{tracks,albums,artists}` — paginated `Page<Ref>`, sorted `liked_at DESC`. - `GET /api/likes/ids` — flat `{ track_ids, album_ids, artist_ids }` for client-side heart-button caching. - **Subsonic** — `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2`. Star/unstar accept any combination of `id`/`albumId`/`artistId`; star uses validate-all-first atomicity (a missing entity refuses the whole call); unstar is best-effort. getStarred returns `<starred>` (and getStarred2 returns `starred2` JSON) with song/album/artist arrays sorted `liked_at DESC`, capped at 500 per category. ### Web - **`createLikedIdsQuery()`** in `web/src/lib/api/likes.ts` — single TanStack Query that returns `{ track_ids, album_ids, artist_ids }`. Every heart in the app subscribes to the same cache. - **`likeEntity` / `unlikeEntity`** mutations with optimistic `setQueryData` updates and rollback on error. - **`LikeButton.svelte`** — heart icon, takes `(entityType, entityId)`, click toggles via mutation, `event.stopPropagation` so it nests inside `TrackRow`'s `<div role="button">` without triggering row activation. - **Heart buttons everywhere** — `TrackRow`, `AlbumCard`, `ArtistRow`, `PlayerBar`. Each uses the same `LikeButton` component. - **`/library/liked` page** — three sections (Artists / Albums / Tracks) backed by infinite queries; "Load more" per section; "No liked X yet" when empty. - **Nav** — "Liked" entry added to `Shell.svelte` left rail. - **`ArtistRow` refactor** — outer element changed from `<a>` to `<div>` with the link as a positioned overlay, so the heart's `<button>` can nest without invalid `<button>`-in-`<a>` markup. ## Test plan - [x] `go test -short -race ./...` — all packages pass - [x] `golangci-lint run ./...` — clean - [x] Full integration suite (`MINSTREL_TEST_DATABASE_URL=... go test -p 1 ./...`) — all packages including `playevents`, `playsessions`, `subsonic`, `api` pass - [x] `cd web && npm run check` — 0 errors / 0 warnings - [x] `cd web && npm test` — 174 tests across 29 files - [x] `cd web && npm run build` — adapter-static emits `web/build/` - [x] `docker build -t minstrel:m2-likes-smoke .` + binary smoke — ok - [ ] Manual end-to-end: heart on track/album/artist persists; refresh shows it; Subsonic star round-trips to web heart; `/library/liked` populates correctly; `PlayerBar` heart toggles in sync with TrackRow heart for the same track. ## Notes - **No `liked` flag on entity refs** — server stays simple per request (no joins added to `/api/artists`, `/api/albums/:id`, etc.). Liked state is computed client-side via O(1) Set lookups against the cached id query. Single source of truth; cache shared across all hearts; mutations invalidate it once and every component re-renders. - **Atomic star semantics** — when a Subsonic client calls `star?id=X&albumId=Y` and `Y` doesn't exist, the whole call fails with Subsonic error 70 (data not found) and `X` is NOT starred. Predictability over partial success. - **`getStarred` cap = 500** — Subsonic spec doesn't define pagination on these endpoints; v1 caps each category. M3+ can revisit if libraries get large. - **Mid-implementation regression** — Task 7 (wiring `LikeButton` into existing components) caused 13 route tests to fail because the indirectly-mounted `LikeButton` calls `useQueryClient()`. Fixed in commit `1f08ee3` by adding the `vi.mock('@tanstack/svelte-query', …)` and `vi.mock('$lib/api/likes', …)` blocks to 7 route test files. 🤖 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#20