# 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 migration `0005_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`. Default `liked_at DESC` is 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`) ```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/...` ```ts // 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 GET /api/likes/albums?limit=&offset= → Page GET /api/likes/artists?limit=&offset= → Page // 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=&albumId=&artistId= → standard `ok` envelope GET /rest/unstar?id=&albumId=&artistId= → standard `ok` envelope GET /rest/getStarred → with song/album/artist children GET /rest/getStarred2 → (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 `` (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 `` (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 `` 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 ``. | | `web/src/lib/components/ArtistRow.svelte` | Add `` to the row's right side, before the chevron. | | `web/src/lib/components/PlayerBar.svelte` | Add `` 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:** 1. User clicks heart on a `TrackRow`. 2. `LikeButton` reads `createLikedIdsQuery()` data; sees `track_ids` doesn't include this id; fires `likeTrack(id)` mutation. 3. Mutation does optimistic `setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))` so heart fills instantly. Then `POST /api/likes/tracks/:id`. 4. Server inserts into `general_likes` with `ON CONFLICT DO NOTHING`. Returns 204. 5. Mutation `onSuccess` invalidates `qk.likedIds` and `qk.likedTracks` (so `/library/liked` refetches if visible). `onError` rolls back the optimistic update from a `previousIds` snapshot captured in `onMutate`. **Star from a Subsonic client:** 1. Client POSTs `/rest/star?id=&u=admin&t=...`. 2. `internal/subsonic/star.go::handleStar` resolves auth, parses any combination of `id`/`albumId`/`artistId`, upserts each non-empty into the matching table, returns `ok` envelope. 3. 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:** 1. User clicks "Liked" → `/library/liked`. 2. Page mounts three `createInfiniteQuery` instances. Each fires `GET /api/likes/{type}?limit=50&offset=0`. 3. 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`. Plus `SELECT count(*) FROM general_likes WHERE user_id = $1` for the `total`. 4. Returns `Page` (and analogous for albums/artists). 5. 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:** `` — 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` → `LibrarySkeleton` for that section - `q.isError` → `ApiErrorBanner` - `total === 0` → "No liked X yet" hint - otherwise → list/grid + "Load more" if `hasNextPage` ## Testing ### Server (`go test`) - **Migration smoke** — applying `0006_likes` round-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` sorted `liked_at DESC`; `total` reflects user's full liked set; `limit`/`offset` work. - `GET /api/likes/ids` returns three string arrays containing exactly the ids of the user's rows. - Cross-user isolation — Alice likes track X, Bob's `/api/likes/ids` does 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=` writes to `general_likes`, returns `ok` envelope. - `star?albumId=&artistId=` writes to both album and artist tables in one call. - `star?id=` returns Subsonic error 70 (data not found). - `unstar` removes the row, returns `ok`. Idempotent. - `getStarred2` returns artist/album/song arrays correctly populated, sorted `liked_at DESC`. Cross-user isolation verified. ### Web (`vitest`) - **`likes.test.ts`** — TanStack Query factory + mutations: - `createLikedIdsQuery` shape (`{ track_ids, album_ids, artist_ids }`). - `likeTrack(id)` optimistic update inserts id into `track_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 `
` on TrackRow). - **`liked.test.ts`** (`/library/liked` page): - Three sections render with mocked infinite queries. - Empty section shows "No liked X yet" hint (gate on `total === 0` per the search-page pattern). - "Load more" calls `fetchNextPage` per section. - **`TrackRow.test.ts` / `AlbumCard.test.ts` / `ArtistRow.test.ts` / `PlayerBar.test.ts`** updates: each gets one new test asserting `` is rendered with the right `entityType` + `entityId`. The button's behavior is covered in `LikeButton.test.ts`; we just verify wiring. ### End-to-end manual Final task in the implementation plan: 1. Sign in. Click heart on a track → fills instantly. Refresh page → still filled. `psql` shows the row in `general_likes`. 2. Click heart again → empties. Row gone. 3. Click heart on an album card. Click heart on an artist row. Verify `general_likes_albums` and `general_likes_artists` rows. 4. Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled. 5. Unstar from Subsonic. Web SPA's heart empties on next refetch. 6. Visit `/library/liked`. Three sections render with the items above. "Load more" works once you have > 50 of any type. 7. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates 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=B` and 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. - **`createLikedIdsQuery` payload 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 — `onError` only fires on actual error, and the `setQueryData` we do in `onMutate` is 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_at` clock skew across replicas.** Single-instance deployment for v1 — defer.