docs(spec): add M2 likes sub-plan design

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.
This commit is contained in:
2026-04-26 15:44:45 -04:00
parent 6ff00ea24d
commit f91257a79e
@@ -0,0 +1,242 @@
# 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<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:**
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=<track_uuid>&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<TrackRef>` (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:**
`<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``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<TrackRef>` 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=<track>` writes to `general_likes`, returns `ok` envelope.
- `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).
- `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 `<div role="button">` 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 `<LikeButton>` 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.