Files
minstrel/docs/superpowers/specs/2026-04-26-m2-likes-design.md
T
bvandeusen f91257a79e 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.
2026-04-26 15:44:45 -04:00

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 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)

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:

  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 === 0LibrarySkeleton for that section
  • q.isErrorApiErrorBanner
  • 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.