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:
@@ -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.
|
||||
Reference in New Issue
Block a user