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.
17 KiB
M2 Likes Sub-Plan — Design Spec
Status: approved 2026-04-26
Slice: M2 (Events, sessions, general likes), spec §13 step 6 + scope extension. The original spec only covers track-level general_likes; this slice extends to album and artist starring for full Subsonic compatibility.
Fable tasks: #326 (server + Subsonic) and #327 (web UI heart + Liked view); closes M2 along with #328 (milestone gate verification).
Goal
Ship 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.
Non-goals
- Per-context likes, session vector capture on like (
contextual_likes) — that's M3 territory; the table already exists from migration0005_events. - Aggregate
artist_preferences(computed weighted signal) — M3. - Liked-state propagation via WebSocket / SSE. Subsonic-driven changes only show up after the next refetch (manual reload or TanStack's window-focus refetch).
- Bulk operations (like-all-from-album, etc.). Single-entity like/unlike only.
- Sorting controls on
/library/liked. Defaultliked_at DESCis the only order in v1. - Importing likes from Last.fm / ListenBrainz / Spotify. v1 only persists what the user does in this server.
Architecture
Three new tables — one per entity type — keyed on (user_id, entity_id) with liked_at timestamptz. Three native /api/likes/{tracks,albums,artists}/:id endpoint families (POST/DELETE/GET) plus a single /api/likes/ids endpoint that returns flat id sets for client-side heart-rendering. Subsonic /rest/star, /rest/unstar, /rest/getStarred, /rest/getStarred2 handlers route through the same per-table writes.
The web client exposes one LikeButton.svelte component that takes (entityType, entityId) and reads from a single createLikedIdsQuery() TanStack Query whose data shape is { track_ids: string[], album_ids: string[], artist_ids: string[] }. Mutations do optimistic setQueryData updates with rollback on error. Heart placements: TrackRow, AlbumCard, ArtistRow, PlayerBar. New /library/liked page with three sections backed by infinite queries.
No liked flag on entity refs. Server doesn't add a join to every track/album/artist response. Liked state is computed on the client via O(1) Set lookups from the cached likedIdsQuery. Single source of truth; cache is shared across all hearts in the app; mutations invalidate it once and every component re-renders.
Schema (migration 0006_likes.up.sql)
CREATE TABLE general_likes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
CREATE TABLE general_likes_albums (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, album_id)
);
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
CREATE TABLE general_likes_artists (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, artist_id)
);
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
Down migration drops all three tables.
API contracts
Native /api/likes/...
// Idempotent upsert. 204 on success, 404 if entity doesn't exist, 400 on bad UUID.
POST /api/likes/tracks/:track_id → 204
POST /api/likes/albums/:album_id → 204
POST /api/likes/artists/:artist_id → 204
// Idempotent delete. 204 regardless of prior state.
DELETE /api/likes/tracks/:track_id → 204
DELETE /api/likes/albums/:album_id → 204
DELETE /api/likes/artists/:artist_id → 204
// Paginated detail listings, sorted by liked_at DESC.
GET /api/likes/tracks?limit=&offset= → Page<TrackRef>
GET /api/likes/albums?limit=&offset= → Page<AlbumRef>
GET /api/likes/artists?limit=&offset= → Page<ArtistRef>
// Flat id set for heart-button rendering.
GET /api/likes/ids → { track_ids: string[], album_ids: string[], artist_ids: string[] }
All gated by the existing RequireUser middleware. user_id comes from request context; nothing in the URL or body identifies the user. Cross-user isolation is enforced by the queries (WHERE user_id = $1).
Subsonic /rest/...
GET /rest/star?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/unstar?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/getStarred → <starred> with song/album/artist children
GET /rest/getStarred2 → <starred2> (OpenSubsonic JSON variant)
star/unstar accept any combination of id, albumId, artistId (zero or more of each). For each present param, upsert (or delete) the matching row in the matching table. Missing-entity → standard Subsonic <error code="70"> (data not found). Repeated params (e.g. id=A&id=B) — accept the first occurrence only for v1; document as known limitation.
getStarred and getStarred2 return the user's stars under a <starred> (or starred2 JSON) wrapper containing arrays of song, album, artist children, each sorted liked_at DESC. The existing TrackRef/AlbumRef/ArtistRef Subsonic serialization (used by search3, getAlbum, getArtist) is reused here.
Components & files
New server files
| Path | Responsibility |
|---|---|
internal/db/migrations/0006_likes.up.sql + .down.sql |
Three tables + indexes. |
internal/db/queries/likes.sql |
sqlc queries: Insert*Like, Delete*Like, List*Likes (paginated), Count*Likes, List*LikedIds for each of the three tables. |
internal/db/dbq/likes.sql.go |
Generated bindings. |
internal/api/likes.go |
Handlers for all seven native routes. |
internal/api/likes_test.go |
HTTP-level coverage. |
internal/subsonic/star.go |
handleStar, handleUnstar, handleGetStarred, handleGetStarred2. |
internal/subsonic/star_test.go |
Subsonic coverage. |
Modified server files
| Path | Change |
|---|---|
internal/api/api.go |
Register /api/likes/{tracks,albums,artists}/:id (POST + DELETE), /api/likes/{tracks,albums,artists} (GET, paginated), /api/likes/ids (GET). Inside the authed group. |
internal/subsonic/subsonic.go |
Register /star, /unstar, /getStarred, /getStarred2 in the Mount table. |
New web files
| Path | Responsibility |
|---|---|
web/src/lib/api/likes.ts |
TanStack Query helpers: createLikedIdsQuery() and per-entity infinite-query factories. Mutation factories: likeTrack(id), unlikeTrack(id), plus album and artist variants. Each mutation does optimistic update via setQueryData and rolls back on error. |
web/src/lib/api/likes.test.ts |
Cache-update unit tests. |
web/src/lib/components/LikeButton.svelte |
Heart icon. Props entityType: 'track' | 'album' | 'artist', entityId: string. Reads createLikedIdsQuery(); click toggles via the right mutation; event.stopPropagation() so it doesn't bubble to TrackRow's row-click. |
web/src/lib/components/LikeButton.test.ts |
Renders correct fill state; click toggles correctly per entity type; click stops propagation. |
web/src/routes/library/liked/+page.svelte |
Three sections (Artists / Albums / Tracks) backed by infinite queries. |
web/src/routes/library/liked/liked.test.ts |
Page integration. |
Modified web files
| Path | Change |
|---|---|
web/src/lib/api/types.ts |
Add LikedIdsResponse = { track_ids: string[]; album_ids: string[]; artist_ids: string[] }. |
web/src/lib/api/queries.ts |
Add qk.likedIds, qk.likedTracks(q), qk.likedAlbums(q), qk.likedArtists(q) keys. Re-export from likes.ts (or leave them in their own module — match prior convention). |
web/src/lib/components/TrackRow.svelte |
Insert <LikeButton entityType="track" entityId={track.id} /> next to the existing + queue button. Grid template grows to [32px_1fr_auto_auto_auto]. |
web/src/lib/components/AlbumCard.svelte |
Add a second overlay button row beneath the existing + queue overlay (or stack them vertically) with <LikeButton entityType="album" entityId={album.id} />. |
web/src/lib/components/ArtistRow.svelte |
Add <LikeButton entityType="artist" entityId={artist.id} /> to the row's right side, before the chevron. |
web/src/lib/components/PlayerBar.svelte |
Add <LikeButton entityType="track" entityId={current.id} /> next to the title in the left cluster (cover + title + artist link). |
web/src/lib/components/Shell.svelte |
Add a "Liked" entry to navItems pointing at /library/liked. |
Data flow
Like a track from the album page:
- User clicks heart on a
TrackRow. LikeButtonreadscreateLikedIdsQuery()data; seestrack_idsdoesn't include this id; fireslikeTrack(id)mutation.- Mutation does optimistic
setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))so heart fills instantly. ThenPOST /api/likes/tracks/:id. - Server inserts into
general_likeswithON CONFLICT DO NOTHING. Returns 204. - Mutation
onSuccessinvalidatesqk.likedIdsandqk.likedTracks(so/library/likedrefetches if visible).onErrorrolls back the optimistic update from apreviousIdssnapshot captured inonMutate.
Star from a Subsonic client:
- Client POSTs
/rest/star?id=<track_uuid>&u=admin&t=.... internal/subsonic/star.go::handleStarresolves auth, parses any combination ofid/albumId/artistId, upserts each non-empty into the matching table, returnsokenvelope.- Web SPA's
createLikedIdsQuery()doesn't see it until next refetch (TanStack default refetch-on-window-focus or manual invalidation). Acceptable — Subsonic-driven changes flow through eventually.
View liked items:
- User clicks "Liked" →
/library/liked. - Page mounts three
createInfiniteQueryinstances. Each firesGET /api/likes/{type}?limit=50&offset=0. - Server SQL: e.g.
SELECT t.* FROM tracks t JOIN general_likes l ON l.track_id = t.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC LIMIT $2 OFFSET $3. PlusSELECT count(*) FROM general_likes WHERE user_id = $1for thetotal. - Returns
Page<TrackRef>(and analogous for albums/artists). - Page renders three sections; each has its own "Load more" per the existing infinite-query convention. Empty section shows "No liked X yet".
Heart on PlayerBar:
<LikeButton entityType="track" entityId={current.id} /> — same component as TrackRow's heart. The fill state is reactive: when player.current changes, the derived id-membership check re-runs against the cached id set.
Liked-state propagation across the app: every heart subscribes to the same qk.likedIds query. A mutation invalidation triggers all of them to re-render. No prop drilling or pub-sub needed.
States
| Condition | Render |
|---|---|
likedIdsQuery.isPending |
Heart is disabled and shows a neutral state (outline). Action defers; quick to load. |
likedIdsQuery.isError |
Heart shows outline; clicks are no-ops; no error toast (likes are non-critical). |
| Set contains entity id | Filled heart, aria-pressed="true". |
| Set does NOT contain entity id | Outline heart, aria-pressed="false". |
/library/liked page states (per section, reusing patterns from search):
q.isPending && items.length === 0→LibrarySkeletonfor that sectionq.isError→ApiErrorBannertotal === 0→ "No liked X yet" hint- otherwise → list/grid + "Load more" if
hasNextPage
Testing
Server (go test)
- Migration smoke — applying
0006_likesround-trips the three tables; insert+select for each. - Native handlers (
internal/api/likes_test.go):- Like-track happy path (204; row exists; idempotent — 204 on repeat).
- Like with malformed UUID → 400; nonexistent UUID → 404.
- Unlike happy path (204; row gone; idempotent).
- List endpoint returns paginated
Page<TrackRef>sortedliked_at DESC;totalreflects user's full liked set;limit/offsetwork. GET /api/likes/idsreturns three string arrays containing exactly the ids of the user's rows.- Cross-user isolation — Alice likes track X, Bob's
/api/likes/idsdoes not contain X. - One coverage case per entity type (album, artist) on the like+unlike+list+ids paths.
- Subsonic handlers (
internal/subsonic/star_test.go):star?id=<track>writes togeneral_likes, returnsokenvelope.star?albumId=<album>&artistId=<artist>writes to both album and artist tables in one call.star?id=<unknown>returns Subsonic error 70 (data not found).unstarremoves the row, returnsok. Idempotent.getStarred2returns artist/album/song arrays correctly populated, sortedliked_at DESC. Cross-user isolation verified.
Web (vitest)
likes.test.ts— TanStack Query factory + mutations:createLikedIdsQueryshape ({ track_ids, album_ids, artist_ids }).likeTrack(id)optimistic update inserts id intotrack_ids; mocked failure rolls back.unlikeTrack(id)removes id; rollback restores.- Same coverage for album/artist mutations.
LikeButton.test.ts:- Renders empty heart when id is NOT in the matching set; filled heart when it IS.
- Click on empty heart fires the like mutation (mocked).
- Click on filled heart fires the unlike mutation.
- Click stops propagation (doesn't bubble to parent
<div role="button">on TrackRow).
liked.test.ts(/library/likedpage):- Three sections render with mocked infinite queries.
- Empty section shows "No liked X yet" hint (gate on
total === 0per the search-page pattern). - "Load more" calls
fetchNextPageper section.
TrackRow.test.ts/AlbumCard.test.ts/ArtistRow.test.ts/PlayerBar.test.tsupdates: each gets one new test asserting<LikeButton>is rendered with the rightentityType+entityId. The button's behavior is covered inLikeButton.test.ts; we just verify wiring.
End-to-end manual
Final task in the implementation plan:
- Sign in. Click heart on a track → fills instantly. Refresh page → still filled.
psqlshows the row ingeneral_likes. - Click heart again → empties. Row gone.
- Click heart on an album card. Click heart on an artist row. Verify
general_likes_albumsandgeneral_likes_artistsrows. - Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
- Unstar from Subsonic. Web SPA's heart empties on next refetch.
- Visit
/library/liked. Three sections render with the items above. "Load more" works once you have > 50 of any type. - Currently-playing track shows filled heart in
PlayerBarif liked; toggling fromPlayerBarupdates state everywhere the same track is rendered.
Risks & mitigations
- Likes diverge from Subsonic on partial failures. If a Subsonic client sends
star?id=A&albumId=Band the album doesn't exist, the track gets starred but album fails. Mitigation: validate all entity ids first; if any is missing, return error 70 BEFORE writing any. Atomicity matters less than predictability for clients. createLikedIdsQuerypayload grows unbounded for power users. For 10k liked tracks, the array is ~370 KB — acceptable. For 100k+ users it becomes a concern. Mitigation: not a v1 problem; document and revisit if telemetry shows large libraries. A per-entity-type "is liked" check endpoint would be a clean upgrade path.- Optimistic update / refetch race. If a mutation is in-flight when a refetch lands, the rollback might overwrite the actual server state with stale "previous" data. TanStack's mutation flow handles this —
onErroronly fires on actual error, and thesetQueryDatawe do inonMutateis the optimistic value, not stale. - Cross-table star with mixed presence.
star?id=&albumId=where one exists and the other doesn't — option A: 404 the whole call; option B: succeed for the existing one, ignore the missing one. We pick A (atomicity) per the first risk above. Validation pass before any insert. liked_atclock skew across replicas. Single-instance deployment for v1 — defer.