diff --git a/bin/minstrel b/bin/minstrel new file mode 100755 index 00000000..9b34c685 Binary files /dev/null and b/bin/minstrel differ diff --git a/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md b/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md new file mode 100644 index 00000000..5171952d --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md @@ -0,0 +1,954 @@ +# Web UI Scaffold Implementation Plan + +> **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:** Scaffold a SvelteKit SPA at `web/`, embed its build output into the Go binary, and update the Dockerfile + CI so a single container image serves the SPA at `/` alongside the existing `/api/*` and `/rest/*` surfaces. + +**Architecture:** SvelteKit with `@sveltejs/adapter-static` (SPA fallback to `index.html`) builds to `web/build/`. A thin `web` Go package `//go:embed`s that directory and returns an `http.Handler` that serves static assets and falls back to `index.html` for unknown paths. The handler is wired as the server's `NotFound` route so explicit routes (`/api`, `/rest`, `/healthz`) are unaffected; the `NotFound` wrapper also returns a JSON 404 for unmatched `/api/*` and `/rest/*` paths so the SPA fallback never produces HTML bodies for API consumers. A hand-crafted placeholder `web/build/index.html` is committed (via negated gitignore) so `go build` works in clean checkouts before anyone runs `npm run build`. + +**Tech Stack:** SvelteKit 2.x, Svelte 5, TypeScript 5, Vite 5, Tailwind CSS 3, Vitest, Go 1.23, chi/v5, Docker multi-stage builds, Forgejo Actions. + +**Reference spec:** `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`. This plan implements only the Stack / Packaging / Dev-workflow / Build sections. Feature UI (login, library views, player) is out of scope and lands in follow-up plans. + +--- + +## File structure + +| File | Action | Purpose | +|---|---|---| +| `web/package.json` | Create | npm config + scripts | +| `web/tsconfig.json` | Create | TypeScript config extending SvelteKit's generated one | +| `web/svelte.config.js` | Create | `adapter-static` with `fallback: 'index.html'` | +| `web/vite.config.ts` | Create | Vite config + dev proxy for `/api` and `/rest` | +| `web/vitest.config.ts` | Create | Vitest + jsdom config | +| `web/tailwind.config.js` | Create | Tailwind content globs + dark palette tokens | +| `web/postcss.config.cjs` | Create | PostCSS pipeline (tailwindcss + autoprefixer) | +| `web/src/app.html` | Create | HTML template | +| `web/src/app.css` | Create | Tailwind directives + CSS custom properties | +| `web/src/routes/+layout.svelte` | Create | Imports `app.css`, renders outlet | +| `web/src/routes/+page.svelte` | Create | Placeholder landing page | +| `web/src/lib/example.test.ts` | Create | Vitest smoke test | +| `web/static/favicon.png` | Create | 1×1 transparent PNG placeholder | +| `web/build/index.html` | Create | Hand-crafted placeholder committed so `go:embed` works pre-build | +| `web/.gitignore` | Create | Ignore `node_modules/`, `.svelte-kit/`, `build/*` with `!build/index.html` | +| `web/embed.go` | Create | `//go:embed` build tree + `Handler()` with SPA fallback (must live next to `web/build/` — `go:embed` cannot reach parent directories) | +| `web/embed_test.go` | Create | Unit tests for the handler | +| `internal/server/server.go` | Modify | Wire `web.Handler()` into `NotFound`; JSON-404 for `/api/*` and `/rest/*` | +| `internal/server/server_test.go` | Modify | Assert `/` returns HTML; `/api/anything` does not return HTML | +| `Dockerfile` | Rewrite | Three-stage build: `node` → `golang` → `debian:slim` | +| `.forgejo/workflows/test.yml` | Modify | Install Node, build web, run `npm test` before Go steps | +| `README.md` | Modify | Document two-process dev workflow | + +--- + +## Task 1: SvelteKit project skeleton + +**Files:** +- Create: `web/package.json`, `web/tsconfig.json`, `web/svelte.config.js`, `web/vite.config.ts`, `web/tailwind.config.js`, `web/postcss.config.cjs` +- Create: `web/src/app.html`, `web/src/app.css`, `web/src/routes/+layout.svelte`, `web/src/routes/+page.svelte` +- Create: `web/.gitignore`, `web/static/favicon.png`, `web/build/index.html` + +- [ ] **Step 1: Create `web/package.json`** + +```json +{ + "name": "minstrel-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "svelte-kit sync && vitest run" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "svelte": "^5.1.9", + "svelte-check": "^4.0.5", + "tailwindcss": "^3.4.14", + "tslib": "^2.8.0", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.4" + } +} +``` + +- [ ] **Step 2: Create `web/tsconfig.json`** + +```json +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} +``` + +- [ ] **Step 3: Create `web/svelte.config.js`** + +```js +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: false + }), + files: { + assets: 'static' + } + } +}; +``` + +- [ ] **Step 4: Create `web/vite.config.ts`** + +```ts +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:4533', + changeOrigin: true + }, + '/rest': { + target: 'http://localhost:4533', + changeOrigin: true + } + } + } +}); +``` + +- [ ] **Step 5: Create `web/tailwind.config.js`** + +```js +export default { + content: ['./src/**/*.{html,js,ts,svelte}'], + darkMode: 'class', + theme: { + extend: { + colors: { + surface: { + 900: '#14161a', + 800: '#1a1d22', + 700: '#2a2f36' + }, + text: { + primary: '#e8ecf2', + secondary: '#cdd3db' + } + } + } + }, + plugins: [] +}; +``` + +- [ ] **Step 6: Create `web/postcss.config.cjs`** + +```js +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; +``` + +- [ ] **Step 7: Create `web/src/app.html`** + +```html + + + + + + + Minstrel + %sveltekit.head% + + +
%sveltekit.body%
+ + +``` + +- [ ] **Step 8: Create `web/src/app.css`** + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +html, +body { + height: 100%; + margin: 0; +} +``` + +- [ ] **Step 9: Create `web/src/routes/+layout.svelte`** + +```svelte + + + +``` + +- [ ] **Step 10: Create `web/src/routes/+page.svelte`** + +```svelte +
+
+

Minstrel

+

+ 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 + + +
+ + {track.track_number ?? '—'} + + {track.title} + + + {formatDuration(track.duration_sec)} +
+``` + +- [ ] **Step 2: Update `TrackRow.test.ts`** + +Add to the top of the file (after the existing `vi.mock('$lib/player/store.svelte', ...)` block): + +```ts +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); +``` + +Add `import { readable } from 'svelte/store';` at the top. + +- [ ] **Step 3: Update `AlbumCard.svelte`** + +Replace: + +```svelte + + + +``` + +Update `AlbumCard.test.ts` with the same likes/query-client mocks as TrackRow.test.ts. + +- [ ] **Step 4: Update `ArtistRow.svelte`** + +Replace: + +```svelte + + +
+ + + {artist.name} + {countLabel} + + + + +
+``` + +Note the structural change: previously ArtistRow was a single ``. Now it's a `
` with the link as a positioned overlay so the LikeButton can sit above it without nesting ` + {/if} + {/if} + + +
+

Albums

+ {#if albumsTotal === 0} +

No liked albums yet.

+ {:else} +
+ {#each albums as al (al.id)} + + {/each} +
+ {#if albumsQuery?.hasNextPage} + + {/if} + {/if} +
+ +
+

Tracks

+ {#if tracksTotal === 0} +

No liked tracks yet.

+ {:else} +
+ {#each tracks as t, i (t.id)} + + {/each} +
+ {#if tracksQuery?.hasNextPage} + + {/if} + {/if} +
+
+``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `cd web && npm test -- 'src/routes/library/liked/liked.test.ts'` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/routes/library/liked/+page.svelte web/src/routes/library/liked/liked.test.ts +git commit -m "feat(web): add /library/liked page with three sections + +Each section is its own infinite query against /api/likes/{type}. +Empty sections show 'No liked X yet' (gated on total === 0). Reuses +ArtistRow / AlbumCard / TrackRow for rendering — same components as +the search overflow pages." +``` + +--- + +## Task 9: Final verification + branch finish + +**Files:** none (verification only). + +- [ ] **Step 1: Full Go suite (with DB)** + +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 2: Go lint** + +Run: `golangci-lint run ./...` +Expected: clean. + +- [ ] **Step 3: Web check + tests + build** + +Run: `cd web && npm run check && npm test && npm run build` +Expected: check 0/0; all vitest files pass; build emits `web/build/`. + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 4: Docker build smoke** + +Run: `docker build -t minstrel:m2-likes-smoke .` +Expected: image builds. + +Run: `docker run --rm --entrypoint /bin/sh minstrel:m2-likes-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` +Expected: `ok`. + +- [ ] **Step 5: Local end-to-end manual check** + +```bash +docker compose up --build -d +``` + +Browser at `localhost:4533`: + +1. Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled. +2. Verify in psql: + ```bash + docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \ + -c "SELECT user_id, track_id FROM general_likes;" + ``` +3. Click heart on an album card → row in `general_likes_albums`. +4. Click heart on an artist row → row in `general_likes_artists`. +5. Click filled heart again → row gone in each case. +6. Open Feishin/Symfonium pointed at the same instance. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled. +7. Unstar from Subsonic. Web SPA's heart empties on next refetch. +8. Visit `/library/liked` via the new "Liked" sidebar link. Three sections render with the items above. +9. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered. + +Tear down: +```bash +docker compose down +``` + +- [ ] **Step 6: Finish the branch** + +Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. + +--- + +## Self-Review Notes + +**Spec coverage:** +- Schema (3 tables) → Task 1. +- sqlc queries → Task 1. +- Native API endpoints (7) → Task 2. +- Subsonic star/unstar with validate-all-first atomicity → Task 3. +- Subsonic getStarred/getStarred2 with cap=500 → Task 4. +- Web types + qk keys + likes.ts factory + mutations + rollback → Task 5. +- LikeButton component with stop-propagation → Task 6. +- Wire heart into TrackRow / AlbumCard / ArtistRow / PlayerBar → Task 7. +- "Liked" nav entry → Task 7. +- /library/liked page → Task 8. +- End-to-end manual + branch finish → Task 9. + +**Type consistency:** +- `EntityKind = 'track' | 'album' | 'artist'` — same in TS factory and component prop. +- `LikedIdsResponse = { track_ids, album_ids, artist_ids }` — server JSON keys match TS keys (snake_case). +- `qk.likedIds()`, `qk.likedTracks()` etc. — same names everywhere they're consumed. +- `likeEntity(client, kind, id)` / `unlikeEntity(client, kind, id)` — same signature in factory + component + tests. +- pgtype.UUID throughout server-side; string at HTTP boundaries via `uuidToString`. + +**Filename hazards:** all new test files use plain `.test.ts` (no rune syntax in the test bodies — just imports of components that use runes). Route test files use non-`+`-prefixed names (`liked.test.ts`). + +**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command shows expected output. + +**ArtistRow refactor risk:** changing `` to `
` with positioned overlay link is a surface change. The existing artist tests (in route pages — `src/routes/+page.svelte` etc.) use `screen.getByRole('link')` and `getByText` which both still work. Tests re-run as part of Task 7 Step 7. diff --git a/docs/superpowers/specs/2026-04-26-m2-likes-design.md b/docs/superpowers/specs/2026-04-26-m2-likes-design.md new file mode 100644 index 00000000..55fc99c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-m2-likes-design.md @@ -0,0 +1,242 @@ +# M2 Likes Sub-Plan — Design Spec + +**Status:** approved 2026-04-26 +**Slice:** M2 (Events, sessions, general likes), spec §13 step 6 + scope extension. The original spec only covers track-level `general_likes`; this slice extends to album and artist starring for full Subsonic compatibility. +**Fable tasks:** #326 (server + Subsonic) and #327 (web UI heart + Liked view); closes M2 along with #328 (milestone gate verification). + +## Goal + +Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page. + +## Non-goals + +- Per-context likes, session vector capture on like (`contextual_likes`) — that's M3 territory; the table already exists from migration `0005_events`. +- Aggregate `artist_preferences` (computed weighted signal) — M3. +- Liked-state propagation via WebSocket / SSE. Subsonic-driven changes only show up after the next refetch (manual reload or TanStack's window-focus refetch). +- Bulk operations (like-all-from-album, etc.). Single-entity like/unlike only. +- Sorting controls on `/library/liked`. Default `liked_at DESC` is the only order in v1. +- Importing likes from Last.fm / ListenBrainz / Spotify. v1 only persists what the user does in this server. + +## Architecture + +Three new tables — one per entity type — keyed on `(user_id, entity_id)` with `liked_at timestamptz`. Three native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus a single `/api/likes/ids` endpoint that returns flat id sets for client-side heart-rendering. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` handlers route through the same per-table writes. + +The web client exposes one `LikeButton.svelte` component that takes `(entityType, entityId)` and reads from a single `createLikedIdsQuery()` TanStack Query whose data shape is `{ track_ids: string[], album_ids: string[], artist_ids: string[] }`. Mutations do optimistic `setQueryData` updates with rollback on error. Heart placements: `TrackRow`, `AlbumCard`, `ArtistRow`, `PlayerBar`. New `/library/liked` page with three sections backed by infinite queries. + +**No `liked` flag on entity refs.** Server doesn't add a join to every track/album/artist response. Liked state is computed on the client via O(1) Set lookups from the cached `likedIdsQuery`. Single source of truth; cache is shared across all hearts in the app; mutations invalidate it once and every component re-renders. + +## Schema (migration `0006_likes.up.sql`) + +```sql +CREATE TABLE general_likes ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); +CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC); + +CREATE TABLE general_likes_albums ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, album_id) +); +CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC); + +CREATE TABLE general_likes_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); +CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC); +``` + +Down migration drops all three tables. + +## API contracts + +### Native `/api/likes/...` + +```ts +// Idempotent upsert. 204 on success, 404 if entity doesn't exist, 400 on bad UUID. +POST /api/likes/tracks/:track_id → 204 +POST /api/likes/albums/:album_id → 204 +POST /api/likes/artists/:artist_id → 204 + +// Idempotent delete. 204 regardless of prior state. +DELETE /api/likes/tracks/:track_id → 204 +DELETE /api/likes/albums/:album_id → 204 +DELETE /api/likes/artists/:artist_id → 204 + +// Paginated detail listings, sorted by liked_at DESC. +GET /api/likes/tracks?limit=&offset= → Page +GET /api/likes/albums?limit=&offset= → Page +GET /api/likes/artists?limit=&offset= → Page + +// Flat id set for heart-button rendering. +GET /api/likes/ids → { track_ids: string[], album_ids: string[], artist_ids: string[] } +``` + +All gated by the existing `RequireUser` middleware. `user_id` comes from request context; nothing in the URL or body identifies the user. Cross-user isolation is enforced by the queries (`WHERE user_id = $1`). + +### Subsonic `/rest/...` + +``` +GET /rest/star?id=&albumId=&artistId= → standard `ok` envelope +GET /rest/unstar?id=&albumId=&artistId= → standard `ok` envelope +GET /rest/getStarred → with song/album/artist children +GET /rest/getStarred2 → (OpenSubsonic JSON variant) +``` + +`star`/`unstar` accept any combination of `id`, `albumId`, `artistId` (zero or more of each). For each present param, upsert (or delete) the matching row in the matching table. Missing-entity → standard Subsonic `` (data not found). Repeated params (e.g. `id=A&id=B`) — accept the first occurrence only for v1; document as known limitation. + +`getStarred` and `getStarred2` return the user's stars under a `` (or `starred2` JSON) wrapper containing arrays of `song`, `album`, `artist` children, each sorted `liked_at DESC`. The existing `TrackRef`/`AlbumRef`/`ArtistRef` Subsonic serialization (used by search3, getAlbum, getArtist) is reused here. + +## Components & files + +### New server files + +| Path | Responsibility | +|---|---| +| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Three tables + indexes. | +| `internal/db/queries/likes.sql` | sqlc queries: `Insert*Like`, `Delete*Like`, `List*Likes` (paginated), `Count*Likes`, `List*LikedIds` for each of the three tables. | +| `internal/db/dbq/likes.sql.go` | Generated bindings. | +| `internal/api/likes.go` | Handlers for all seven native routes. | +| `internal/api/likes_test.go` | HTTP-level coverage. | +| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. | +| `internal/subsonic/star_test.go` | Subsonic coverage. | + +### Modified server files + +| Path | Change | +|---|---| +| `internal/api/api.go` | Register `/api/likes/{tracks,albums,artists}/:id` (POST + DELETE), `/api/likes/{tracks,albums,artists}` (GET, paginated), `/api/likes/ids` (GET). Inside the authed group. | +| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2` in the Mount table. | + +### New web files + +| Path | Responsibility | +|---|---| +| `web/src/lib/api/likes.ts` | TanStack Query helpers: `createLikedIdsQuery()` and per-entity infinite-query factories. Mutation factories: `likeTrack(id)`, `unlikeTrack(id)`, plus album and artist variants. Each mutation does optimistic update via `setQueryData` and rolls back on error. | +| `web/src/lib/api/likes.test.ts` | Cache-update unit tests. | +| `web/src/lib/components/LikeButton.svelte` | Heart icon. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. Reads `createLikedIdsQuery()`; click toggles via the right mutation; `event.stopPropagation()` so it doesn't bubble to `TrackRow`'s row-click. | +| `web/src/lib/components/LikeButton.test.ts` | Renders correct fill state; click toggles correctly per entity type; click stops propagation. | +| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists / Albums / Tracks) backed by infinite queries. | +| `web/src/routes/library/liked/liked.test.ts` | Page integration. | + +### Modified web files + +| Path | Change | +|---|---| +| `web/src/lib/api/types.ts` | Add `LikedIdsResponse = { track_ids: string[]; album_ids: string[]; artist_ids: string[] }`. | +| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks(q)`, `qk.likedAlbums(q)`, `qk.likedArtists(q)` keys. Re-export from `likes.ts` (or leave them in their own module — match prior convention). | +| `web/src/lib/components/TrackRow.svelte` | Insert `` next to the existing `+ queue` button. Grid template grows to `[32px_1fr_auto_auto_auto]`. | +| `web/src/lib/components/AlbumCard.svelte` | Add a second overlay button row beneath the existing `+ queue` overlay (or stack them vertically) with ``. | +| `web/src/lib/components/ArtistRow.svelte` | Add `` to the row's right side, before the chevron. | +| `web/src/lib/components/PlayerBar.svelte` | Add `` next to the title in the left cluster (cover + title + artist link). | +| `web/src/lib/components/Shell.svelte` | Add a "Liked" entry to `navItems` pointing at `/library/liked`. | + +## Data flow + +**Like a track from the album page:** + +1. User clicks heart on a `TrackRow`. +2. `LikeButton` reads `createLikedIdsQuery()` data; sees `track_ids` doesn't include this id; fires `likeTrack(id)` mutation. +3. Mutation does optimistic `setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))` so heart fills instantly. Then `POST /api/likes/tracks/:id`. +4. Server inserts into `general_likes` with `ON CONFLICT DO NOTHING`. Returns 204. +5. Mutation `onSuccess` invalidates `qk.likedIds` and `qk.likedTracks` (so `/library/liked` refetches if visible). `onError` rolls back the optimistic update from a `previousIds` snapshot captured in `onMutate`. + +**Star from a Subsonic client:** + +1. Client POSTs `/rest/star?id=&u=admin&t=...`. +2. `internal/subsonic/star.go::handleStar` resolves auth, parses any combination of `id`/`albumId`/`artistId`, upserts each non-empty into the matching table, returns `ok` envelope. +3. Web SPA's `createLikedIdsQuery()` doesn't see it until next refetch (TanStack default refetch-on-window-focus or manual invalidation). Acceptable — Subsonic-driven changes flow through eventually. + +**View liked items:** + +1. User clicks "Liked" → `/library/liked`. +2. Page mounts three `createInfiniteQuery` instances. Each fires `GET /api/likes/{type}?limit=50&offset=0`. +3. Server SQL: e.g. `SELECT t.* FROM tracks t JOIN general_likes l ON l.track_id = t.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC LIMIT $2 OFFSET $3`. Plus `SELECT count(*) FROM general_likes WHERE user_id = $1` for the `total`. +4. Returns `Page` (and analogous for albums/artists). +5. Page renders three sections; each has its own "Load more" per the existing infinite-query convention. Empty section shows "No liked X yet". + +**Heart on PlayerBar:** + +`` — same component as TrackRow's heart. The fill state is reactive: when `player.current` changes, the derived id-membership check re-runs against the cached id set. + +**Liked-state propagation across the app:** every heart subscribes to the same `qk.likedIds` query. A mutation invalidation triggers all of them to re-render. No prop drilling or pub-sub needed. + +## States + +| Condition | Render | +|---|---| +| `likedIdsQuery.isPending` | Heart is disabled and shows a neutral state (outline). Action defers; quick to load. | +| `likedIdsQuery.isError` | Heart shows outline; clicks are no-ops; no error toast (likes are non-critical). | +| Set contains entity id | Filled heart, `aria-pressed="true"`. | +| Set does NOT contain entity id | Outline heart, `aria-pressed="false"`. | + +`/library/liked` page states (per section, reusing patterns from search): +- `q.isPending && items.length === 0` → `LibrarySkeleton` for that section +- `q.isError` → `ApiErrorBanner` +- `total === 0` → "No liked X yet" hint +- otherwise → list/grid + "Load more" if `hasNextPage` + +## Testing + +### Server (`go test`) + +- **Migration smoke** — applying `0006_likes` round-trips the three tables; insert+select for each. +- **Native handlers** (`internal/api/likes_test.go`): + - Like-track happy path (204; row exists; idempotent — 204 on repeat). + - Like with malformed UUID → 400; nonexistent UUID → 404. + - Unlike happy path (204; row gone; idempotent). + - List endpoint returns paginated `Page` sorted `liked_at DESC`; `total` reflects user's full liked set; `limit`/`offset` work. + - `GET /api/likes/ids` returns three string arrays containing exactly the ids of the user's rows. + - Cross-user isolation — Alice likes track X, Bob's `/api/likes/ids` does not contain X. + - One coverage case per entity type (album, artist) on the like+unlike+list+ids paths. +- **Subsonic handlers** (`internal/subsonic/star_test.go`): + - `star?id=` writes to `general_likes`, returns `ok` envelope. + - `star?albumId=&artistId=` writes to both album and artist tables in one call. + - `star?id=` returns Subsonic error 70 (data not found). + - `unstar` removes the row, returns `ok`. Idempotent. + - `getStarred2` returns artist/album/song arrays correctly populated, sorted `liked_at DESC`. Cross-user isolation verified. + +### Web (`vitest`) + +- **`likes.test.ts`** — TanStack Query factory + mutations: + - `createLikedIdsQuery` shape (`{ track_ids, album_ids, artist_ids }`). + - `likeTrack(id)` optimistic update inserts id into `track_ids`; mocked failure rolls back. + - `unlikeTrack(id)` removes id; rollback restores. + - Same coverage for album/artist mutations. +- **`LikeButton.test.ts`**: + - Renders empty heart when id is NOT in the matching set; filled heart when it IS. + - Click on empty heart fires the like mutation (mocked). + - Click on filled heart fires the unlike mutation. + - Click stops propagation (doesn't bubble to parent `
` on TrackRow). +- **`liked.test.ts`** (`/library/liked` page): + - Three sections render with mocked infinite queries. + - Empty section shows "No liked X yet" hint (gate on `total === 0` per the search-page pattern). + - "Load more" calls `fetchNextPage` per section. +- **`TrackRow.test.ts` / `AlbumCard.test.ts` / `ArtistRow.test.ts` / `PlayerBar.test.ts`** updates: each gets one new test asserting `` is rendered with the right `entityType` + `entityId`. The button's behavior is covered in `LikeButton.test.ts`; we just verify wiring. + +### End-to-end manual + +Final task in the implementation plan: + +1. Sign in. Click heart on a track → fills instantly. Refresh page → still filled. `psql` shows the row in `general_likes`. +2. Click heart again → empties. Row gone. +3. Click heart on an album card. Click heart on an artist row. Verify `general_likes_albums` and `general_likes_artists` rows. +4. Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled. +5. Unstar from Subsonic. Web SPA's heart empties on next refetch. +6. Visit `/library/liked`. Three sections render with the items above. "Load more" works once you have > 50 of any type. +7. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered. + +## Risks & mitigations + +- **Likes diverge from Subsonic on partial failures.** If a Subsonic client sends `star?id=A&albumId=B` and the album doesn't exist, the track gets starred but album fails. Mitigation: validate all entity ids first; if any is missing, return error 70 BEFORE writing any. Atomicity matters less than predictability for clients. +- **`createLikedIdsQuery` payload grows unbounded for power users.** For 10k liked tracks, the array is ~370 KB — acceptable. For 100k+ users it becomes a concern. Mitigation: not a v1 problem; document and revisit if telemetry shows large libraries. A per-entity-type "is liked" check endpoint would be a clean upgrade path. +- **Optimistic update / refetch race.** If a mutation is in-flight when a refetch lands, the rollback might overwrite the actual server state with stale "previous" data. TanStack's mutation flow handles this — `onError` only fires on actual error, and the `setQueryData` we do in `onMutate` is the optimistic value, not stale. +- **Cross-table star with mixed presence.** `star?id=&albumId=` where one exists and the other doesn't — option A: 404 the whole call; option B: succeed for the existing one, ignore the missing one. We pick A (atomicity) per the first risk above. Validation pass before any insert. +- **`liked_at` clock skew across replicas.** Single-instance deployment for v1 — defer. diff --git a/internal/api/api.go b/internal/api/api.go index ebae8648..27ee3a2f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,6 +36,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/search", h.handleSearch) authed.Get("/radio", h.handleRadio) authed.Post("/events", h.handleEvents) + 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) }) }) } diff --git a/internal/api/likes.go b/internal/api/likes.go new file mode 100644 index 00000000..3cb1c698 --- /dev/null +++ b/internal/api/likes.go @@ -0,0 +1,316 @@ +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 diff --git a/internal/api/likes_test.go b/internal/api/likes_test.go new file mode 100644 index 00000000..e97a6a08 --- /dev/null +++ b/internal/api/likes_test.go @@ -0,0 +1,201 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "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) + } +} diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go new file mode 100644 index 00000000..3ded9371 --- /dev/null +++ b/internal/db/dbq/likes.sql.go @@ -0,0 +1,342 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: likes.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countLikedAlbums = `-- name: CountLikedAlbums :one +SELECT count(*) FROM general_likes_albums WHERE user_id = $1 +` + +func (q *Queries) CountLikedAlbums(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedAlbums, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLikedArtists = `-- name: CountLikedArtists :one +SELECT count(*) FROM general_likes_artists WHERE user_id = $1 +` + +func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedArtists, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLikedTracks = `-- name: CountLikedTracks :one +SELECT count(*) FROM general_likes WHERE user_id = $1 +` + +func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedTracks, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const likeAlbum = `-- name: LikeAlbum :exec +INSERT INTO general_likes_albums (user_id, album_id) +VALUES ($1, $2) +ON CONFLICT (user_id, album_id) DO NOTHING +` + +type LikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID +} + +func (q *Queries) LikeAlbum(ctx context.Context, arg LikeAlbumParams) error { + _, err := q.db.Exec(ctx, likeAlbum, arg.UserID, arg.AlbumID) + return err +} + +const likeArtist = `-- name: LikeArtist :exec +INSERT INTO general_likes_artists (user_id, artist_id) +VALUES ($1, $2) +ON CONFLICT (user_id, artist_id) DO NOTHING +` + +type LikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID +} + +func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error { + _, err := q.db.Exec(ctx, likeArtist, arg.UserID, arg.ArtistID) + return err +} + +const likeTrack = `-- name: LikeTrack :exec +INSERT INTO general_likes (user_id, track_id) +VALUES ($1, $2) +ON CONFLICT (user_id, track_id) DO NOTHING +` + +type LikeTrackParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error { + _, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) + return err +} + +const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many +SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedAlbumIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedAlbumIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var album_id pgtype.UUID + if err := rows.Scan(&album_id); err != nil { + return nil, err + } + items = append(items, album_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedAlbumRows = `-- name: ListLikedAlbumRows :many +SELECT a.id, a.title, a.sort_title, a.artist_id, a.release_date, a.mbid, a.cover_art_path, a.created_at, a.updated_at 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 +` + +type ListLikedAlbumRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedAlbumRows(ctx context.Context, arg ListLikedAlbumRowsParams) ([]Album, error) { + rows, err := q.db.Query(ctx, listLikedAlbumRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedArtistIDs = `-- name: ListLikedArtistIDs :many +SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedArtistIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedArtistIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var artist_id pgtype.UUID + if err := rows.Scan(&artist_id); err != nil { + return nil, err + } + items = append(items, artist_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedArtistRows = `-- name: ListLikedArtistRows :many +SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at 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 +` + +type ListLikedArtistRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedArtistRows(ctx context.Context, arg ListLikedArtistRowsParams) ([]Artist, error) { + rows, err := q.db.Query(ctx, listLikedArtistRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedTrackIDs = `-- name: ListLikedTrackIDs :many +SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedTrackIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var track_id pgtype.UUID + if err := rows.Scan(&track_id); err != nil { + return nil, err + } + items = append(items, track_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedTrackRows = `-- name: ListLikedTrackRows :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at 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 +` + +type ListLikedTrackRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const unlikeAlbum = `-- name: UnlikeAlbum :exec +DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2 +` + +type UnlikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID +} + +func (q *Queries) UnlikeAlbum(ctx context.Context, arg UnlikeAlbumParams) error { + _, err := q.db.Exec(ctx, unlikeAlbum, arg.UserID, arg.AlbumID) + return err +} + +const unlikeArtist = `-- name: UnlikeArtist :exec +DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2 +` + +type UnlikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID +} + +func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) error { + _, err := q.db.Exec(ctx, unlikeArtist, arg.UserID, arg.ArtistID) + return err +} + +const unlikeTrack = `-- name: UnlikeTrack :exec +DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2 +` + +type UnlikeTrackParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error { + _, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID) + return err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 1783cd70..4d6c4199 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -29,6 +29,24 @@ type Artist struct { UpdatedAt pgtype.Timestamptz } +type GeneralLike struct { + UserID pgtype.UUID + TrackID pgtype.UUID + LikedAt pgtype.Timestamptz +} + +type GeneralLikesAlbum struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + LikedAt pgtype.Timestamptz +} + +type GeneralLikesArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + LikedAt pgtype.Timestamptz +} + type PlayEvent struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/migrations/0006_likes.down.sql b/internal/db/migrations/0006_likes.down.sql new file mode 100644 index 00000000..b3ca0692 --- /dev/null +++ b/internal/db/migrations/0006_likes.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS general_likes_artists; +DROP TABLE IF EXISTS general_likes_albums; +DROP TABLE IF EXISTS general_likes; diff --git a/internal/db/migrations/0006_likes.up.sql b/internal/db/migrations/0006_likes.up.sql new file mode 100644 index 00000000..fdbf33c8 --- /dev/null +++ b/internal/db/migrations/0006_likes.up.sql @@ -0,0 +1,27 @@ +-- 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); diff --git a/internal/db/queries/likes.sql b/internal/db/queries/likes.sql new file mode 100644 index 00000000..b4a38473 --- /dev/null +++ b/internal/db/queries/likes.sql @@ -0,0 +1,62 @@ +-- 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; diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go new file mode 100644 index 00000000..7dea5cb4 --- /dev/null +++ b/internal/subsonic/star.go @@ -0,0 +1,233 @@ +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 +} + +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 +} diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go new file mode 100644 index 00000000..be27e876 --- /dev/null +++ b/internal/subsonic/star_test.go @@ -0,0 +1,251 @@ +package subsonic + +import ( + "context" + "encoding/json" + "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 +} + +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)) + } +} diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index ed3f0069..c885bbf1 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -38,6 +38,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev register(sub, "/download", m.handleDownload) register(sub, "/getCoverArt", m.handleGetCoverArt) register(sub, "/scrobble", m.handleScrobble) + register(sub, "/star", m.handleStar) + register(sub, "/unstar", m.handleUnstar) + register(sub, "/getStarred", m.handleGetStarred) + register(sub, "/getStarred2", m.handleGetStarred2) register(sub, "/getUser", handleGetUser) // Subsonic clients expect every /rest/* miss to come back as a diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go index df0d1c18..19ffb63d 100644 --- a/internal/subsonic/types.go +++ b/internal/subsonic/types.go @@ -211,6 +211,34 @@ type SearchResult3Response struct { SearchResult3 SearchResult3Container `json:"searchResult3" xml:"searchResult3"` } +// 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"` +} + // ---- conversion helpers ---- func artistRef(a dbq.Artist, albumCount int) ArtistRef { diff --git a/web/src/lib/api/likes.test.ts b/web/src/lib/api/likes.test.ts new file mode 100644 index 00000000..f5dc3b46 --- /dev/null +++ b/web/src/lib/api/likes.test.ts @@ -0,0 +1,72 @@ +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', undefined); + }); + + 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', undefined); + }); + + 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', undefined); + }); + + 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'); + }); +}); diff --git a/web/src/lib/api/likes.ts b/web/src/lib/api/likes.ts new file mode 100644 index 00000000..c6cf592f --- /dev/null +++ b/web/src/lib/api/likes.ts @@ -0,0 +1,116 @@ +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; + } +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 8fb531a8..11c8b2a4 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -15,6 +15,10 @@ export const qk = { 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, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 4725753b..2d666faa 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -57,6 +57,12 @@ export type RadioResponse = { tracks: TrackRef[]; }; +export type LikedIdsResponse = { + track_ids: string[]; + album_ids: string[]; + artist_ids: string[]; +}; + export type EventRequest = | { type: 'play_started'; track_id: string; client_id?: string } | { type: 'play_ended'; play_event_id: string; duration_played_ms: number } diff --git a/web/src/lib/components/AlbumCard.svelte b/web/src/lib/components/AlbumCard.svelte index 828daa79..4eeb0aed 100644 --- a/web/src/lib/components/AlbumCard.svelte +++ b/web/src/lib/components/AlbumCard.svelte @@ -3,6 +3,7 @@ import { FALLBACK_COVER } from '$lib/media/covers'; import { api } from '$lib/api/client'; import { enqueueTracks } from '$lib/player/store.svelte'; + import LikeButton from './LikeButton.svelte'; let { album }: { album: AlbumRef } = $props(); @@ -35,10 +36,13 @@
{album.year}
{/if}
- +
+ + +
diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 1f512b6a..6a95cbd0 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types'; import { FALLBACK_COVER } from '$lib/media/covers'; +import { readable } from 'svelte/store'; vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } @@ -10,6 +11,21 @@ vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import AlbumCard from './AlbumCard.svelte'; import { api } from '$lib/api/client'; import { enqueueTracks } from '$lib/player/store.svelte'; diff --git a/web/src/lib/components/ArtistRow.svelte b/web/src/lib/components/ArtistRow.svelte index 92aee7ad..33fd5698 100644 --- a/web/src/lib/components/ArtistRow.svelte +++ b/web/src/lib/components/ArtistRow.svelte @@ -1,5 +1,6 @@ - +
+ - {artist.name} - {countLabel} - - + {artist.name} + {countLabel} + + + + +
diff --git a/web/src/lib/components/ArtistRow.test.ts b/web/src/lib/components/ArtistRow.test.ts index 5dec7fe4..333b78c2 100644 --- a/web/src/lib/components/ArtistRow.test.ts +++ b/web/src/lib/components/ArtistRow.test.ts @@ -1,8 +1,24 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import ArtistRow from './ArtistRow.svelte'; import type { ArtistRef } from '$lib/api/types'; +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; describe('ArtistRow', () => { @@ -11,14 +27,14 @@ describe('ArtistRow', () => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/artists/abc'); - expect(link).toHaveTextContent('alice'); - expect(link).toHaveTextContent('12 albums'); - expect(link).toHaveTextContent('A'); // initial letter + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('12 albums')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); }); test('singular "1 album" when album_count is 1', () => { render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); - expect(screen.getByRole('link')).toHaveTextContent('1 album'); + expect(screen.getByText('1 album')).toBeInTheDocument(); }); test('empty name still renders a safe initial', () => { diff --git a/web/src/lib/components/LikeButton.svelte b/web/src/lib/components/LikeButton.svelte new file mode 100644 index 00000000..774a6887 --- /dev/null +++ b/web/src/lib/components/LikeButton.svelte @@ -0,0 +1,43 @@ + + + diff --git a/web/src/lib/components/LikeButton.test.ts b/web/src/lib/components/LikeButton.test.ts new file mode 100644 index 00000000..90f724a6 --- /dev/null +++ b/web/src/lib/components/LikeButton.test.ts @@ -0,0 +1,70 @@ +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'] as string[], album_ids: [] as string[], artist_ids: [] as string[] } +})); + +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'); + }); +}); diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index a35c8932..e2e593b3 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -6,6 +6,7 @@ } from '$lib/player/store.svelte'; import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER } from '$lib/media/covers'; + import LikeButton from './LikeButton.svelte'; const current = $derived(player.current); @@ -48,7 +49,7 @@ onerror={onCoverError} /> -
+
{current.title}
+
diff --git a/web/src/lib/components/PlayerBar.test.ts b/web/src/lib/components/PlayerBar.test.ts index e817aa64..67c31e02 100644 --- a/web/src/lib/components/PlayerBar.test.ts +++ b/web/src/lib/components/PlayerBar.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import type { TrackRef } from '$lib/api/types'; +import { readable } from 'svelte/store'; // Mutable state the mocked store reads from. const state = vi.hoisted(() => ({ @@ -40,6 +41,21 @@ vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import PlayerBar from './PlayerBar.svelte'; import { togglePlay, skipNext, skipPrev, seekTo, setVolume, diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index e19df1f6..8a81e8a8 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -22,9 +22,10 @@ } const navItems = [ - { href: '/', label: 'Library' }, - { href: '/search', label: 'Search' }, - { href: '/playlists', label: 'Playlists' } + { href: '/', label: 'Library' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/search', label: 'Search' }, + { href: '/playlists', label: 'Playlists' } ]; diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index 5cad7134..68a76432 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -2,6 +2,7 @@ import type { TrackRef } from '$lib/api/types'; import { formatDuration } from '$lib/media/duration'; import { playQueue, enqueueTrack } from '$lib/player/store.svelte'; + import LikeButton from './LikeButton.svelte'; type PlayHandler = (tracks: TrackRef[], index: number) => void; @@ -41,12 +42,13 @@ aria-label={track.title} onclick={onRowClick} onkeydown={onRowKey} - class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" + class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" > {track.track_number ?? '—'} {track.title} + + {/if} + {/if} + + +
+

Albums

+ {#if albumsTotal === 0} +

No liked albums yet.

+ {:else} +
+ {#each albums as al (al.id)} + + {/each} +
+ {#if albumsQuery?.hasNextPage} + + {/if} + {/if} +
+ +
+

Tracks

+ {#if tracksTotal === 0} +

No liked tracks yet.

+ {:else} +
+ {#each tracks as t, i (t.id)} + + {/each} +
+ {#if tracksQuery?.hasNextPage} + + {/if} + {/if} +
+
diff --git a/web/src/routes/library/liked/liked.test.ts b/web/src/routes/library/liked/liked.test.ts new file mode 100644 index 00000000..bcb2d29b --- /dev/null +++ b/web/src/routes/library/liked/liked.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { ArtistRef, AlbumRef, TrackRef, Page } from '$lib/api/types'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + createLikedTracksInfiniteQuery: vi.fn(), + createLikedAlbumsInfiniteQuery: vi.fn(), + createLikedArtistsInfiniteQuery: vi.fn(), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn() +})); + +import LikedPage from './+page.svelte'; +import { + createLikedTracksInfiniteQuery, + createLikedAlbumsInfiniteQuery, + createLikedArtistsInfiniteQuery +} from '$lib/api/likes'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +afterEach(() => vi.clearAllMocks()); + +describe('liked library page', () => { + test('renders three sections when each has items', () => { + const ar: ArtistRef = { id: 'a1', name: 'X', album_count: 1 }; + const al: AlbumRef = { + id: 'al1', title: 'Y', artist_id: 'a1', artist_name: 'X', + year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x' + }; + const tr: TrackRef = { + id: 't1', title: 'Z', album_id: 'al1', album_title: 'Y', + artist_id: 'a1', artist_name: 'X', + track_number: 1, disc_number: 1, duration_sec: 100, + stream_url: '/api/tracks/t1/stream' + }; + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([ar], 1)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([al], 1)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([tr], 1)] }) + ); + render(LikedPage); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); + }); + + test('empty section renders "No liked X yet" hint', () => { + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(LikedPage); + expect(screen.getAllByText(/no liked/i)).toHaveLength(3); + }); + + test('Load more calls fetchNextPage on each section that has more', async () => { + const fetchTracks = vi.fn(); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 100)], hasNextPage: true, fetchNextPage: fetchTracks }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(LikedPage); + const buttons = screen.getAllByRole('button', { name: /load more/i }); + expect(buttons).toHaveLength(1); + await fireEvent.click(buttons[0]); + expect(fetchTracks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts index f48b8781..32eaed58 100644 --- a/web/src/routes/search/albums/albums.test.ts +++ b/web/src/routes/search/albums/albums.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { AlbumRef, Page } from '$lib/api/types'; @@ -20,6 +21,21 @@ vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import AlbumsOverflow from './+page.svelte'; import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts index 607ed39e..0c18d635 100644 --- a/web/src/routes/search/artists/artists.test.ts +++ b/web/src/routes/search/artists/artists.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { ArtistRef, Page } from '$lib/api/types'; @@ -13,6 +14,21 @@ vi.mock('$lib/api/queries', () => ({ createSearchArtistsInfiniteQuery: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import ArtistsOverflow from './+page.svelte'; import { createSearchArtistsInfiniteQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/search.test.ts b/web/src/routes/search/search.test.ts index 8aab5c7d..4c4927b6 100644 --- a/web/src/routes/search/search.test.ts +++ b/web/src/routes/search/search.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockQuery } from '../../test-utils/query'; import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types'; @@ -26,6 +27,21 @@ vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import SearchPage from './+page.svelte'; import { createSearchQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts index 5eb9c300..4d515483 100644 --- a/web/src/routes/search/tracks/tracks.test.ts +++ b/web/src/routes/search/tracks/tracks.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { TrackRef, Page } from '$lib/api/types'; @@ -19,6 +20,21 @@ vi.mock('$lib/player/store.svelte', () => ({ enqueueTrack: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import TracksOverflow from './+page.svelte'; import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; import { playRadio } from '$lib/player/store.svelte';