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
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.
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)
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.
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
devafter #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/likedpage.Server
0006_likes) — three tables:general_likes(track),general_likes_albums,general_likes_artists. Composite PKs on(user_id, entity_id), FK cascades from bothusersand the entity table, indexed on(user_id, liked_at DESC)./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}— paginatedPage<Ref>, sortedliked_at DESC.GET /api/likes/ids— flat{ track_ids, album_ids, artist_ids }for client-side heart-button caching./rest/star,/rest/unstar,/rest/getStarred,/rest/getStarred2. Star/unstar accept any combination ofid/albumId/artistId; star uses validate-all-first atomicity (a missing entity refuses the whole call); unstar is best-effort. getStarred returns<starred>(and getStarred2 returnsstarred2JSON) with song/album/artist arrays sortedliked_at DESC, capped at 500 per category.Web
createLikedIdsQuery()inweb/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/unlikeEntitymutations with optimisticsetQueryDataupdates and rollback on error.LikeButton.svelte— heart icon, takes(entityType, entityId), click toggles via mutation,event.stopPropagationso it nests insideTrackRow's<div role="button">without triggering row activation.TrackRow,AlbumCard,ArtistRow,PlayerBar. Each uses the sameLikeButtoncomponent./library/likedpage — three sections (Artists / Albums / Tracks) backed by infinite queries; "Load more" per section; "No liked X yet" when empty.Shell.svelteleft rail.ArtistRowrefactor — 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 passgolangci-lint run ./...— cleanMINSTREL_TEST_DATABASE_URL=... go test -p 1 ./...) — all packages includingplayevents,playsessions,subsonic,apipasscd web && npm run check— 0 errors / 0 warningscd web && npm test— 174 tests across 29 filescd web && npm run build— adapter-static emitsweb/build/docker build -t minstrel:m2-likes-smoke .+ binary smoke — ok/library/likedpopulates correctly;PlayerBarheart toggles in sync with TrackRow heart for the same track.Notes
likedflag 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.star?id=X&albumId=YandYdoesn't exist, the whole call fails with Subsonic error 70 (data not found) andXis NOT starred. Predictability over partial success.getStarredcap = 500 — Subsonic spec doesn't define pagination on these endpoints; v1 caps each category. M3+ can revisit if libraries get large.LikeButtoninto existing components) caused 13 route tests to fail because the indirectly-mountedLikeButtoncallsuseQueryClient(). Fixed in commit1f08ee3by adding thevi.mock('@tanstack/svelte-query', …)andvi.mock('$lib/api/likes', …)blocks to 7 route test files.🤖 Generated with Claude Code