feat: M2 likes — full track/album/artist starring (closes M2) #20
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?
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