+ Scaffold — UI features land in subsequent plans.
+
+
+
+```
+
+- [ ] **Step 11: Create `web/.gitignore`**
+
+```
+node_modules/
+.svelte-kit/
+build/*
+!build/index.html
+```
+
+- [ ] **Step 12: Create placeholder `web/static/favicon.png`**
+
+Run:
+```bash
+mkdir -p web/static
+python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png
+```
+
+Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes).
+
+- [ ] **Step 13: Create placeholder `web/build/index.html`**
+
+This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed.
+
+```html
+
+
+
+
+ Minstrel
+
+
+
+ Minstrel SPA has not been built. Run
+ cd web && npm install && npm run build or
+ build the container image, which runs the web build during
+ docker build.
+
+
+
+```
+
+- [ ] **Step 14: Install dependencies**
+
+Run: `cd web && npm install`
+Expected: `node_modules/` populated, `package-lock.json` written, exit 0.
+
+- [ ] **Step 15: Verify SvelteKit sync + build**
+
+Run: `cd web && npm run build`
+Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.)
+
+Undo the local overwrite so the committed placeholder remains:
+
+Run: `git checkout -- web/build/index.html`
+Expected: placeholder restored.
+
+- [ ] **Step 16: Commit**
+
+```bash
+git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \
+ web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \
+ web/src/ web/static/ web/build/index.html
+git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static
+
+- SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5
+- adapter-static with fallback:'index.html' for SPA deep-link routing
+- Tailwind configured with dark-only palette matching spec
+- Placeholder landing page at /
+- Committed web/build/index.html placeholder (via .gitignore negation) so
+ go:embed works before the SvelteKit build has been run"
+```
+
+---
+
+## Task 2: Vitest smoke test
+
+**Files:**
+- Create: `web/vitest.config.ts`
+- Create: `web/src/lib/example.test.ts`
+
+- [ ] **Step 1: Create `web/vitest.config.ts`**
+
+```ts
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.test.ts']
+ }
+});
+```
+
+- [ ] **Step 2: Write failing smoke test**
+
+Create `web/src/lib/example.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+
+describe('scaffold smoke', () => {
+ it('runs vitest', () => {
+ expect(1 + 1).toBe(2);
+ });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `cd web && npm test`
+Expected: 1 test passes. Exit 0.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/vitest.config.ts web/src/lib/example.test.ts
+git commit -m "test(web): add Vitest smoke test and jsdom config"
+```
+
+---
+
+## Task 3: Go embed package + SPA fallback handler
+
+**Files:**
+- Create: `web/embed.go`
+- Create: `web/embed_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `web/embed_test.go`:
+
+```go
+package web
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestHandler_ServesIndexAtRoot(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+ body, _ := io.ReadAll(rec.Body)
+ if !strings.Contains(strings.ToLower(string(body)), " build/index.html
+// - GET / -> that file, via http.FileServer
+// - GET / -> build/index.html (SPA fallback)
+//
+// Callers are expected to register this as the router's NotFound/catch-all so
+// explicitly-routed paths (like /api/* and /rest/*) take precedence.
+func Handler() http.Handler {
+ sub, err := fs.Sub(buildFS, "build")
+ if err != nil {
+ // fs.Sub on a valid embed path can only fail if the embed directive
+ // is malformed, which the compile would have caught.
+ panic("web: fs.Sub(build) failed: " + err.Error())
+ }
+
+ index, err := fs.ReadFile(sub, "index.html")
+ if err != nil {
+ panic("web: embedded build/index.html missing: " + err.Error())
+ }
+
+ fileServer := http.FileServer(http.FS(sub))
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ clean := path.Clean(r.URL.Path)
+ if clean == "/" || clean == "." {
+ serveIndex(w, index)
+ return
+ }
+ name := strings.TrimPrefix(clean, "/")
+ if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ serveIndex(w, index)
+ })
+}
+
+func serveIndex(w http.ResponseWriter, index []byte) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(index)
+}
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+Run: `go test ./web/ -v`
+Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/
+git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler"
+```
+
+---
+
+## Task 4: Wire web handler into server router
+
+**Files:**
+- Modify: `internal/server/server.go`
+- Modify: `internal/server/server_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `internal/server/server_test.go` (also add `"strings"` to the imports):
+
+```go
+func TestRouter_ServesSPAAtRoot(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/")
+ if err != nil {
+ t.Fatalf("GET /: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+}
+
+func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
+ if err != nil {
+ t.Fatalf("GET deep link: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+}
+
+func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/api/does-not-exist")
+ if err != nil {
+ t.Fatalf("GET /api/does-not-exist: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
+ }
+}
+
+func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/rest/does-not-exist")
+ if err != nil {
+ t.Fatalf("GET /rest/does-not-exist: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/server/ -v`
+Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes).
+
+- [ ] **Step 3: Wire `web.Handler()` into `Router()`**
+
+Modify `internal/server/server.go`:
+
+a) Add imports (preserving alphabetical order within the group):
+
+```go
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/api"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
+ "git.fabledsword.com/bvandeusen/minstrel/web"
+)
+```
+
+b) Replace the `Router()` method with:
+
+```go
+func (s *Server) Router() http.Handler {
+ r := chi.NewRouter()
+ r.Use(middleware.RequestID)
+ r.Use(middleware.Recoverer)
+
+ r.Get("/healthz", s.handleHealthz)
+
+ if s.Pool != nil {
+ api.Mount(r, s.Pool, s.Logger)
+ r.Route("/api/admin", func(admin chi.Router) {
+ admin.Use(auth.RequireAdmin(s.Pool))
+ if s.Scanner != nil {
+ admin.Post("/scan", s.handleAdminScan)
+ }
+ })
+ subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
+ }
+
+ spa := web.Handler()
+ r.NotFound(func(w http.ResponseWriter, req *http.Request) {
+ p := req.URL.Path
+ if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
+ return
+ }
+ spa.ServeHTTP(w, req)
+ })
+ return r
+}
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+Run: `go test ./internal/server/ -v`
+Expected: all tests PASS, including the existing `TestHealthz` and the four new ones.
+
+- [ ] **Step 5: Run full test suite**
+
+Run: `go test -short -race ./...`
+Expected: PASS.
+
+- [ ] **Step 6: Run linter**
+
+Run: `golangci-lint run ./...`
+Expected: no issues.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/server/
+git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out
+
+- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
+- Everything else falls through to the embedded web handler, which
+ resolves static assets or returns index.html for SPA deep links"
+```
+
+---
+
+## Task 5: Multi-stage Dockerfile
+
+**Files:**
+- Rewrite: `Dockerfile`
+
+- [ ] **Step 1: Replace `Dockerfile` contents**
+
+```dockerfile
+# syntax=docker/dockerfile:1.6
+
+FROM node:22-bookworm-slim AS web
+WORKDIR /web
+COPY web/package.json web/package-lock.json ./
+RUN npm ci
+COPY web/ ./
+RUN npm run build
+
+FROM golang:1.23-bookworm AS builder
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+# Overwrite the committed placeholder with the freshly-built SPA assets.
+COPY --from=web /web/build ./web/build
+ENV CGO_ENABLED=0
+RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
+
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN groupadd --system --gid 1000 minstrel \
+ && useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel
+
+COPY --from=builder /out/minstrel /usr/local/bin/minstrel
+COPY config.example.yaml /etc/smartmusic/config.yaml
+
+USER minstrel
+EXPOSE 4533
+ENTRYPOINT ["/usr/local/bin/minstrel"]
+CMD ["--config", "/etc/smartmusic/config.yaml"]
+```
+
+- [ ] **Step 2: Build image locally**
+
+Run: `docker build -t minstrel:scaffold-smoke .`
+Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`.
+
+- [ ] **Step 3: Inspect image for SPA assets**
+
+Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
+Expected: output `ok` (verifies the binary was copied in and is executable).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Dockerfile
+git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA
+
+Web assets are built in the node stage, copied into the go stage before
+go build so //go:embed picks them up, then the minimal slim runtime
+carries only the final binary and ffmpeg."
+```
+
+---
+
+## Task 6: CI — install Node and build web before Go
+
+**Files:**
+- Modify: `.forgejo/workflows/test.yml`
+
+- [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents**
+
+```yaml
+name: test
+
+# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run
+# locally via docker-compose; they should guard with testing.Short().
+
+on:
+ push:
+ branches: [dev]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: go-ci
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: web/package-lock.json
+
+ - name: Install web deps
+ working-directory: web
+ run: npm ci
+
+ - name: Web build (populates web/build/ for go:embed)
+ working-directory: web
+ run: npm run build
+
+ - name: Web test (vitest)
+ working-directory: web
+ run: npm test
+
+ - name: Detect Go project
+ id: gomod
+ shell: bash
+ run: |
+ if [ -f go.mod ]; then
+ echo "present=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands"
+ fi
+
+ - name: Toolchain versions
+ if: steps.gomod.outputs.present == 'true'
+ run: |
+ go version
+ golangci-lint --version
+
+ - name: go vet
+ if: steps.gomod.outputs.present == 'true'
+ run: go vet ./...
+
+ - name: golangci-lint
+ if: steps.gomod.outputs.present == 'true'
+ run: golangci-lint run ./...
+
+ - name: go test (short, race)
+ if: steps.gomod.outputs.present == 'true'
+ run: go test -short -race ./...
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add .forgejo/workflows/test.yml
+git commit -m "ci: install Node + build web before Go steps
+
+Go's //go:embed needs web/build/ to exist at compile time. CI now
+runs 'npm ci && npm run build && npm test' ahead of the Go steps
+so the embed directive sees real, freshly-built assets."
+```
+
+---
+
+## Task 7: README dev-workflow section
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Read current README**
+
+Run: `cat README.md`
+Note the existing structure (headings, sections).
+
+- [ ] **Step 2: Append or replace a "Development" section**
+
+Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file.
+
+```markdown
+## Development
+
+Minstrel has two concurrent dev processes:
+
+1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`.
+2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR.
+
+The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`,
+so session cookies work correctly when the SPA is loaded from the Vite origin.
+
+### Production build
+
+`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage,
+copies the output into the `golang` stage, and `//go:embed`s it into the
+final binary. The resulting container serves the SPA from `/` alongside the
+API surfaces. No separate static-file server is required.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document two-process dev workflow with Vite proxy"
+```
+
+---
+
+## Task 8: Final verification
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Full Go suite**
+
+Run: `go test -short -race ./...`
+Expected: PASS.
+
+- [ ] **Step 2: Go linter**
+
+Run: `golangci-lint run ./...`
+Expected: no issues.
+
+- [ ] **Step 3: Web tests + build**
+
+Run: `cd web && npm test && npm run build`
+Expected: PASS. Verify `web/build/index.html` was regenerated; then:
+
+Run: `git checkout -- web/build/index.html`
+Expected: committed placeholder restored (the fresh build output stays out of version control).
+
+- [ ] **Step 4: Docker build smoke**
+
+Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'`
+Expected: image builds; size printed (bytes).
+
+- [ ] **Step 5: Local end-to-end**
+
+Bring up the stack:
+```bash
+docker compose up --build -d
+```
+
+Then in another terminal:
+```bash
+curl -sI http://localhost:4533/
+curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001
+curl -sI http://localhost:4533/healthz
+curl -sI http://localhost:4533/api/nonexistent
+```
+
+Expected responses:
+- `GET /` → `200 OK`, `Content-Type: text/html; charset=utf-8`.
+- `GET /artists/…` → `200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback).
+- `GET /healthz` → `200 OK`, `Content-Type: application/json`.
+- `GET /api/nonexistent` → `401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**.
+
+Tear down:
+```bash
+docker compose down
+```
+
+- [ ] **Step 6: Finish the branch**
+
+Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
diff --git a/docs/superpowers/plans/2026-04-26-m2-likes.md b/docs/superpowers/plans/2026-04-26-m2-likes.md
new file mode 100644
index 00000000..acf420b4
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-26-m2-likes.md
@@ -0,0 +1,2495 @@
+# M2 Likes Sub-Plan Implementation
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**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. Closes M2.
+
+**Architecture:** Three new tables (one per entity type) keyed on `(user_id, entity_id)` with `liked_at`. Native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus `/api/likes/ids` for client-side heart caching. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` route through the same per-table writes with validate-all-first atomicity. Web client uses one `LikeButton.svelte` reading a single `createLikedIdsQuery()` cache; mutations do optimistic updates with rollback.
+
+**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TypeScript + TanStack Query (Svelte adapter) + Vitest + Testing Library.
+
+**Reference:** design spec at `docs/superpowers/specs/2026-04-26-m2-likes-design.md`.
+
+---
+
+## File Structure
+
+**New server files:**
+
+| File | Responsibility |
+|---|---|
+| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Schema for `general_likes`, `general_likes_albums`, `general_likes_artists`. |
+| `internal/db/queries/likes.sql` | sqlc queries (like/unlike/list/count/list-ids per table). Generated bindings emitted into `internal/db/dbq/likes.sql.go`. |
+| `internal/api/likes.go` | Native `/api/likes/...` handlers (3 like, 3 unlike, 3 list, 1 ids). |
+| `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:**
+
+| File | Change |
+|---|---|
+| `internal/api/api.go` | Register the seven new `/api/likes/...` routes inside the authed group. |
+| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2`. |
+| `internal/subsonic/types.go` | Add `StarredContainer` / `Starred2Container` types. |
+
+**New web files:**
+
+| File | Responsibility |
+|---|---|
+| `web/src/lib/api/likes.ts` | TanStack Query helpers + mutations. Single source for liked-id state. |
+| `web/src/lib/api/likes.test.ts` | Mutation/cache-update unit tests. |
+| `web/src/lib/components/LikeButton.svelte` | Heart icon button. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. |
+| `web/src/lib/components/LikeButton.test.ts` | Component tests. |
+| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists/Albums/Tracks) with infinite queries. |
+| `web/src/routes/library/liked/liked.test.ts` | Page integration tests. |
+
+**Modified web files:**
+
+| File | Change |
+|---|---|
+| `web/src/lib/api/types.ts` | Add `LikedIdsResponse`. |
+| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks`, `qk.likedAlbums`, `qk.likedArtists`. |
+| `web/src/lib/components/TrackRow.svelte` | Insert `` next to `+ queue`. Grid grows to `[32px_1fr_auto_auto_auto]`. |
+| `web/src/lib/components/AlbumCard.svelte` | Add `` overlay. |
+| `web/src/lib/components/ArtistRow.svelte` | Add `` before the chevron. |
+| `web/src/lib/components/PlayerBar.svelte` | Add `` next to title. |
+| `web/src/lib/components/Shell.svelte` | Add "Liked" entry to `navItems` pointing at `/library/liked`. |
+
+---
+
+## Task 1: Schema migration + sqlc queries
+
+**Files:**
+- Create: `internal/db/migrations/0006_likes.up.sql`
+- Create: `internal/db/migrations/0006_likes.down.sql`
+- Create: `internal/db/queries/likes.sql`
+- Generated: `internal/db/dbq/likes.sql.go` (via `sqlc generate`)
+
+- [ ] **Step 1: Write the up migration**
+
+Create `internal/db/migrations/0006_likes.up.sql`:
+
+```sql
+-- M2 likes: three tables, one per entity type. Schema follows the spec §5
+-- pattern (general_likes was originally track-only); this slice extends it
+-- to albums and artists for full Subsonic star/unstar support.
+
+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);
+```
+
+- [ ] **Step 2: Write the down migration**
+
+Create `internal/db/migrations/0006_likes.down.sql`:
+
+```sql
+DROP TABLE IF EXISTS general_likes_artists;
+DROP TABLE IF EXISTS general_likes_albums;
+DROP TABLE IF EXISTS general_likes;
+```
+
+- [ ] **Step 3: Write the sqlc queries**
+
+Create `internal/db/queries/likes.sql`:
+
+```sql
+-- name: LikeTrack :exec
+INSERT INTO general_likes (user_id, track_id)
+VALUES ($1, $2)
+ON CONFLICT (user_id, track_id) DO NOTHING;
+
+-- name: UnlikeTrack :exec
+DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2;
+
+-- name: ListLikedTrackRows :many
+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;
+
+-- name: CountLikedTracks :one
+SELECT count(*) FROM general_likes WHERE user_id = $1;
+
+-- name: ListLikedTrackIDs :many
+SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
+
+-- name: LikeAlbum :exec
+INSERT INTO general_likes_albums (user_id, album_id)
+VALUES ($1, $2)
+ON CONFLICT (user_id, album_id) DO NOTHING;
+
+-- name: UnlikeAlbum :exec
+DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2;
+
+-- name: ListLikedAlbumRows :many
+SELECT a.* FROM albums a
+JOIN general_likes_albums l ON l.album_id = a.id
+WHERE l.user_id = $1
+ORDER BY l.liked_at DESC
+LIMIT $2 OFFSET $3;
+
+-- name: CountLikedAlbums :one
+SELECT count(*) FROM general_likes_albums WHERE user_id = $1;
+
+-- name: ListLikedAlbumIDs :many
+SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC;
+
+-- name: LikeArtist :exec
+INSERT INTO general_likes_artists (user_id, artist_id)
+VALUES ($1, $2)
+ON CONFLICT (user_id, artist_id) DO NOTHING;
+
+-- name: UnlikeArtist :exec
+DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2;
+
+-- name: ListLikedArtistRows :many
+SELECT a.* FROM artists a
+JOIN general_likes_artists l ON l.artist_id = a.id
+WHERE l.user_id = $1
+ORDER BY l.liked_at DESC
+LIMIT $2 OFFSET $3;
+
+-- name: CountLikedArtists :one
+SELECT count(*) FROM general_likes_artists WHERE user_id = $1;
+
+-- name: ListLikedArtistIDs :many
+SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC;
+```
+
+- [ ] **Step 4: Generate sqlc bindings**
+
+Run: `$(go env GOPATH)/bin/sqlc generate`
+Expected: writes `internal/db/dbq/likes.sql.go` with the new query functions; no other dbq files change beyond the version comment.
+
+- [ ] **Step 5: Verify build**
+
+Run: `go build ./...`
+Expected: clean build.
+
+- [ ] **Step 6: Migration smoke test**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/db -run TestMigrate -v
+```
+Expected: PASS — exercises 0006 up/down via the existing migration test.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/db/migrations/0006_likes.up.sql \
+ internal/db/migrations/0006_likes.down.sql \
+ internal/db/queries/likes.sql \
+ internal/db/dbq/likes.sql.go \
+ internal/db/dbq/models.go
+git commit -m "feat(db): add M2 likes schema (general_likes, _albums, _artists)
+
+Three tables keyed on (user_id, entity_id) with liked_at. Per-table
+indexes on (user_id, liked_at DESC) for the recently-liked feed.
+sqlc queries cover like/unlike/list-rows/count/list-ids per entity."
+```
+
+---
+
+## Task 2: Native `/api/likes/...` handlers
+
+**Files:**
+- Create: `internal/api/likes.go`
+- Create: `internal/api/likes_test.go`
+- Modify: `internal/api/api.go`
+
+Single handler file owning all seven routes (3 like, 3 unlike, 3 list, 1 ids). Tests follow the existing `events_test.go` pattern with `userCtxKeyForTest()` injection.
+
+- [ ] **Step 1: Write failing tests**
+
+Create `internal/api/likes_test.go`:
+
+```go
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+func callLike(h *handlers, user dbq.User, method, path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, nil)
+ req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
+ w := httptest.NewRecorder()
+ likesRouter(h).ServeHTTP(w, req)
+ return w
+}
+
+// likesRouter matches the routes registered in Mount but without RequireUser
+// so tests can inject the user via context directly.
+func likesRouter(h *handlers) http.Handler {
+ r := chi.NewRouter()
+ r.Post("/api/likes/tracks/{id}", h.handleLikeTrack)
+ r.Delete("/api/likes/tracks/{id}", h.handleUnlikeTrack)
+ r.Post("/api/likes/albums/{id}", h.handleLikeAlbum)
+ r.Delete("/api/likes/albums/{id}", h.handleUnlikeAlbum)
+ r.Post("/api/likes/artists/{id}", h.handleLikeArtist)
+ r.Delete("/api/likes/artists/{id}", h.handleUnlikeArtist)
+ r.Get("/api/likes/tracks", h.handleListLikedTracks)
+ r.Get("/api/likes/albums", h.handleListLikedAlbums)
+ r.Get("/api/likes/artists", h.handleListLikedArtists)
+ r.Get("/api/likes/ids", h.handleGetLikedIDs)
+ return r
+}
+
+func TestLikeTrack_Idempotent(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
+ track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
+
+ w := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("first like: status = %d body=%s", w.Code, w.Body.String())
+ }
+ w = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
+ if w.Code != http.StatusNoContent {
+ t.Errorf("second like (idempotent): status = %d", w.Code)
+ }
+ var count int
+ _ = pool.QueryRow(context.Background(),
+ "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
+ if count != 1 {
+ t.Errorf("rows = %d, want 1", count)
+ }
+}
+
+func TestLikeTrack_BadUUIDIs400(t *testing.T) {
+ h, pool := testHandlers(t)
+ user := seedUser(t, pool, "alice", "x", false)
+ w := callLike(h, user, http.MethodPost, "/api/likes/tracks/not-a-uuid")
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d", w.Code)
+ }
+}
+
+func TestLikeTrack_UnknownTrackIs404(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ w := callLike(h, user, http.MethodPost, "/api/likes/tracks/00000000-0000-0000-0000-000000000000")
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d", w.Code)
+ }
+}
+
+func TestUnlikeTrack_IdempotentEvenWhenAbsent(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
+ track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
+
+ w := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(track.ID))
+ if w.Code != http.StatusNoContent {
+ t.Errorf("status = %d", w.Code)
+ }
+}
+
+func TestListLikedTracks_PaginatedAndSortedDescByLikedAt(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ artist := seedArtist(t, pool, "X")
+ album := seedAlbum(t, pool, artist.ID, "X", 1990)
+ t1 := seedTrack(t, pool, album.ID, artist.ID, "First", 1, 100_000)
+ t2 := seedTrack(t, pool, album.ID, artist.ID, "Second", 2, 100_000)
+
+ // Like t1 then t2 — newest should come first.
+ _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t1.ID))
+ _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t2.ID))
+
+ w := callLike(h, user, http.MethodGet, "/api/likes/tracks?limit=10&offset=0")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
+ }
+ var resp struct {
+ Items []TrackRef `json:"items"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ }
+ _ = json.Unmarshal(w.Body.Bytes(), &resp)
+ if resp.Total != 2 || len(resp.Items) != 2 {
+ t.Fatalf("shape: %+v", resp)
+ }
+ if resp.Items[0].Title != "Second" {
+ t.Errorf("first item = %q, want %q (most recent)", resp.Items[0].Title, "Second")
+ }
+}
+
+func TestGetLikedIDs_ReturnsAllThreeArrays(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ artist := seedArtist(t, pool, "X")
+ album := seedAlbum(t, pool, artist.ID, "X", 1990)
+ track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
+
+ _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
+ _ = callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
+ _ = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
+
+ w := callLike(h, user, http.MethodGet, "/api/likes/ids")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var resp struct {
+ TrackIDs []string `json:"track_ids"`
+ AlbumIDs []string `json:"album_ids"`
+ ArtistIDs []string `json:"artist_ids"`
+ }
+ _ = json.Unmarshal(w.Body.Bytes(), &resp)
+ if len(resp.TrackIDs) != 1 || len(resp.AlbumIDs) != 1 || len(resp.ArtistIDs) != 1 {
+ t.Errorf("ids = %+v", resp)
+ }
+}
+
+func TestGetLikedIDs_CrossUserIsolation(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ alice := seedUser(t, pool, "alice", "x", false)
+ bob := seedUser(t, pool, "bob", "x", false)
+ artist := seedArtist(t, pool, "X")
+ album := seedAlbum(t, pool, artist.ID, "X", 1990)
+ track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
+
+ // Alice likes the track.
+ _ = callLike(h, alice, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
+ // Bob queries his ids — should be empty.
+ w := callLike(h, bob, http.MethodGet, "/api/likes/ids")
+ var resp struct {
+ TrackIDs []string `json:"track_ids"`
+ }
+ _ = json.Unmarshal(w.Body.Bytes(), &resp)
+ if len(resp.TrackIDs) != 0 {
+ t.Errorf("bob's track_ids should be empty, got %v", resp.TrackIDs)
+ }
+}
+
+func TestLikeAlbumAndArtist_HappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ user := seedUser(t, pool, "alice", "x", false)
+ artist := seedArtist(t, pool, "X")
+ album := seedAlbum(t, pool, artist.ID, "X", 1990)
+
+ w := callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
+ if w.Code != http.StatusNoContent {
+ t.Errorf("album: status = %d", w.Code)
+ }
+ w = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
+ if w.Code != http.StatusNoContent {
+ t.Errorf("artist: status = %d", w.Code)
+ }
+ w = callLike(h, user, http.MethodGet, "/api/likes/albums?limit=10")
+ var resp struct{ Total int `json:"total"` }
+ _ = json.Unmarshal(w.Body.Bytes(), &resp)
+ if resp.Total != 1 {
+ t.Errorf("album total = %d", resp.Total)
+ }
+}
+```
+
+The test imports `chi`; add that import alongside the existing ones.
+
+- [ ] **Step 2: Run tests, confirm they fail**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/api -run TestLike -v
+```
+Expected: FAIL — handlers undefined.
+
+- [ ] **Step 3: Implement `internal/api/likes.go`**
+
+```go
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+type likedIDsResponse struct {
+ TrackIDs []string `json:"track_ids"`
+ AlbumIDs []string `json:"album_ids"`
+ ArtistIDs []string `json:"artist_ids"`
+}
+
+func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
+ return
+ }
+ q := dbq.New(h.pool)
+ if _, err := q.GetTrackByID(r.Context(), id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "track not found")
+ return
+ }
+ h.logger.Error("api: like track lookup", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
+ h.logger.Error("api: like track insert", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
+ return
+ }
+ if err := dbq.New(h.pool).UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
+ h.logger.Error("api: unlike track", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
+ return
+ }
+ q := dbq.New(h.pool)
+ if _, err := q.GetAlbumByID(r.Context(), id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "album not found")
+ return
+ }
+ h.logger.Error("api: like album lookup", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
+ h.logger.Error("api: like album insert", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
+ return
+ }
+ if err := dbq.New(h.pool).UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
+ h.logger.Error("api: unlike album", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
+ return
+ }
+ q := dbq.New(h.pool)
+ if _, err := q.GetArtistByID(r.Context(), id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "artist not found")
+ return
+ }
+ h.logger.Error("api: like artist lookup", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
+ h.logger.Error("api: like artist insert", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
+ return
+ }
+ if err := dbq.New(h.pool).UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
+ h.logger.Error("api: unlike artist", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *handlers) handleListLikedTracks(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ limit, offset, err := parsePaging(r.URL.Query())
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
+ return
+ }
+ q := dbq.New(h.pool)
+ rows, err := q.ListLikedTrackRows(r.Context(), dbq.ListLikedTrackRowsParams{
+ UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: list liked tracks", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ total, err := q.CountLikedTracks(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: count liked tracks", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
+ return
+ }
+ items, err := h.resolveTrackRefs(r.Context(), q, rows)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, Page[TrackRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
+}
+
+func (h *handlers) handleListLikedAlbums(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ limit, offset, err := parsePaging(r.URL.Query())
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
+ return
+ }
+ q := dbq.New(h.pool)
+ rows, err := q.ListLikedAlbumRows(r.Context(), dbq.ListLikedAlbumRowsParams{
+ UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: list liked albums", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ total, err := q.CountLikedAlbums(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: count liked albums", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
+ return
+ }
+ items, err := h.resolveAlbumRefs(r.Context(), q, rows)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, Page[AlbumRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
+}
+
+func (h *handlers) handleListLikedArtists(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ limit, offset, err := parsePaging(r.URL.Query())
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
+ return
+ }
+ q := dbq.New(h.pool)
+ rows, err := q.ListLikedArtistRows(r.Context(), dbq.ListLikedArtistRowsParams{
+ UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: list liked artists", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ total, err := q.CountLikedArtists(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: count liked artists", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
+ return
+ }
+ items := make([]ArtistRef, 0, len(rows))
+ for _, a := range rows {
+ albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
+ if aerr != nil {
+ h.logger.Error("api: list albums for artist", "err", aerr)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ items = append(items, artistRefFrom(a, len(albums)))
+ }
+ writeJSON(w, http.StatusOK, Page[ArtistRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
+}
+
+func (h *handlers) handleGetLikedIDs(w http.ResponseWriter, r *http.Request) {
+ user, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
+ return
+ }
+ q := dbq.New(h.pool)
+ trackIDs, err := q.ListLikedTrackIDs(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: list liked track ids", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ albumIDs, err := q.ListLikedAlbumIDs(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: list liked album ids", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ artistIDs, err := q.ListLikedArtistIDs(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("api: list liked artist ids", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
+ return
+ }
+ resp := likedIDsResponse{
+ TrackIDs: uuidsToStrings(trackIDs),
+ AlbumIDs: uuidsToStrings(albumIDs),
+ ArtistIDs: uuidsToStrings(artistIDs),
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+func uuidsToStrings(ids []pgtype.UUID) []string {
+ out := make([]string, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, uuidToString(id))
+ }
+ return out
+}
+
+// Compile-time references so the json import is always live even when
+// no handler in this file decodes a body.
+var _ = json.Marshal
+```
+
+- [ ] **Step 4: Register the routes in `internal/api/api.go`**
+
+Inside the `authed.Group(...)` block, after `authed.Post("/events", h.handleEvents)`:
+
+```go
+authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
+authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
+authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
+authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum)
+authed.Post("/likes/artists/{id}", h.handleLikeArtist)
+authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist)
+authed.Get("/likes/tracks", h.handleListLikedTracks)
+authed.Get("/likes/albums", h.handleListLikedAlbums)
+authed.Get("/likes/artists", h.handleListLikedArtists)
+authed.Get("/likes/ids", h.handleGetLikedIDs)
+```
+
+- [ ] **Step 5: Run tests, confirm pass**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/api -run TestLike -v
+```
+Expected: PASS — 7 tests.
+
+- [ ] **Step 6: Lint + full Go suite**
+
+Run: `golangci-lint run ./...`
+Expected: clean.
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test -p 1 ./...
+```
+Expected: all packages PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/api/likes.go internal/api/likes_test.go internal/api/api.go
+git commit -m "feat(api): add /api/likes/{tracks,albums,artists} endpoints
+
+Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
+on repeats). List endpoints return Page
+sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
+client-side heart-button cache."
+```
+
+---
+
+## Task 3: Subsonic `/star` and `/unstar` handlers
+
+**Files:**
+- Create: `internal/subsonic/star.go`
+- Create: `internal/subsonic/star_test.go` (will be extended in Task 4 with getStarred coverage)
+- Modify: `internal/subsonic/subsonic.go` (register routes)
+
+Validate-all-first atomicity: parse all `id`/`albumId`/`artistId` params, look up each entity; if any is missing return error 70 BEFORE any write.
+
+- [ ] **Step 1: Write failing tests**
+
+Create `internal/subsonic/star_test.go`:
+
+```go
+package subsonic
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
+)
+
+func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping in -short mode")
+ }
+ dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("MINSTREL_TEST_DATABASE_URL not set")
+ }
+ if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ pool, err := pgxpool.New(context.Background(), dsn)
+ if err != nil {
+ t.Fatalf("pool: %v", err)
+ }
+ t.Cleanup(pool.Close)
+ if _, err := pool.Exec(context.Background(),
+ "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ q := dbq.New(pool)
+ u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
+ Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
+ })
+ a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
+ al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
+ tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
+ Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000,
+ })
+ return pool, u, tr, al, a
+}
+
+func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
+ return newMediaHandlers(pool, w)
+}
+
+func TestHandleStar_TrackIDInsertsRow(t *testing.T) {
+ pool, user, track, _, _ := testStarPool(t)
+ m := newStarHandlers(pool)
+
+ q := url.Values{}
+ q.Set("id", uuidToID(track.ID))
+ req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, user)
+ w := httptest.NewRecorder()
+ m.handleStar(w, req.WithContext(ctx))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var count int
+ _ = pool.QueryRow(context.Background(),
+ "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
+ if count != 1 {
+ t.Errorf("count = %d", count)
+ }
+}
+
+func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) {
+ pool, user, track, album, artist := testStarPool(t)
+ m := newStarHandlers(pool)
+
+ q := url.Values{}
+ q.Set("id", uuidToID(track.ID))
+ q.Set("albumId", uuidToID(album.ID))
+ q.Set("artistId", uuidToID(artist.ID))
+ req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, user)
+ w := httptest.NewRecorder()
+ m.handleStar(w, req.WithContext(ctx))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var tCount, alCount, arCount int
+ _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount)
+ _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount)
+ _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount)
+ if tCount != 1 || alCount != 1 || arCount != 1 {
+ t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount)
+ }
+}
+
+func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) {
+ pool, user, _, _, _ := testStarPool(t)
+ m := newStarHandlers(pool)
+
+ q := url.Values{}
+ q.Set("id", "00000000-0000-0000-0000-000000000000")
+ req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, user)
+ w := httptest.NewRecorder()
+ m.handleStar(w, req.WithContext(ctx))
+
+ body := w.Body.String()
+ // Subsonic error envelope returns 200 OK with status="failed" inside the body.
+ // The exact code is 70 (data not found).
+ if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) {
+ t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body)
+ }
+}
+
+func TestHandleStar_PartialMissingAtomic(t *testing.T) {
+ pool, user, track, _, _ := testStarPool(t)
+ m := newStarHandlers(pool)
+
+ q := url.Values{}
+ q.Set("id", uuidToID(track.ID))
+ q.Set("albumId", "00000000-0000-0000-0000-000000000000")
+ req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, user)
+ w := httptest.NewRecorder()
+ m.handleStar(w, req.WithContext(ctx))
+
+ // Album missing → atomic refusal: track NOT starred.
+ var count int
+ _ = pool.QueryRow(context.Background(),
+ "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
+ if count != 0 {
+ t.Errorf("track was starred despite album miss; count = %d, want 0", count)
+ }
+}
+
+func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) {
+ pool, user, track, _, _ := testStarPool(t)
+ m := newStarHandlers(pool)
+
+ // First star.
+ q := url.Values{}
+ q.Set("id", uuidToID(track.ID))
+ star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
+ starCtx := context.WithValue(star.Context(), userCtxKey, user)
+ starResp := httptest.NewRecorder()
+ m.handleStar(starResp, star.WithContext(starCtx))
+
+ // Unstar.
+ un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
+ unCtx := context.WithValue(un.Context(), userCtxKey, user)
+ unResp := httptest.NewRecorder()
+ m.handleUnstar(unResp, un.WithContext(unCtx))
+
+ if unResp.Code != http.StatusOK {
+ t.Fatalf("unstar status = %d", unResp.Code)
+ }
+ var count int
+ _ = pool.QueryRow(context.Background(),
+ "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
+ if count != 0 {
+ t.Errorf("count after unstar = %d, want 0", count)
+ }
+
+ // Idempotent — unstar again.
+ un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
+ un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user))
+ un2Resp := httptest.NewRecorder()
+ m.handleUnstar(un2Resp, un2)
+ if un2Resp.Code != http.StatusOK {
+ t.Errorf("second unstar status = %d", un2Resp.Code)
+ }
+}
+
+func contains(s, substr string) bool {
+ for i := 0; i+len(substr) <= len(s); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+```
+
+- [ ] **Step 2: Run tests, confirm fail**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/subsonic -run TestHandleStar -v
+```
+Expected: FAIL — `handleStar`/`handleUnstar` undefined.
+
+- [ ] **Step 3: Implement `internal/subsonic/star.go`**
+
+```go
+package subsonic
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// handleStar implements /rest/star. Accepts any combination of id (track),
+// albumId, artistId. All entities are validated before any insert; a single
+// missing entity fails the whole call with Subsonic error 70.
+func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
+ user, ok := UserFromContext(r.Context())
+ if !ok {
+ WriteFail(w, r, ErrGeneric, "Unauthenticated")
+ return
+ }
+ params := r.URL.Query()
+ q := dbq.New(m.pool)
+ trackID, err := resolveStarID(r.Context(), q, params.Get("id"), entityKindTrack)
+ if err != nil {
+ WriteFail(w, r, ErrDataNotFound, err.Error())
+ return
+ }
+ albumID, err := resolveStarID(r.Context(), q, params.Get("albumId"), entityKindAlbum)
+ if err != nil {
+ WriteFail(w, r, ErrDataNotFound, err.Error())
+ return
+ }
+ artistID, err := resolveStarID(r.Context(), q, params.Get("artistId"), entityKindArtist)
+ if err != nil {
+ WriteFail(w, r, ErrDataNotFound, err.Error())
+ return
+ }
+ if trackID.Valid {
+ if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
+ WriteFail(w, r, ErrGeneric, "Could not star track")
+ return
+ }
+ }
+ if albumID.Valid {
+ if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil {
+ WriteFail(w, r, ErrGeneric, "Could not star album")
+ return
+ }
+ }
+ if artistID.Valid {
+ if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artistID}); err != nil {
+ WriteFail(w, r, ErrGeneric, "Could not star artist")
+ return
+ }
+ }
+ Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
+}
+
+// handleUnstar mirrors handleStar but DELETEs. Missing entities are treated
+// as a no-op rather than an error — Subsonic clients commonly call unstar
+// on entities the server has never seen (cross-server library moves).
+func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
+ user, ok := UserFromContext(r.Context())
+ if !ok {
+ WriteFail(w, r, ErrGeneric, "Unauthenticated")
+ return
+ }
+ params := r.URL.Query()
+ q := dbq.New(m.pool)
+ if t, ok := parseUUID(params.Get("id")); ok {
+ _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
+ }
+ if a, ok := parseUUID(params.Get("albumId")); ok {
+ _ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a})
+ }
+ if a, ok := parseUUID(params.Get("artistId")); ok {
+ _ = q.UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: a})
+ }
+ Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
+}
+
+type entityKind int
+
+const (
+ entityKindTrack entityKind = iota
+ entityKindAlbum
+ entityKindArtist
+)
+
+// resolveStarID returns (uuid, nil) if the param is empty (no-op), or
+// (uuid, nil) if the entity exists, or (zero, error) if the entity is
+// missing/malformed. Non-empty malformed UUID is treated as a 'not found'
+// per Subsonic semantics.
+func resolveStarID(ctx context.Context, q *dbq.Queries, raw string, kind entityKind) (pgtype.UUID, error) {
+ if raw == "" {
+ return pgtype.UUID{}, nil
+ }
+ id, ok := parseUUID(raw)
+ if !ok {
+ return pgtype.UUID{}, errors.New("not found")
+ }
+ switch kind {
+ case entityKindTrack:
+ if _, err := q.GetTrackByID(ctx, id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return pgtype.UUID{}, errors.New("track not found")
+ }
+ return pgtype.UUID{}, err
+ }
+ case entityKindAlbum:
+ if _, err := q.GetAlbumByID(ctx, id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return pgtype.UUID{}, errors.New("album not found")
+ }
+ return pgtype.UUID{}, err
+ }
+ case entityKindArtist:
+ if _, err := q.GetArtistByID(ctx, id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return pgtype.UUID{}, errors.New("artist not found")
+ }
+ return pgtype.UUID{}, err
+ }
+ }
+ return id, nil
+}
+```
+
+- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`**
+
+Inside the `r.Route("/rest", ...)` block, after the existing `/scrobble` registration:
+
+```go
+register(sub, "/star", m.handleStar)
+register(sub, "/unstar", m.handleUnstar)
+```
+
+(Lines for `/getStarred` and `/getStarred2` come in Task 4.)
+
+- [ ] **Step 5: Run tests, confirm pass**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/subsonic -run TestHandleStar -v
+```
+Expected: PASS — 5 tests including `TestHandleUnstar_RemovesAndIsIdempotent`.
+
+- [ ] **Step 6: Lint**
+
+Run: `golangci-lint run ./internal/subsonic/...`
+Expected: clean.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go
+git commit -m "feat(subsonic): add /rest/star and /rest/unstar handlers
+
+Validate-all-first atomicity on star: a missing entity refuses the whole
+call with Subsonic error 70. Unstar is a best-effort delete (missing
+entities are no-ops, matching client expectations after library moves)."
+```
+
+---
+
+## Task 4: Subsonic `/getStarred` and `/getStarred2`
+
+**Files:**
+- Modify: `internal/subsonic/star.go` (add the two handlers)
+- Modify: `internal/subsonic/star_test.go` (extend with getStarred tests)
+- Modify: `internal/subsonic/subsonic.go` (register routes)
+- Modify: `internal/subsonic/types.go` (add `StarredContainer` and response types)
+
+- [ ] **Step 1: Add tests for getStarred2**
+
+Append to `internal/subsonic/star_test.go`:
+
+```go
+import "encoding/json"
+
+func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) {
+ pool, user, track, album, artist := testStarPool(t)
+ m := newStarHandlers(pool)
+ q := dbq.New(pool)
+
+ // Star one of each.
+ _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID})
+ _ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID})
+ _ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID})
+
+ req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, user)
+ w := httptest.NewRecorder()
+ m.handleGetStarred2(w, req.WithContext(ctx))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
+ }
+ // Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}}
+ var raw map[string]any
+ _ = json.Unmarshal(w.Body.Bytes(), &raw)
+ resp, _ := raw["subsonic-response"].(map[string]any)
+ starred, _ := resp["starred2"].(map[string]any)
+ if starred == nil {
+ t.Fatalf("missing starred2 envelope: %v", raw)
+ }
+ songs, _ := starred["song"].([]any)
+ albums, _ := starred["album"].([]any)
+ artists, _ := starred["artist"].([]any)
+ if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 {
+ t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists))
+ }
+}
+
+func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
+ pool, alice, track, _, _ := testStarPool(t)
+ q := dbq.New(pool)
+ bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
+ Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
+ })
+ _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
+
+ m := newStarHandlers(pool)
+ req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
+ ctx := context.WithValue(req.Context(), userCtxKey, bob)
+ w := httptest.NewRecorder()
+ m.handleGetStarred2(w, req.WithContext(ctx))
+
+ var raw map[string]any
+ _ = json.Unmarshal(w.Body.Bytes(), &raw)
+ resp, _ := raw["subsonic-response"].(map[string]any)
+ starred, _ := resp["starred2"].(map[string]any)
+ songs, _ := starred["song"].([]any)
+ if len(songs) != 0 {
+ t.Errorf("bob's starred songs should be empty, got %d", len(songs))
+ }
+}
+```
+
+- [ ] **Step 2: Add the type containers in `internal/subsonic/types.go`**
+
+After `SearchResult3Container` and its response wrapper, add:
+
+```go
+// StarredContainer is the body of getStarred / getStarred2.
+type StarredContainer struct {
+ XMLName xml.Name `json:"-" xml:"starred2"`
+ Artists []ArtistRef `json:"artist" xml:"artist"`
+ Albums []AlbumRef `json:"album" xml:"album"`
+ Songs []SongRef `json:"song" xml:"song"`
+}
+
+type GetStarred2Response struct {
+ Envelope
+ Starred2 StarredContainer `json:"starred2" xml:"starred2"`
+}
+
+// GetStarredContainer mirrors getStarred (id3-less variant). Response uses
+// the same shape; the only material difference from getStarred2 is the
+// outer key name.
+type GetStarredContainer struct {
+ XMLName xml.Name `json:"-" xml:"starred"`
+ Artists []ArtistRef `json:"artist" xml:"artist"`
+ Albums []AlbumRef `json:"album" xml:"album"`
+ Songs []SongRef `json:"song" xml:"song"`
+}
+
+type GetStarredResponse struct {
+ Envelope
+ Starred GetStarredContainer `json:"starred" xml:"starred"`
+}
+```
+
+- [ ] **Step 3: Implement the handlers in `internal/subsonic/star.go`**
+
+Append:
+
+```go
+func (m *mediaHandlers) handleGetStarred(w http.ResponseWriter, r *http.Request) {
+ user, ok := UserFromContext(r.Context())
+ if !ok {
+ WriteFail(w, r, ErrGeneric, "Unauthenticated")
+ return
+ }
+ q := dbq.New(m.pool)
+ songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
+ if err != nil {
+ WriteFail(w, r, ErrGeneric, "Could not load starred")
+ return
+ }
+ Write(w, r, GetStarredResponse{
+ Envelope: NewEnvelope("ok"),
+ Starred: GetStarredContainer{
+ Artists: artists,
+ Albums: albums,
+ Songs: songs,
+ },
+ })
+}
+
+func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request) {
+ user, ok := UserFromContext(r.Context())
+ if !ok {
+ WriteFail(w, r, ErrGeneric, "Unauthenticated")
+ return
+ }
+ q := dbq.New(m.pool)
+ songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
+ if err != nil {
+ WriteFail(w, r, ErrGeneric, "Could not load starred")
+ return
+ }
+ Write(w, r, GetStarred2Response{
+ Envelope: NewEnvelope("ok"),
+ Starred2: StarredContainer{
+ Artists: artists,
+ Albums: albums,
+ Songs: songs,
+ },
+ })
+}
+
+// loadStarred fetches the user's full starred set in liked_at DESC order,
+// projects to Subsonic refs (Song / Album / Artist).
+func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) {
+ const cap = 500 // bounded for safety; M2 doesn't paginate getStarred
+ songs := []SongRef{}
+ albums := []AlbumRef{}
+ artists := []ArtistRef{}
+
+ trackRows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{
+ UserID: userID, Limit: cap, Offset: 0,
+ })
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ for _, t := range trackRows {
+ album, err := q.GetAlbumByID(ctx, t.AlbumID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ artist, err := q.GetArtistByID(ctx, t.ArtistID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ songs = append(songs, songRef(t, album.Title, artist.Name))
+ }
+
+ albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{
+ UserID: userID, Limit: cap, Offset: 0,
+ })
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ for _, a := range albumRows {
+ artist, err := q.GetArtistByID(ctx, a.ArtistID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ count, err := q.CountTracksByAlbum(ctx, a.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ albums = append(albums, albumRef(a, artist.Name, int(count), 0))
+ }
+
+ artistRows, err := q.ListLikedArtistRows(ctx, dbq.ListLikedArtistRowsParams{
+ UserID: userID, Limit: cap, Offset: 0,
+ })
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ for _, a := range artistRows {
+ albums, err := q.ListAlbumsByArtist(ctx, a.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ artists = append(artists, artistRef(a, len(albums)))
+ }
+
+ return songs, albums, artists, nil
+}
+```
+
+- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`**
+
+After the `register(sub, "/star", ...)` and `/unstar` lines, add:
+
+```go
+register(sub, "/getStarred", m.handleGetStarred)
+register(sub, "/getStarred2", m.handleGetStarred2)
+```
+
+- [ ] **Step 5: Run tests, confirm pass**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test ./internal/subsonic -v
+```
+Expected: PASS — including the 2 new getStarred tests + the 5 from Task 3 + existing scrobble + browse coverage.
+
+- [ ] **Step 6: Full Go suite + lint**
+
+Run:
+```bash
+MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
+ go test -p 1 ./...
+```
+Expected: all packages PASS.
+
+Run: `golangci-lint run ./...`
+Expected: clean.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go internal/subsonic/types.go
+git commit -m "feat(subsonic): add getStarred and getStarred2 handlers
+
+Both return user's starred artists/albums/songs sorted liked_at DESC.
+Cap at 500 entries per category for v1 (Subsonic spec doesn't define
+pagination on these endpoints; M3+ can revisit if needed)."
+```
+
+---
+
+## Task 5: Web client — types + likes.ts (TanStack Query helpers)
+
+**Files:**
+- Modify: `web/src/lib/api/types.ts`
+- Modify: `web/src/lib/api/queries.ts`
+- Create: `web/src/lib/api/likes.ts`
+- Create: `web/src/lib/api/likes.test.ts`
+
+- [ ] **Step 1: Add types**
+
+Append to `web/src/lib/api/types.ts`:
+
+```ts
+export type LikedIdsResponse = {
+ track_ids: string[];
+ album_ids: string[];
+ artist_ids: string[];
+};
+```
+
+- [ ] **Step 2: Add qk keys**
+
+Modify `web/src/lib/api/queries.ts`'s `qk` object — add four new entries:
+
+```ts
+export const qk = {
+ artists: (sort: ArtistSort) => ['artists', { sort }] as const,
+ artist: (id: string) => ['artist', id] as const,
+ album: (id: string) => ['album', id] as const,
+ search: (q: string) => ['search', { q }] as const,
+ searchArtists: (q: string) => ['searchArtists', { q }] as const,
+ searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
+ searchTracks: (q: string) => ['searchTracks', { q }] as const,
+ likedIds: () => ['likedIds'] as const,
+ likedTracks: () => ['likedTracks'] as const,
+ likedAlbums: () => ['likedAlbums'] as const,
+ likedArtists: () => ['likedArtists'] as const,
+};
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+Create `web/src/lib/api/likes.test.ts`:
+
+```ts
+import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
+import { QueryClient } from '@tanstack/svelte-query';
+
+vi.mock('./client', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn().mockResolvedValue(null),
+ del: vi.fn().mockResolvedValue(null)
+ }
+}));
+
+import { likeEntity, unlikeEntity } from './likes';
+import { qk } from './queries';
+import { api } from './client';
+
+let client: QueryClient;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ client = new QueryClient();
+ client.setQueryData(qk.likedIds(), {
+ track_ids: ['t1'],
+ album_ids: [],
+ artist_ids: []
+ });
+});
+
+afterEach(() => client.clear());
+
+describe('likes mutations', () => {
+ test('likeEntity track adds id to track_ids in cache (optimistic)', async () => {
+ await likeEntity(client, 'track', 't2');
+ const cached = client.getQueryData(qk.likedIds()) as
+ | { track_ids: string[] } | undefined;
+ expect(cached?.track_ids).toContain('t2');
+ expect(api.post).toHaveBeenCalledWith('/api/likes/tracks/t2');
+ });
+
+ test('likeEntity album hits albums endpoint and adds to album_ids', async () => {
+ await likeEntity(client, 'album', 'al1');
+ const cached = client.getQueryData(qk.likedIds()) as
+ | { album_ids: string[] } | undefined;
+ expect(cached?.album_ids).toContain('al1');
+ expect(api.post).toHaveBeenCalledWith('/api/likes/albums/al1');
+ });
+
+ test('likeEntity artist adds to artist_ids', async () => {
+ await likeEntity(client, 'artist', 'ar1');
+ const cached = client.getQueryData(qk.likedIds()) as
+ | { artist_ids: string[] } | undefined;
+ expect(cached?.artist_ids).toContain('ar1');
+ expect(api.post).toHaveBeenCalledWith('/api/likes/artists/ar1');
+ });
+
+ test('unlikeEntity removes id from cache', async () => {
+ await unlikeEntity(client, 'track', 't1');
+ const cached = client.getQueryData(qk.likedIds()) as
+ | { track_ids: string[] } | undefined;
+ expect(cached?.track_ids).not.toContain('t1');
+ expect(api.del).toHaveBeenCalledWith('/api/likes/tracks/t1');
+ });
+
+ test('likeEntity rolls back on error', async () => {
+ (api.post as ReturnType).mockRejectedValueOnce(new Error('boom'));
+ try {
+ await likeEntity(client, 'track', 't2');
+ } catch { /* expected */ }
+ const cached = client.getQueryData(qk.likedIds()) as
+ | { track_ids: string[] } | undefined;
+ expect(cached?.track_ids).not.toContain('t2');
+ });
+});
+```
+
+- [ ] **Step 4: Run, confirm fail**
+
+Run: `cd web && npm test -- src/lib/api/likes.test.ts`
+Expected: FAIL — `./likes` module not found.
+
+- [ ] **Step 5: Implement `web/src/lib/api/likes.ts`**
+
+```ts
+import type { QueryClient } from '@tanstack/svelte-query';
+import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
+import { api } from './client';
+import { qk, ARTIST_PAGE_SIZE } from './queries';
+import type {
+ ArtistRef,
+ AlbumRef,
+ TrackRef,
+ Page,
+ LikedIdsResponse
+} from './types';
+
+export type EntityKind = 'track' | 'album' | 'artist';
+
+const PATH_BY_KIND: Record = {
+ track: 'tracks',
+ album: 'albums',
+ artist: 'artists'
+};
+
+const SET_KEY_BY_KIND: Record = {
+ track: 'track_ids',
+ album: 'album_ids',
+ artist: 'artist_ids'
+};
+
+export function createLikedIdsQuery() {
+ return createQuery({
+ queryKey: qk.likedIds(),
+ queryFn: () => api.get('/api/likes/ids'),
+ staleTime: 60_000
+ });
+}
+
+export function createLikedTracksInfiniteQuery() {
+ return createInfiniteQuery({
+ queryKey: qk.likedTracks(),
+ queryFn: ({ pageParam = 0 }) =>
+ api.get>(`/api/likes/tracks?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const loaded = last.offset + last.items.length;
+ return loaded >= last.total ? undefined : loaded;
+ }
+ });
+}
+
+export function createLikedAlbumsInfiniteQuery() {
+ return createInfiniteQuery({
+ queryKey: qk.likedAlbums(),
+ queryFn: ({ pageParam = 0 }) =>
+ api.get>(`/api/likes/albums?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const loaded = last.offset + last.items.length;
+ return loaded >= last.total ? undefined : loaded;
+ }
+ });
+}
+
+export function createLikedArtistsInfiniteQuery() {
+ return createInfiniteQuery({
+ queryKey: qk.likedArtists(),
+ queryFn: ({ pageParam = 0 }) =>
+ api.get>(`/api/likes/artists?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const loaded = last.offset + last.items.length;
+ return loaded >= last.total ? undefined : loaded;
+ }
+ });
+}
+
+// likeEntity does an optimistic insert into the likedIds cache, fires the
+// network call, and rolls back on error. Same shape for unlikeEntity.
+export async function likeEntity(
+ client: QueryClient,
+ kind: EntityKind,
+ id: string
+): Promise {
+ const setKey = SET_KEY_BY_KIND[kind];
+ const previous = client.getQueryData(qk.likedIds());
+ if (previous) {
+ client.setQueryData(qk.likedIds(), {
+ ...previous,
+ [setKey]: previous[setKey].includes(id) ? previous[setKey] : [...previous[setKey], id]
+ });
+ }
+ try {
+ await api.post(`/api/likes/${PATH_BY_KIND[kind]}/${id}`, undefined);
+ } catch (err) {
+ if (previous) client.setQueryData(qk.likedIds(), previous);
+ throw err;
+ }
+}
+
+export async function unlikeEntity(
+ client: QueryClient,
+ kind: EntityKind,
+ id: string
+): Promise {
+ const setKey = SET_KEY_BY_KIND[kind];
+ const previous = client.getQueryData(qk.likedIds());
+ if (previous) {
+ client.setQueryData(qk.likedIds(), {
+ ...previous,
+ [setKey]: previous[setKey].filter((x) => x !== id)
+ });
+ }
+ try {
+ await api.del(`/api/likes/${PATH_BY_KIND[kind]}/${id}`);
+ } catch (err) {
+ if (previous) client.setQueryData(qk.likedIds(), previous);
+ throw err;
+ }
+}
+```
+
+- [ ] **Step 6: Run tests, confirm pass**
+
+Run: `cd web && npm test -- src/lib/api/likes.test.ts`
+Expected: PASS — 5 tests.
+
+- [ ] **Step 7: Type-check**
+
+Run: `cd web && npm run check`
+Expected: `0 ERRORS 0 WARNINGS`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/likes.ts web/src/lib/api/likes.test.ts
+git commit -m "feat(web): add likes TanStack Query helpers + mutations
+
+createLikedIdsQuery is the single source for heart-button state across
+the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
+do optimistic setQueryData updates with rollback on error. Per-entity
+infinite queries back the /library/liked sections."
+```
+
+---
+
+## Task 6: `LikeButton.svelte` component
+
+**Files:**
+- Create: `web/src/lib/components/LikeButton.svelte`
+- Create: `web/src/lib/components/LikeButton.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `web/src/lib/components/LikeButton.test.ts`:
+
+```ts
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
+
+const cacheState = vi.hoisted(() => ({
+ data: { track_ids: ['t1'], album_ids: [], artist_ids: [] }
+}));
+
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
+ likeEntity: vi.fn().mockResolvedValue(undefined),
+ unlikeEntity: vi.fn().mockResolvedValue(undefined)
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return {
+ ...actual,
+ useQueryClient: () => ({})
+ };
+});
+
+import LikeButton from './LikeButton.svelte';
+import { likeEntity, unlikeEntity } from '$lib/api/likes';
+
+afterEach(() => {
+ vi.clearAllMocks();
+ cacheState.data = { track_ids: ['t1'], album_ids: [], artist_ids: [] };
+});
+
+describe('LikeButton', () => {
+ test('renders filled heart when track id IS in the set', () => {
+ render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
+ const btn = screen.getByRole('button', { name: /unlike/i });
+ expect(btn.getAttribute('aria-pressed')).toBe('true');
+ });
+
+ test('renders empty heart when track id is NOT in the set', () => {
+ render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
+ const btn = screen.getByRole('button', { name: /like/i });
+ expect(btn.getAttribute('aria-pressed')).toBe('false');
+ });
+
+ test('click on empty heart calls likeEntity', async () => {
+ render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
+ await fireEvent.click(screen.getByRole('button', { name: /like/i }));
+ expect(likeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't2');
+ });
+
+ test('click on filled heart calls unlikeEntity', async () => {
+ render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
+ await fireEvent.click(screen.getByRole('button', { name: /unlike/i }));
+ expect(unlikeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't1');
+ });
+
+ test('click stops propagation', async () => {
+ const parentClick = vi.fn();
+ const { container } = render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
+ const wrapper = container.parentElement!;
+ wrapper.addEventListener('click', parentClick);
+ await fireEvent.click(screen.getByRole('button', { name: /like/i }));
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+
+ test('album entityType reads from album_ids set', () => {
+ cacheState.data = { track_ids: [], album_ids: ['al1'], artist_ids: [] };
+ render(LikeButton, { props: { entityType: 'album', entityId: 'al1' } });
+ expect(screen.getByRole('button', { name: /unlike/i }).getAttribute('aria-pressed')).toBe('true');
+ });
+});
+```
+
+- [ ] **Step 2: Run, confirm fail**
+
+Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts`
+Expected: FAIL — component doesn't exist.
+
+- [ ] **Step 3: Implement `web/src/lib/components/LikeButton.svelte`**
+
+```svelte
+
+
+
+```
+
+- [ ] **Step 4: Run, confirm pass**
+
+Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts`
+Expected: PASS — 6 tests.
+
+- [ ] **Step 5: Type-check**
+
+Run: `cd web && npm run check`
+Expected: `0 ERRORS 0 WARNINGS`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src/lib/components/LikeButton.svelte web/src/lib/components/LikeButton.test.ts
+git commit -m "feat(web): add LikeButton component
+
+Heart icon reading from createLikedIdsQuery; click toggles via
+likeEntity/unlikeEntity. Click stops propagation so it can nest
+inside TrackRow's role=button div without triggering row activation."
+```
+
+---
+
+## Task 7: Wire `LikeButton` into existing components
+
+**Files:**
+- Modify: `web/src/lib/components/TrackRow.svelte` + `TrackRow.test.ts`
+- Modify: `web/src/lib/components/AlbumCard.svelte` + `AlbumCard.test.ts`
+- Modify: `web/src/lib/components/ArtistRow.svelte` + (no test file currently — skip)
+- Modify: `web/src/lib/components/PlayerBar.svelte` + `PlayerBar.test.ts`
+- Modify: `web/src/lib/components/Shell.svelte`
+
+`LikeButton` mounts inside contexts that already mock the query layer; the test files need their existing `vi.mock('$lib/api/likes', ...)` set up so `LikeButton` doesn't crash during test render.
+
+- [ ] **Step 1: Update `TrackRow.svelte`**
+
+Replace the file:
+
+```svelte
+
+
+