diff --git a/docs/superpowers/specs/2026-04-26-m2-likes-design.md b/docs/superpowers/specs/2026-04-26-m2-likes-design.md new file mode 100644 index 00000000..55fc99c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-m2-likes-design.md @@ -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 +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.