chore: untrack docs/superpowers, ignore going forward

Agent-authored design specs and implementation plans are kept local
on the working machine; the canonical record lives in commit messages
and the running code. The 48 historical files remain in git history
and can be retrieved via git checkout <sha> -- docs/superpowers/ if
ever needed; they just stop accruing on the dev tip.
This commit is contained in:
2026-05-03 15:32:43 -04:00
parent d56edadf0e
commit ec1b3a3397
49 changed files with 4 additions and 54772 deletions
+4
View File
@@ -29,6 +29,10 @@ go.work.sum
# Superpowers brainstorming companion sessions
.superpowers/
# Agent-authored design specs and implementation plans (kept local; the
# canonical record lives in commit messages and the running code).
docs/superpowers/
# Flutter
flutter_client/.dart_tool/
flutter_client/.flutter-plugins
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,954 +0,0 @@
# 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
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
%sveltekit.head%
</head>
<body class="bg-surface-900 text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
- [ ] **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
<script lang="ts">
import '../app.css';
</script>
<slot />
```
- [ ] **Step 10: Create `web/src/routes/+page.svelte`**
```svelte
<main class="flex h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-semibold">Minstrel</h1>
<p class="mt-2 text-text-secondary">
Scaffold &mdash; UI features land in subsequent plans.
</p>
</div>
</main>
```
- [ ] **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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Minstrel</title>
</head>
<body>
<p>
Minstrel SPA has not been built. Run
<code>cd web &amp;&amp; npm install &amp;&amp; npm run build</code> or
build the container image, which runs the web build during
<code>docker build</code>.
</p>
</body>
</html>
```
- [ ] **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)), "<html") {
t.Errorf("body missing <html tag; got: %q", string(body))
}
}
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
}
}
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `go test ./web/ -v`
Expected: FAIL — `Handler` undefined / package missing.
- [ ] **Step 3: Implement `web/embed.go`**
```go
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
// Requests for real files under build/ are served verbatim; anything else —
// deep links like /artists/abc, missing asset paths — returns build/index.html
// so the SPA can resolve the route client-side.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:build
var buildFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA.
//
// Behavior:
// - GET / -> build/index.html
// - GET /<path that exists in build/> -> that file, via http.FileServer
// - GET /<anything else> -> 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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,860 +0,0 @@
# M7 #362 — Web SPA Theme Toggle 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:** Ship a Dark / Light / System theme toggle for the Minstrel web SPA, persisted to localStorage and applied before first paint.
**Architecture:** CSS custom-property indirection via existing `--fs-*` tokens. `tokens.json` grows from a flat `colors` map to `colors.dark` + `colors.light` + `colors.flat`. Generator emits `:root { ... }` (dark + flat) and `[data-theme="light"] { ... }` (light overrides only). Theme is applied by setting `data-theme` on `<html>`; an inline `<head>` script does this before paint to avoid FOUC.
**Tech Stack:** SvelteKit 2 / Svelte 5 (runes mode), Tailwind CSS, vanilla `matchMedia` + `localStorage`. No new dependencies.
**Spec:** `docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md` (commit `1d6a71a` on `dev`).
**Test policy:** No in-task test runs. Tests are written as files for CI to execute (vitest under `web/src/**/*.test.ts` and `web/scripts/*.test.js`); the implementer never invokes `npm test`, `vitest`, `npm run build`, or `npm run check` during this work. Codegen scripts (`npm run tokens`) ARE allowed.
---
## File Structure
**Modified:**
- `web/src/lib/styles/tokens.json` — schema change: `colors``colors.dark` + `colors.light` + `colors.flat`; add `on-action` flat token.
- `web/scripts/tokens-to-css.js` — emit `:root` (dark + flat) and `[data-theme="light"]` blocks.
- `web/src/lib/styles/tokens.generated.css` — regenerated artifact (do not hand-edit).
- `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'`.
- `web/src/app.html` — inline FOUC script in `<head>`; remove static `class="dark"` on `<html>`.
- `web/src/routes/settings/+page.svelte` — add Appearance card.
- ~25 component/route files — replace `text-text-primary` with `text-action-fg` on action-button labels.
**Created:**
- `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme state with matchMedia listener.
- `web/src/lib/stores/theme.svelte.test.ts` — store unit test (CI).
- `web/scripts/tokens-to-css.test.js` — generator unit test (CI).
- `web/src/routes/settings/Appearance.test.ts` — Appearance card render test (CI).
---
## Task 1: Restructure tokens.json + dual-block generator
**Files:**
- Modify: `web/src/lib/styles/tokens.json`
- Modify: `web/scripts/tokens-to-css.js`
- Modify: `web/src/lib/styles/tokens.generated.css` (regenerated)
- [ ] **Step 1: Restructure `tokens.json` colors map**
Replace the entire `colors` object at the top of `web/src/lib/styles/tokens.json`. The other top-level sections (`radii`, `fonts`, `fontStacks`) stay untouched.
```json
{
"colors": {
"dark": {
"obsidian": "#14171A",
"iron": "#1E2228",
"slate": "#2C313A",
"pewter": "#3F4651",
"parchment": "#E8E4D8",
"vellum": "#C2BFB4",
"ash": "#9C9A92"
},
"light": {
"obsidian": "#F8F5EE",
"iron": "#ECE6D5",
"slate": "#DCD3BD",
"pewter": "#B8AE94",
"parchment": "#14171A",
"vellum": "#2C313A",
"ash": "#5C6068"
},
"flat": {
"moss": "#4A5D3F",
"bronze": "#8B7355",
"oxblood": "#6B2118",
"warning": "#8B6F1E",
"error": "#C04A1F",
"info": "#3D5A6E",
"accent": "#4A6B5C",
"on-action": "#E8E4D8"
}
},
"radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" },
"fonts": { "display": "Fraunces", "body": "Inter", "mono": "JetBrains Mono" },
"fontStacks": {
"display": "'Fraunces', Georgia, serif",
"body": "'Inter', system-ui, sans-serif",
"mono": "'JetBrains Mono', ui-monospace, monospace"
}
}
```
- [ ] **Step 2: Rewrite the generator to emit dual blocks**
Replace the entire body of `web/scripts/tokens-to-css.js` with:
```js
#!/usr/bin/env node
// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css.
// Single source of truth for FabledSword tokens shared with the Flutter
// client. Keep output deterministic — Tailwind reads tokens.json directly,
// the .css emission is for raw CSS consumers and theme switching.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const tokensPath = resolve(here, '../src/lib/styles/tokens.json');
const outPath = resolve(here, '../src/lib/styles/tokens.generated.css');
export function emit(tokens) {
const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {'];
for (const [name, value] of Object.entries(tokens.colors.dark)) {
lines.push(` --fs-${name}: ${value};`);
}
for (const [name, value] of Object.entries(tokens.colors.flat)) {
lines.push(` --fs-${name}: ${value};`);
}
for (const [name, value] of Object.entries(tokens.radii)) {
lines.push(` --fs-radius-${name}: ${value};`);
}
lines.push(` --fs-font-display: ${tokens.fontStacks.display};`);
lines.push(` --fs-font-body: ${tokens.fontStacks.body};`);
lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`);
lines.push('}');
lines.push('[data-theme="light"] {');
for (const [name, value] of Object.entries(tokens.colors.light)) {
lines.push(` --fs-${name}: ${value};`);
}
lines.push('}');
lines.push('[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.flat.accent};`, '}', '');
return lines.join('\n');
}
// Run as script: read tokens.json + write generated CSS
if (import.meta.url === `file://${process.argv[1]}`) {
const tokens = JSON.parse(readFileSync(tokensPath, 'utf8'));
writeFileSync(outPath, emit(tokens));
console.log(`wrote ${outPath}`);
}
```
Note the new `export function emit(tokens)` so the unit test (Task 7) can import it directly without spawning a subprocess. The CLI behavior at the bottom only fires when invoked as a script.
- [ ] **Step 3: Regenerate `tokens.generated.css`**
```bash
cd web && npm run tokens
```
Expected output: `wrote /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web/src/lib/styles/tokens.generated.css`
The new file content should look like:
```css
/* GENERATED — do not edit. Source: tokens.json */
:root {
--fs-obsidian: #14171A;
--fs-iron: #1E2228;
--fs-slate: #2C313A;
--fs-pewter: #3F4651;
--fs-parchment: #E8E4D8;
--fs-vellum: #C2BFB4;
--fs-ash: #9C9A92;
--fs-moss: #4A5D3F;
--fs-bronze: #8B7355;
--fs-oxblood: #6B2118;
--fs-warning: #8B6F1E;
--fs-error: #C04A1F;
--fs-info: #3D5A6E;
--fs-accent: #4A6B5C;
--fs-on-action: #E8E4D8;
--fs-radius-sm: 4px;
--fs-radius-md: 8px;
--fs-radius-lg: 12px;
--fs-radius-xl: 16px;
--fs-font-display: 'Fraunces', Georgia, serif;
--fs-font-body: 'Inter', system-ui, sans-serif;
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
[data-theme="light"] {
--fs-obsidian: #F8F5EE;
--fs-iron: #ECE6D5;
--fs-slate: #DCD3BD;
--fs-pewter: #B8AE94;
--fs-parchment: #14171A;
--fs-vellum: #2C313A;
--fs-ash: #5C6068;
}
[data-fs-app="minstrel"] {
--fs-accent: #4A6B5C;
}
```
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/lib/styles/tokens.generated.css
git commit -m "feat(web/m7-362): tokens.json dark/light/flat split + dual-block CSS generator"
```
---
## Task 2: Add `text-action-fg` Tailwind alias
**Files:**
- Modify: `web/tailwind.config.js`
- [ ] **Step 1: Add `fg` to the action color group**
Edit `web/tailwind.config.js`. In the `action` block under `theme.extend.colors`, add the `fg` entry:
```js
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)'
},
```
The full file after edit:
```js
export default {
content: ['./src/**/*.{html,js,ts,svelte}'],
darkMode: 'class',
theme: {
extend: {
colors: {
background: 'var(--fs-obsidian)',
surface: {
DEFAULT: 'var(--fs-iron)',
hover: 'var(--fs-slate)'
},
border: {
DEFAULT: 'var(--fs-pewter)'
},
text: {
primary: 'var(--fs-parchment)',
secondary: 'var(--fs-vellum)',
muted: 'var(--fs-ash)'
},
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)'
},
accent: {
DEFAULT: 'var(--fs-accent)',
tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)'
},
warning: 'var(--fs-warning)',
error: 'var(--fs-error)',
info: 'var(--fs-info)'
},
borderRadius: {
sm: 'var(--fs-radius-sm)',
md: 'var(--fs-radius-md)',
lg: 'var(--fs-radius-lg)',
xl: 'var(--fs-radius-xl)'
},
fontFamily: {
display: ['var(--fs-font-display)'],
sans: ['var(--fs-font-body)'],
mono: ['var(--fs-font-mono)']
}
}
},
plugins: []
};
```
- [ ] **Step 2: Commit**
```bash
git add web/tailwind.config.js
git commit -m "feat(web/m7-362): add Tailwind text-action-fg alias for non-flipping label color"
```
---
## Task 3: Replace action-button label class across the codebase
**Files:**
- Modify: every site where a `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` element also carries `text-text-primary`
This is a mechanical audit + replace. The grep performed during planning found ~25 sites; the implementer should re-grep to catch any new sites since.
- [ ] **Step 1: Run the audit grep**
```bash
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary"
```
Expected output: a list of file:line entries, each containing both classes on the same element. Confirmed at planning time:
```
src/lib/components/AddToPlaylistMenu.svelte:113
src/lib/components/DiscoverResultCard.svelte:77
src/lib/components/FlagPopover.svelte:91
src/routes/admin/+page.svelte:239
src/routes/admin/+page.svelte:302
src/routes/admin/+page.svelte:312
src/routes/admin/integrations/+page.svelte:276
src/routes/admin/integrations/+page.svelte:293
src/routes/admin/integrations/+page.svelte:360
src/routes/admin/integrations/+page.svelte:368
src/routes/admin/quarantine/+page.svelte:293
src/routes/admin/quarantine/+page.svelte:304
src/routes/admin/quarantine/+page.svelte:315
src/routes/admin/quarantine/+page.svelte:364
src/routes/admin/quarantine/+page.svelte:426
src/routes/admin/requests/+page.svelte:273
src/routes/admin/requests/+page.svelte:282
src/routes/admin/requests/+page.svelte:355
src/routes/admin/requests/+page.svelte:407
src/routes/discover/+page.svelte:221
src/routes/discover/+page.svelte:228
src/routes/playlists/+page.svelte:46
src/routes/playlists/+page.svelte:81
src/routes/playlists/[id]/+page.svelte:216
```
- [ ] **Step 2: Replace `text-text-primary` with `text-action-fg` at each site**
For each file:line above, edit the element so `text-text-primary` becomes `text-action-fg`. Other classes on the same element (rounded, px, py, hover:opacity-90, disabled:opacity-50, etc.) stay.
Example: `web/src/lib/components/DiscoverResultCard.svelte:77` before:
```svelte
<button class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary">
```
after:
```svelte
<button class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg">
```
For lines like `src/routes/admin/+page.svelte:302` and `:312` where `text-text-primary` is concatenated in a template literal (e.g., `'bg-action-secondary text-text-primary'`), the same swap applies inside the literal:
before:
```svelte
'bg-action-secondary text-text-primary'
```
after:
```svelte
'bg-action-secondary text-action-fg'
```
- [ ] **Step 3: Re-run the audit grep to confirm zero hits**
```bash
grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary"
```
Expected: empty output. If any site remains, edit it the same way.
- [ ] **Step 4: Confirm `text-text-primary` is still legitimately used on non-action surfaces**
```bash
grep -rln "text-text-primary" src/
```
Expected: a non-empty list (page text, surface card headings, form labels, etc.). These keep `text-text-primary` because their backgrounds DO flip with the theme — that's the whole point of the alias.
- [ ] **Step 5: Commit**
```bash
git add -u src/
git commit -m "refactor(web/m7-362): action-button labels use non-flipping text-action-fg"
```
---
## Task 4: Theme rune store
**Files:**
- Create: `web/src/lib/stores/theme.svelte.ts`
- [ ] **Step 1: Create the store file**
Write the following to `web/src/lib/stores/theme.svelte.ts`:
```ts
const STORAGE_KEY = 'minstrel:theme';
export type ThemePreference = 'dark' | 'light' | 'system';
export type ResolvedTheme = 'dark' | 'light';
function readPref(): ThemePreference {
if (typeof localStorage === 'undefined') return 'system';
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'dark' || v === 'light' || v === 'system' ? v : 'system';
} catch {
return 'system';
}
}
function systemResolved(): ResolvedTheme {
if (typeof window === 'undefined' || !window.matchMedia) return 'dark';
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemResolved() : pref;
}
function applyToDOM(resolved: ResolvedTheme): void {
if (typeof document === 'undefined') return;
document.documentElement.setAttribute('data-theme', resolved);
}
let _theme = $state<ThemePreference>(readPref());
let _resolved = $state<ResolvedTheme>(resolve(_theme));
export const theme = {
get value(): ThemePreference {
return _theme;
}
};
export const resolvedTheme = {
get value(): ResolvedTheme {
return _resolved;
}
};
export function setTheme(pref: ThemePreference): void {
_theme = pref;
_resolved = resolve(pref);
if (typeof localStorage !== 'undefined') {
try { localStorage.setItem(STORAGE_KEY, pref); } catch { /* ignore quota / unavailable */ }
}
applyToDOM(_resolved);
}
// Subscribe to OS-level changes only when in 'system' mode.
if (typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: light)');
mq.addEventListener('change', () => {
if (_theme === 'system') {
_resolved = systemResolved();
applyToDOM(_resolved);
}
});
}
```
This matches the project pattern in `web/src/lib/auth/store.svelte.ts`: module-scoped `$state`, exported `{ get value() }` for reads, exported function for mutations.
- [ ] **Step 2: Commit**
```bash
git add web/src/lib/stores/theme.svelte.ts
git commit -m "feat(web/m7-362): theme preference + resolved-theme rune store"
```
---
## Task 5: FOUC script + drop static dark class on `<html>`
**Files:**
- Modify: `web/src/app.html`
- [ ] **Step 1: Edit `web/src/app.html`**
Replace the entire file with:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
<script>
(function () {
try {
var t = localStorage.getItem('minstrel:theme') || 'system';
var resolved = t === 'system'
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: t;
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/>
%sveltekit.head%
</head>
<body class="bg-background text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
Two changes vs. the previous file:
1. `<html lang="en" class="dark">``<html lang="en">` (no static class).
2. Inline `<script>` block added immediately after `<title>` — must run synchronously before any CSS link so `data-theme` is set before paint.
- [ ] **Step 2: Commit**
```bash
git add web/src/app.html
git commit -m "feat(web/m7-362): pre-paint theme application via inline FOUC script"
```
---
## Task 6: Settings → Appearance card
**Files:**
- Modify: `web/src/routes/settings/+page.svelte`
- [ ] **Step 1: Add imports**
At the top of the `<script>` block in `web/src/routes/settings/+page.svelte`, after the existing imports, add:
```ts
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
```
- [ ] **Step 2: Add the Appearance section as the first section in the page**
Inside the outer `<div class="space-y-6">`, immediately after the `<h1>Settings</h1>`, insert the Appearance section BEFORE the existing ListenBrainz section:
```svelte
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Appearance</h2>
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Theme</legend>
<div class="flex gap-2">
{#each ['dark', 'light', 'system'] as opt (opt)}
<label
class="flex cursor-pointer items-center gap-2 rounded border border-border px-3 py-1.5 text-sm
{theme.value === opt ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
>
<input
type="radio"
name="theme"
value={opt}
checked={theme.value === opt}
onchange={() => setTheme(opt as ThemePreference)}
class="sr-only"
/>
<span class="capitalize">{opt}</span>
</label>
{/each}
</div>
</fieldset>
<p class="text-xs text-text-secondary">
System follows your device's light/dark preference.
</p>
</section>
```
The styling pattern matches the existing ListenBrainz section directly below: same `space-y-3 rounded border border-border bg-surface p-4` shell, same heading weight.
- [ ] **Step 3: Commit**
```bash
git add web/src/routes/settings/+page.svelte
git commit -m "feat(web/m7-362): Settings → Appearance card with dark/light/system selector"
```
---
## Task 7: Token generator unit test
**Files:**
- Create: `web/scripts/tokens-to-css.test.js`
- [ ] **Step 1: Create the test**
Write the following to `web/scripts/tokens-to-css.test.js`:
```js
import { describe, expect, test } from 'vitest';
import { emit } from './tokens-to-css.js';
const sample = {
colors: {
dark: { obsidian: '#000', parchment: '#FFF' },
light: { obsidian: '#FFF', parchment: '#000' },
flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' }
},
radii: { sm: '4px' },
fontStacks: {
display: "'Fraunces', serif",
body: "'Inter', sans-serif",
mono: "'JetBrains Mono', monospace"
}
};
describe('tokens-to-css emit()', () => {
test('emits :root block with dark and flat tokens', () => {
const css = emit(sample);
expect(css).toMatch(/:root \{[\s\S]*--fs-obsidian: #000;[\s\S]*--fs-parchment: #FFF;[\s\S]*--fs-accent: #4A6B5C;[\s\S]*--fs-on-action: #E8E4D8;[\s\S]*\}/);
});
test('emits [data-theme="light"] block with light tokens only', () => {
const css = emit(sample);
expect(css).toMatch(/\[data-theme="light"\] \{[\s\S]*--fs-obsidian: #FFF;[\s\S]*--fs-parchment: #000;[\s\S]*\}/);
});
test('flat tokens are not duplicated into the light block', () => {
const css = emit(sample);
const lightBlock = css.match(/\[data-theme="light"\] \{([\s\S]*?)\}/)[1];
expect(lightBlock).not.toContain('--fs-accent');
expect(lightBlock).not.toContain('--fs-on-action');
});
test('preserves radii and font stacks in :root', () => {
const css = emit(sample);
expect(css).toContain('--fs-radius-sm: 4px;');
expect(css).toContain("--fs-font-display: 'Fraunces', serif;");
});
});
```
- [ ] **Step 2: Confirm vitest picks up the new file**
The vitest config globs JS test files by default. Verify by reading `web/vitest.config.ts` — if `include` is restricted to `src/**`, this test won't run. If so, broaden the include glob to also match `scripts/**/*.test.js`:
```bash
cat web/vitest.config.ts
```
If editing the config is needed (only if scripts/ isn't in `test.include`), add the glob and commit it as part of this task.
- [ ] **Step 3: Commit**
```bash
git add web/scripts/tokens-to-css.test.js
# If vitest.config.ts was edited:
git add web/vitest.config.ts
git commit -m "test(web/m7-362): tokens-to-css generator unit test"
```
---
## Task 8: Theme store unit test
**Files:**
- Create: `web/src/lib/stores/theme.svelte.test.ts`
- [ ] **Step 1: Create the test**
Write the following to `web/src/lib/stores/theme.svelte.test.ts`:
```ts
import { describe, expect, test, beforeEach, vi } from 'vitest';
// Each test imports the store fresh via dynamic import + vi.resetModules,
// because the store reads localStorage and matchMedia at module load.
function setupLocalStorage(initial: Record<string, string> = {}) {
const store: Record<string, string> = { ...initial };
vi.stubGlobal('localStorage', {
getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => { store[k] = v; },
removeItem: (k: string) => { delete store[k]; }
});
return store;
}
function setupMatchMedia(prefersLight: boolean) {
const listeners: ((e: { matches: boolean }) => void)[] = [];
const mql = {
matches: prefersLight,
addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => listeners.push(cb),
removeEventListener: () => {}
};
vi.stubGlobal('window', {
matchMedia: () => mql
});
return { mql, listeners, fire: (matches: boolean) => listeners.forEach((cb) => cb({ matches })) };
}
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
// jsdom's document is left in place; reset its data-theme between tests.
if (typeof document !== 'undefined') {
document.documentElement.removeAttribute('data-theme');
}
});
describe('theme store', () => {
test('default preference is system; resolves via matchMedia', async () => {
setupLocalStorage();
setupMatchMedia(false); // prefers-dark
const mod = await import('./theme.svelte');
expect(mod.theme.value).toBe('system');
expect(mod.resolvedTheme.value).toBe('dark');
});
test('explicit preference from localStorage is honored', async () => {
setupLocalStorage({ 'minstrel:theme': 'light' });
setupMatchMedia(false);
const mod = await import('./theme.svelte');
expect(mod.theme.value).toBe('light');
expect(mod.resolvedTheme.value).toBe('light');
});
test('setTheme persists, updates resolved, sets data-theme attribute', async () => {
const store = setupLocalStorage();
setupMatchMedia(true);
const mod = await import('./theme.svelte');
mod.setTheme('dark');
expect(store['minstrel:theme']).toBe('dark');
expect(mod.theme.value).toBe('dark');
expect(mod.resolvedTheme.value).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
test('matchMedia change updates resolved while in system mode', async () => {
setupLocalStorage();
const mm = setupMatchMedia(false); // prefers-dark
const mod = await import('./theme.svelte');
expect(mod.resolvedTheme.value).toBe('dark');
mm.fire(true); // OS flipped to light
expect(mod.resolvedTheme.value).toBe('light');
});
test('matchMedia change does NOT update resolved when explicit preference set', async () => {
setupLocalStorage({ 'minstrel:theme': 'dark' });
const mm = setupMatchMedia(false);
const mod = await import('./theme.svelte');
expect(mod.resolvedTheme.value).toBe('dark');
mm.fire(true); // OS prefers light, but user explicitly chose dark
expect(mod.resolvedTheme.value).toBe('dark');
});
});
```
- [ ] **Step 2: Commit**
```bash
git add web/src/lib/stores/theme.svelte.test.ts
git commit -m "test(web/m7-362): theme store unit test (default/persist/matchMedia)"
```
---
## Task 9: Settings Appearance render test
**Files:**
- Create: `web/src/routes/settings/Appearance.test.ts`
- [ ] **Step 1: Create the test**
Write the following to `web/src/routes/settings/Appearance.test.ts`:
```ts
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
// Mock collaborators that the Settings page imports but aren't under test here.
vi.mock('$lib/api/listenbrainz', () => ({
createLBStatusQuery: () => ({
subscribe: () => () => {},
isPending: false,
isError: false,
data: { token_set: false, enabled: false }
}),
createTokenMutation: () => ({ subscribe: () => () => {}, mutate: vi.fn(), isPending: false }),
createEnabledMutation: () => ({ subscribe: () => () => {}, mutate: vi.fn(), isPending: false })
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
beforeEach(() => {
// Fresh localStorage + DOM theme attribute per test
globalThis.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
import SettingsPage from './+page.svelte';
describe('Settings → Appearance', () => {
test('renders three theme options', () => {
render(SettingsPage);
expect(screen.getByText(/^Appearance$/)).toBeInTheDocument();
expect(screen.getByLabelText('dark', { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText('light', { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText('system', { exact: false })).toBeInTheDocument();
});
test('clicking light writes localStorage and sets data-theme', async () => {
render(SettingsPage);
const lightRadio = screen.getByLabelText('light', { exact: false }) as HTMLInputElement;
await fireEvent.click(lightRadio);
expect(localStorage.getItem('minstrel:theme')).toBe('light');
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
test('clicking dark writes localStorage and sets data-theme', async () => {
render(SettingsPage);
const darkRadio = screen.getByLabelText('dark', { exact: false }) as HTMLInputElement;
await fireEvent.click(darkRadio);
expect(localStorage.getItem('minstrel:theme')).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
```
- [ ] **Step 2: Commit**
```bash
git add web/src/routes/settings/Appearance.test.ts
git commit -m "test(web/m7-362): Settings Appearance card render + selection test"
```
---
## Done criteria
After all 9 tasks have green commits on `dev`:
- `npm run tokens` regenerates `tokens.generated.css` with `:root` (dark + flat) and `[data-theme="light"]` blocks.
- `<html>` has no static `class="dark"`; `data-theme` is set by the inline FOUC script before paint.
- Settings → Appearance lets the user pick Dark / Light / System; choice persists across reloads via `localStorage['minstrel:theme']`.
- Action buttons (`bg-action-primary` / `secondary` / `destructive`) use `text-action-fg`, which doesn't flip with the theme.
- CI runs the three new test files and they pass.
- Manual eyeball pass (post-merge, not a task here): home, library, search, playlists, playlist detail, queue, history, settings, admin in both modes.
---
## Self-review (writing-plans)
**Spec coverage:**
- Token strategy → Task 1 ✓
- `--fs-on-action` token + Tailwind alias → Tasks 1, 2 ✓
- Action button label audit → Task 3 ✓
- Theme rune store → Task 4 ✓
- FOUC inline script → Task 5 ✓
- Settings Appearance card → Task 6 ✓
- Generator/store/UI tests → Tasks 79 ✓
- Edge cases 1 (action contrast) → Tasks 2, 3 ✓
- Edge cases 4 (cross-tab sync), 5 (SSR), 6 (error pages) — documented out-of-scope in spec, no tasks needed ✓
- Edge case 7 (localStorage unavailable) → Task 4 store implementation handles via try/catch + fallback default ✓
**Placeholders:** none. All hex values, file paths, code blocks, and grep patterns are concrete.
**Type consistency:** `ThemePreference` and `ResolvedTheme` types are defined in Task 4 and re-imported in Task 6. `setTheme(pref: ThemePreference)` signature matches between definition (Task 4) and call sites (Tasks 6, 8, 9). `theme.value` getter shape matches the auth store pattern referenced in Task 4.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,319 +0,0 @@
# Web UI Library Reads — Design
**Status:** approved (2026-04-20)
**Follows:** `2026-04-20-web-ui-scaffold-design.md`, `2026-04-20-web-ui-server-auth-foundation` (shipped)
**Precedes:** Plan 3 — stream + cover endpoints
## Goal
Add the read-only library surface to Minstrel's native JSON API at `/api/*`. Five endpoints: paginated artist list (two sort modes), artist detail, album detail, track detail, and unified search. This is the data the SvelteKit SPA needs to render browse and search views; streaming and cover art follow in Plan 3.
Scope explicitly excludes: stream, cover, write operations (favorites, play history), admin endpoints beyond what's already mounted.
## Routes
All gated by the existing `RequireUser` middleware inside `api.Mount`:
```
GET /api/artists?sort={alpha|newest}&limit=&offset=
GET /api/artists/{id}
GET /api/albums/{id}
GET /api/tracks/{id}
GET /api/search?q=&limit=&offset=
```
Path-style IDs (chi `{id}`) — matches REST norms and the SPA router's expectations. Pagination defaults: `limit=50`, `limit` clamped to `[1,200]`, `offset=0`, `offset` clamped to `>=0`. Out-of-range values silently clamp (match subsonic ergonomics); malformed values (non-numeric) return 400.
## Response Shapes
Two-tier Ref/Detail split mirrors the subsonic package's pattern.
### Refs
Lightweight, embedded in list and detail responses.
```go
type ArtistRef struct {
ID string `json:"id"` // UUID string
Name string `json:"name"`
AlbumCount int `json:"album_count"`
}
type AlbumRef struct {
ID string `json:"id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
Year int `json:"year,omitempty"`
TrackCount int `json:"track_count"`
DurationSec int `json:"duration_sec"`
CoverURL string `json:"cover_url"` // /api/albums/{id}/cover — lands in Plan 3
}
type TrackRef struct {
ID string `json:"id"`
Title string `json:"title"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
DurationSec int `json:"duration_sec"`
StreamURL string `json:"stream_url"` // /api/tracks/{id}/stream — Plan 3
}
```
### Details
Wrap a ref with nested children.
```go
type ArtistDetail struct {
ArtistRef
Albums []AlbumRef `json:"albums"`
}
type AlbumDetail struct {
AlbumRef
Tracks []TrackRef `json:"tracks"`
}
// Track detail is just TrackRef — parent names are already embedded.
```
### Pagination Envelope
Shared generic, defined once in `types.go`:
```go
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
```
Used by `/api/artists` and each facet of `/api/search`.
### Search Response
Three independent paged facets sharing one `limit`/`offset`:
```go
type SearchResponse struct {
Artists Page[ArtistRef] `json:"artists"`
Albums Page[AlbumRef] `json:"albums"`
Tracks Page[TrackRef] `json:"tracks"`
}
```
Shared limit/offset (vs subsonic's per-facet params) keeps the SPA simple: one set of pager controls per search page.
### JSON conventions
- UUIDs serialize as strings (not `pgtype.UUID`) via a `uuidToString` helper in `convert.go`.
- Empty collections always render as `[]`, never `null` — web clients reject null. Initialize slices explicitly before encoding.
- Field names: snake_case everywhere.
## Embedded URLs
Forward-reference URLs baked into responses now, even though Plan 3 implements the endpoints:
- `AlbumRef.CoverURL` = `/api/albums/{id}/cover`
- `TrackRef.StreamURL` = `/api/tracks/{id}/stream`
Built by helpers in `convert.go`:
```go
func coverURL(albumID pgtype.UUID) string { return "/api/albums/" + uuidToString(albumID) + "/cover" }
func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" }
```
Relative paths — SPA and Flutter client resolve against configured base URL. Responses stay host-agnostic (reverse-proxy friendly). Until Plan 3, these paths 404; the SPA won't wire `<audio src>` / `<img src>` until Plan 3 ships.
## SQL Queries
All SELECT-only. No migrations. Add to `internal/db/queries/`:
### artists.sql
```sql
-- name: ListArtistsAlpha :many
SELECT * FROM artists ORDER BY sort_name, name LIMIT $1 OFFSET $2;
-- name: ListArtistsNewest :many
SELECT * FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;
-- name: CountArtists :one
SELECT COUNT(*) FROM artists;
-- name: CountArtistsMatching :one
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
```
`ListArtists` (no LIMIT) stays — subsonic's `buildArtistIndexes` still needs it. Don't rewrite that call site; add paginated siblings.
### albums.sql
```sql
-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
```
`SearchAlbums` already exists; it just needed the count sibling.
### tracks.sql
```sql
-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
```
### Notes
- Secondary ordering (`created_at DESC, id DESC`) gives newest sort a stable tiebreaker — prevents pagination drift when two artists share a timestamp.
- Two-query pattern (list + count) is acceptable at M1 sizes (thousands of rows). Revisit with window functions if artists exceed ~100k.
Regenerate sqlc output (`go generate ./...` or the Makefile step) to refresh `internal/db/dbq/*.go`.
## Data Flow
### `GET /api/artists?sort=alpha`
1. Parse `limit`/`offset`/`sort` from query; clamp and default.
2. `q.ListArtistsAlpha` or `q.ListArtistsNewest` (pick by `sort`).
3. `q.CountArtists` for `total`.
4. For each artist, `q.ListAlbumsByArtist``album_count`. N+1, but at `limit=50` acceptable. Revisit with a join query if profiling flags it.
5. Build `Page[ArtistRef]`, JSON-encode.
### `GET /api/artists/{id}`
1. Parse `{id}` as UUID (400 on bad form).
2. `GetArtistByID` (404 on `pgx.ErrNoRows`).
3. `ListAlbumsByArtist(id)` → album list.
4. For each album, `CountTracksByAlbum` (consistent with subsonic; cheap).
5. Return `ArtistDetail` with embedded `[]AlbumRef`.
### `GET /api/albums/{id}`
1. Parse `{id}` as UUID (400).
2. `GetAlbumByID` (404).
3. `GetArtistByID(album.ArtistID)` for artist name.
4. `ListTracksByAlbum(id)` → ordered by disc/track number at the query level (existing behavior).
5. Return `AlbumDetail` with embedded `[]TrackRef`.
### `GET /api/tracks/{id}`
1. Parse `{id}` as UUID (400).
2. `GetTrackByID` (404).
3. `GetAlbumByID(track.AlbumID)` + `GetArtistByID(track.ArtistID)` for parent names.
4. Return `TrackRef` directly (no wrapping detail type — all fields already present).
### `GET /api/search?q=foo`
1. Parse `q` (empty → 400), `limit`, `offset`.
2. Serial: `SearchArtists` + `CountArtistsMatching``Page[ArtistRef]`.
3. Serial: `SearchAlbums` + `CountAlbumsMatching``Page[AlbumRef]` (resolve artist names inline).
4. Serial: `SearchTracks` + `CountTracksMatching``Page[TrackRef]` (resolve album + artist names inline).
5. Return `SearchResponse`.
### Artist-name resolution in album lists
Reuse the pattern from subsonic's `resolveArtistNames`: one query per distinct artist id in the page. For a 50-album page this is ≤50 but typically fewer (same artist repeated). Duplicate the pattern inline in `internal/api` rather than importing from subsonic — tiny helper, clean package boundary.
## Error Handling
Reuse existing `writeErr(w, code, slug, message)` from `internal/api/errors.go`. The SPA's auth code already parses the `{error:{code,message}}` envelope.
| Condition | HTTP | code slug |
|---|---|---|
| No cookie/bearer | 401 | `unauthorized` (from `RequireUser`) |
| Malformed UUID in path | 400 | `bad_request` |
| Row not found | 404 | `not_found` |
| Missing `q` on search | 400 | `bad_request` |
| `limit`/`offset` non-numeric | 400 | `bad_request` |
| DB / query failure | 500 | `server_error` (logged; message generic) |
Out-of-range `limit`/`offset` silently clamp (not 400). Matches subsonic's `clampInt` ergonomics and means the SPA can send whatever value the user picks without validation.
## File Structure
Files to create:
| File | Responsibility |
|---|---|
| `internal/api/library.go` | Artist/album/track list and detail handlers |
| `internal/api/search.go` | Unified search handler |
| `internal/api/convert.go` | uuid↔string, year-from-pgdate, URL builders |
| `internal/api/library_test.go` | Integration tests for library endpoints |
| `internal/api/search_test.go` | Integration tests for search |
| `internal/api/library_fixtures_test.go` | Shared seed helpers (`seedArtist`, `seedAlbum`, `seedTrack`) |
Files to modify:
| File | Change |
|---|---|
| `internal/api/api.go` | Register new routes inside the `authed` group |
| `internal/api/types.go` | Add `Page[T]`, refs, details, `SearchResponse` |
| `internal/db/queries/artists.sql` | Add list + count queries |
| `internal/db/queries/albums.sql` | Add `CountAlbumsMatching` |
| `internal/db/queries/tracks.sql` | Add `CountTracksMatching` |
Regenerated: `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go`.
Approach A was chosen during brainstorming: fresh handlers in `internal/api`, tiny helpers duplicated inline. No shared package with subsonic. The two packages shape responses differently (subsonic wraps in `subsonic-response`; api returns bare JSON envelopes), and the duplicated helpers are ~5 lines each. Revisit extraction during Plan 3 if stream/cover surfaces real duplication.
## Testing
Integration-only, following the `auth_test.go` pattern:
- `testHandlers(t)` helper already exists (spins handlers against `MINSTREL_TEST_DATABASE_URL`, TRUNCATE between tests).
- `library_fixtures_test.go` adds `seedArtist`, `seedAlbum`, `seedTrack` shared across library + search tests.
- No unit tests for `convert.go` helpers — trivial, covered through handler tests.
### Key test cases per endpoint
**`GET /api/artists`:**
- default sort = alpha, paged correctly
- `sort=newest``created_at DESC` order
- `sort=garbage` → 400
- `limit`/`offset` respected, `total` reflects full count
- embedded `album_count` matches fixture
**`GET /api/artists/{id}`:**
- happy path with nested albums
- bad UUID → 400
- unknown UUID → 404
**`GET /api/albums/{id}`:**
- happy path with tracks + artist name + `cover_url` embedded
- tracks ordered by disc/track number
- 400 / 404 parity
**`GET /api/tracks/{id}`:**
- happy path with parent names + `stream_url` embedded
- 400 / 404
**`GET /api/search?q=foo`:**
- three paged facets populated
- empty `q` → 400
- no matches → three `[]` facets, totals = 0
- shared `limit`/`offset` applied per facet
- each facet's `total` reflects full match count
### Explicitly out of scope for tests
- Concurrency / race conditions
- Large-result performance (N+1 tolerance, profiling)
- Stream/cover endpoint behavior (Plan 3)
## Out of Scope (Plan 3 or later)
- `GET /api/tracks/{id}/stream` — audio delivery with range requests
- `GET /api/albums/{id}/cover` — cover art delivery
- Favorites, play history, playlists
- Admin endpoints (scan, user management)
- Multi-library support
- Subsonic client shim (separate track)
@@ -1,268 +0,0 @@
# Web UI Scaffold — Design
**Status:** Approved (brainstorm), ready for implementation plan
**Date:** 2026-04-20
**Milestone:** M1.5 (new) — scaffold for the web UI that M2M5 layer features onto
## Purpose
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2M5 in step with the server.
## Non-goals
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
- Light-theme support (dark theme only for first cut; light mode follows).
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
- Reusing the `/rest/*` Subsonic surface from the SPA.
## Stack
- **Framework:** SvelteKit + TypeScript + Vite.
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
- **State:** Svelte stores. No external state library.
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
### Why Svelte over React/Solid
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
## Packaging
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
- **Dockerfile:** multi-stage.
1. `FROM node:<lts> AS web``npm ci`, `npm run build`, produces `web/build/`.
2. `FROM golang:<ver> AS go``go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
## Dev workflow
- Two processes during development:
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
## API surface
### Principle
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
### First-cut endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
| GET | `/api/artists` | Paginated artist list with sort keys. |
| GET | `/api/artists/{id}` | Artist with albums array. |
| GET | `/api/albums/{id}` | Album with tracks array. |
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
### Implementation approach
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
### Deferred endpoints (land with their backend milestone)
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
- M5: Lidarr proxy + quarantine endpoints.
## Authentication
### Login flow
1. SPA `POST /api/auth/login` with `{username, password}`.
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
### Middleware
- `internal/auth.RequireUser` middleware resolves the caller in this order:
1. `Cookie: session=<token>` → sessions table lookup.
2. `Authorization: Bearer <token>` → sessions table lookup.
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
- Unauthenticated requests to `/api/*``401 {error: {code: "unauthenticated", message: "..."}}`.
### Sessions table
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
### Why this design
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
## Shell layout
### Structure
```
+-------------------------------------------------------------+
| SIDEBAR | MAIN CONTENT |
| | |
| Minstrel | (route content — artists list, |
| | album grid, search results, |
| ▸ Home | artist detail, settings, etc.) |
| ▸ Library | |
| ▸ Search | |
| ▸ Recent | |
| ▸ Settings | |
| | |
| — Admin — | |
| ▸ Users | |
| ▸ Scan | |
| ▸ Quarantine | |
| | |
+-------------------------------------------------------------+
| PLAYER BAR: art · title / artist · controls · scrub · time |
+-------------------------------------------------------------+
```
### Components
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
### Player state (Svelte store)
```typescript
interface PlayerState {
queue: Track[];
queueIndex: number;
isPlaying: boolean;
currentTime: number; // seconds
duration: number; // seconds
volume: number; // 0..1
}
```
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
- The store is the only place that writes player state; components read via `$player` subscriptions.
### Theme
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
### Keyboard shortcuts
- `Space` — play/pause (blocked when focus is in an input).
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
- `/` — focus search input.
## First-cut feature scope
- Login / logout.
- Artist list (sortable, searchable within-list).
- Artist detail → albums grid.
- Album detail → track list with inline play buttons.
- Global search: `?q=` against tracks + albums + artists, results grouped.
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
## Deferred
Tracked in Fable milestones 2730 (M2M5). Each milestone adds the corresponding UI alongside its backend work.
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
- **M3:** Smart-shuffle entry points, contextual-like controls.
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
## Testing
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
## Repo structure
```
minstrel/
├── cmd/minstrel/ # Go binary (unchanged)
├── internal/
│ ├── api/ # NEW: /api/* handlers + shared types
│ │ ├── auth.go
│ │ ├── library.go
│ │ ├── search.go
│ │ ├── stream.go
│ │ ├── types.go
│ │ └── *_test.go
│ ├── auth/ # existing; add session token logic
│ ├── subsonic/ # unchanged
│ ├── library/ # unchanged
│ ├── db/ # + migration for sessions
│ └── server/ # Router() wires /api/* alongside /rest/*
├── web/ # NEW: SvelteKit project
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # fetch wrapper
│ │ │ ├── stores/
│ │ │ │ ├── player.ts
│ │ │ │ └── auth.ts
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
│ │ ├── routes/
│ │ │ ├── +layout.svelte
│ │ │ ├── +page.svelte # Home
│ │ │ ├── login/+page.svelte
│ │ │ ├── artists/+page.svelte
│ │ │ ├── artists/[id]/+page.svelte
│ │ │ ├── albums/[id]/+page.svelte
│ │ │ ├── search/+page.svelte
│ │ │ └── settings/+page.svelte
│ │ └── app.html
│ ├── static/
│ ├── svelte.config.js # adapter-static + fallback
│ ├── vite.config.ts # /api + /rest proxy for dev
│ ├── package.json
│ └── tsconfig.json
├── docs/
├── Dockerfile # multi-stage: node → go → slim
├── docker-compose.yml
└── ...
```
## Open questions resolved
- **Framework:** Svelte (B in brainstorm).
- **Packaging:** Single binary with embedded static (A in brainstorm).
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
- **API shape:** New `/api/*` surface (B in brainstorm).
- **Scope:** Rich target, scaffold first, features woven through M2M5 (C in brainstorm with sequencing plan).
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
## Risks / decisions to revisit during implementation
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.
@@ -1,177 +0,0 @@
# Web UI Media Endpoints — Design Spec
**Date:** 2026-04-21
**Status:** Design approved
**Follows:** [Web UI Library Reads](2026-04-20-web-ui-library-reads-design.md) — this spec implements the `cover_url` and `stream_url` forward references shipped in Plan 2.
## Goal
Ship the two byte-serving endpoints the web SPA (and later the Flutter client) need to render album art and play tracks. When this lands, every `<img src>` and `<audio src>` tag that Plan 2's DTOs produced will resolve.
## Non-goals
Explicit YAGNI — documented here so they don't drift back in during implementation:
- No cover resizing (`?size=` param). Serving one size matches subsonic's current behavior; re-sizing is a future optimization when the SPA shows it's needed.
- No audio transcoding (`?format=`, `?maxBitRate=`). Raw bytes only.
- No `/download` attachment-mode variant. `/stream` is enough for the SPA; a separate download endpoint can land later if a concrete caller needs it.
- No scanner changes to populate `albums.cover_art_path`. The sidecar fallback covers the common real-world case today; improving scanner coverage is its own plan.
## Architecture
Two endpoints, both under `RequireUser` in the existing `/api` Mount:
- `GET /api/albums/{id}/cover` → album cover image
- `GET /api/tracks/{id}/stream` → raw audio bytes, Range-capable
**Package isolation.** `internal/subsonic/stream.go` already implements the same two behaviors for `/rest/*`. Per project direction, subsonic is long-term legacy and stays frozen. The protocol-agnostic helpers (mime tables, sidecar lookup, `http.ServeContent` glue) are duplicated into `internal/api/media.go` rather than extracted into a shared package. The only shared layer is `internal/db/dbq` (already) and `http.ServeContent` from stdlib.
**Auth.** No new middleware. `auth.RequireUser` (in `internal/auth/session.go`) already accepts both the `minstrel_session` cookie and `Authorization: Bearer <token>`. Browser `<img>` and `<audio>` tags send cookies automatically; the Flutter client will carry the bearer token.
**Range / ETag / If-Modified-Since.** Delegated to `http.ServeContent`, which reads headers from the request and writes the correct 206/304/200 response plus `Content-Range` / `ETag` / `Last-Modified`. No hand-rolled Range parsing.
## Routes
| Method | Path | Auth | Success response |
|---|---|---|---|
| GET | `/api/albums/{id}/cover` | `RequireUser` | 200 (or 304) with image bytes; `Content-Type` inferred from file extension (`image/jpeg`, `image/png`, `image/webp`, `image/gif`) |
| GET | `/api/tracks/{id}/stream` | `RequireUser` | 200 / 206 / 304 with audio bytes; `Content-Type` from `track.file_format` (e.g. `audio/mpeg`, `audio/flac`, `audio/ogg`); `Accept-Ranges: bytes`; `Content-Length` set by `http.ServeContent` |
Both routes register inside the existing `authed` chi group in `internal/api/api.go` (alongside the Plan 2 library routes). Production `Mount` wires them; the test-only `newLibraryRouter` helper is extended to include them so integration tests can exercise the routes end-to-end.
## Data flow
### `GET /api/albums/{id}/cover`
1. Parse `{id}` via `parseUUID` (the helper from `internal/api/convert.go`). Malformed UUID → 400 `bad_request` "invalid album id".
2. `q.GetAlbumByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "album not found". Other err → 500 `server_error`.
3. `resolveAlbumCoverPath(ctx, q, album)`:
- If `album.CoverArtPath != nil` and the file stats successfully, return that path.
- Otherwise, list tracks for the album (`q.ListTracksByAlbum`, which orders by disc/track number). For the first track in that ordered result, look in its directory for `cover.jpg`, `cover.jpeg`, `cover.png`, `folder.jpg`, `folder.jpeg`, `folder.png` in that order. Return the first file that exists; else return `""`.
4. Empty path return → 404 `not_found` "cover not found".
5. `os.Open` + `f.Stat`. File vanished since resolve → 404 `not_found` "cover not found". Stat err → 500.
6. Set `Content-Type` from `imageContentType(path)` (file-extension-based mapping). Call `http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)`.
### `GET /api/tracks/{id}/stream`
1. Parse `{id}`. Malformed UUID → 400 `bad_request` "invalid track id".
2. `q.GetTrackByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "track not found". Other err → 500.
3. `os.Open(track.FilePath)`. File vanished → 404 `not_found` "track file not found". Other err → 500.
4. `f.Stat`. Err → 500.
5. Set `Content-Type` from `audioContentType(track.FileFormat)`. Set `Accept-Ranges: bytes`. Call `http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)`.
## Error shape
All errors use the existing `writeErr` helper → `{"error":{"code":"...","message":"..."}}`, matching the rest of `/api/*`. Codes reused from Plan 2: `bad_request`, `not_found`, `server_error`.
| Condition | Status | Code | Message |
|---|---|---|---|
| Malformed UUID in path | 400 | `bad_request` | `invalid album id` / `invalid track id` |
| Unknown album / track | 404 | `not_found` | `album not found` / `track not found` |
| Album has no resolvable cover | 404 | `not_found` | `cover not found` |
| Track file missing on disk | 404 | `not_found` | `track file not found` |
| DB lookup failure | 500 | `server_error` | `server error` |
| `os.Stat` failure on known path | 500 | `server_error` | `server error` |
## Components
Single new file: `internal/api/media.go`. Contains both handlers and their duplicated helpers.
```
handlers
handleGetCover(w, r)
handleGetStream(w, r)
free functions (package-private)
resolveAlbumCoverPath(ctx, q, album) string
findSidecarCover(trackDir string) string
audioContentType(format string) string
imageContentType(path string) string
```
`audioContentType` table (mirrors subsonic's `contentTypeForFormat`, duplicated verbatim):
| file_format | Content-Type |
|---|---|
| `mp3` | `audio/mpeg` |
| `flac` | `audio/flac` |
| `ogg` | `audio/ogg` |
| `opus` | `audio/ogg` |
| `m4a` | `audio/mp4` |
| `aac` | `audio/aac` |
| `wav` | `audio/wav` |
| *(other)* | `application/octet-stream` |
`imageContentType` table:
| extension (lowercase) | Content-Type |
|---|---|
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.png` | `image/png` |
| `.webp` | `image/webp` |
| `.gif` | `image/gif` |
| *(other)* | `application/octet-stream` |
Sidecar lookup order (first match wins):
```
cover.jpg, cover.jpeg, cover.png,
folder.jpg, folder.jpeg, folder.png
```
## Testing
Integration tests in `internal/api/media_test.go`. They reuse the existing `testHandlers(t)`, `truncateLibrary(t, pool)`, `seedArtist`, `seedAlbum`, `seedTrack` helpers from `library_fixtures_test.go`, and add a new helper:
- `seedTrackWithFile(t, pool, albumID, artistID, title string, fileBody []byte, ext string) (dbq.Track, string)` — writes `fileBody` into `t.TempDir()` with the given extension, inserts a Track row whose `file_path` points at it, returns the track and the dir.
### Cover tests
- **Happy path (sidecar):** seed album + track, drop a known-bytes `cover.jpg` in the track's dir. GET cover → 200, `Content-Type: image/jpeg`, body equals the file bytes.
- **Happy path (explicit `cover_art_path`):** seed album with `cover_art_path` pointing at a separate image file (no sidecar). GET → 200 with the right file's bytes.
- **Explicit path missing on disk falls through to sidecar:** set `cover_art_path` to a non-existent path, drop a sidecar. GET → 200 with the sidecar bytes.
- **No art anywhere:** seed album + track, no sidecar, no `cover_art_path`. GET → 404 `not_found` "cover not found".
- **Bad UUID:** `GET /api/albums/not-a-uuid/cover` → 400 `bad_request` "invalid album id".
- **Unknown album:** `GET /api/albums/<random UUID>/cover` → 404 `not_found` "album not found".
- **Content-Type per extension:** table-driven test invoking `imageContentType` on `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, and a bogus extension.
### Stream tests
- **Happy path:** seed track with a known-bytes payload (32 KB of random-but-fixed bytes, `.mp3` extension, `file_format="mp3"`). GET → 200, `Content-Type: audio/mpeg`, `Accept-Ranges: bytes`, body equals the file bytes, `Content-Length` matches.
- **Range request:** `Range: bytes=0-99` → 206 Partial Content, body is exactly the first 100 bytes, `Content-Range: bytes 0-99/32768`.
- **Range at end:** `Range: bytes=32000-` → 206, body is the tail, `Content-Range: bytes 32000-32767/32768`.
- **If-Modified-Since pass-through:** second GET with `If-Modified-Since` set to the file's mod time → 304.
- **Bad UUID:** `GET /api/tracks/not-a-uuid/stream` → 400 `bad_request` "invalid track id".
- **Unknown track:** random UUID → 404 `not_found` "track not found".
- **Vanished file:** seed track pointing at `filepath.Join(tempDir, "does-not-exist.mp3")` → 404 `not_found` "track file not found".
- **Content-Type per format:** table-driven test invoking `audioContentType` on all seven known formats plus `"unknown"`.
### Route registration regression
Extend `TestRoutesRegisteredInMount` (from Plan 2's Task 11) to also assert the two new paths return 401 (not 404) unauthenticated:
```
"/api/albums/00000000-0000-0000-0000-000000000001/cover",
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
```
## File structure
| File | Change | Purpose |
|---|---|---|
| `internal/api/media.go` | Create | Both handlers + locally-duplicated helpers |
| `internal/api/media_test.go` | Create | Integration tests for both endpoints, unit tests for mime helpers |
| `internal/api/library_fixtures_test.go` | Modify | Add `seedTrackWithFile` helper |
| `internal/api/api.go` | Modify | Register both new routes inside the `authed` group in `Mount` |
| `internal/api/library_test.go` | Modify | Extend `TestRoutesRegisteredInMount` to include the two new paths; extend the `newLibraryRouter` helper to register the media routes so integration tests can exercise them through the same test harness |
No migrations. No new sqlc queries (existing `GetAlbumByID`, `GetTrackByID`, `ListTracksByAlbum` are sufficient). No package-level dependencies beyond the stdlib and what `/api` already imports.
## Success criteria
- Both endpoints reachable through the production `Mount` under `RequireUser`.
- Browser `<img src="/api/albums/{id}/cover">` renders the art (or triggers `onerror` cleanly on 404).
- Browser `<audio src="/api/tracks/{id}/stream">` plays and can seek (verifies Range).
- Integration test suite green, no flakes.
- Manual smoke: log in, curl each endpoint, verify returns + Range behavior.
- PR merges to `main` via the existing `dev``main` flow.
@@ -1,256 +0,0 @@
# Web UI Auth — Design Spec
**Date:** 2026-04-22
**Status:** Design approved
**Follows:** [Web UI Scaffold](2026-04-20-web-ui-scaffold-design.md) — this spec is the first SPA feature on top of the scaffold.
## Goal
Let a browser visitor sign in with a username and password, reach a protected app shell, and sign out again. After this lands, every subsequent sub-plan (library views, search, player) builds on top of a known-authenticated `user` store, a persistent Shell component, and a typed HTTP client.
## Non-goals
Explicit YAGNI — kept out of this plan so the scope stays tight:
- No self-service sign-up or password reset. Users are admin-provisioned; a forgotten password is a DBA problem for now.
- No "remember me" / persistent-session checkbox. The server cookie already has a 30-day `MaxAge`; every login is long-lived.
- No OAuth / OIDC / third-party sign-in. Deferred (see memory — OIDC is pushed to post-v1).
- No multi-tab logout propagation via `BroadcastChannel`. If you log out in one tab, the other tab will discover it on the next API call (401 → silent logout). Good enough.
- No rate-limiting UX on the login form. The backend already 401s on bad creds; brute-force mitigation is a server-side concern tracked elsewhere.
## Architecture
Three layers stacked under the SvelteKit app:
1. **Transport (`lib/api/`).** `apiFetch(path, init)` hits the relative `/api/*` origin, sets `Content-Type: application/json`, parses the JSON response, and throws a typed `ApiError{code, message, status}` on any non-2xx. A thin facade `api.get<T>`, `api.post<T>`, `api.del` wraps it. 401 responses trigger `auth.logout({silent: true})` before the error propagates.
2. **Auth state (`lib/auth/`).** A Svelte 5 rune-based store — `let _user = $state<User|null>(null)` plus helpers `bootstrap()`, `login(u, p)`, `logout()`. The store is the single synchronous source of truth for "is the current user authenticated, and if so who."
3. **Query layer (`lib/query/`).** TanStack Query's Svelte 5 adapter. A `QueryClient` is instantiated once in the root layout and installed via `<QueryClientProvider>`. Components consume data with `createQuery({queryKey, queryFn: () => api.get<T>(path)})`. `auth.logout()` calls `queryClient.clear()` so stale data doesn't leak to the next user.
**Bootstrap timing is load-time, not render-time.** The root `+layout.ts` `load()` awaits `auth.bootstrap()` before SvelteKit renders, so `+layout.svelte` sees the store already populated. There is no "flash of login screen" on refresh.
## File structure
New files under `web/`:
```
src/
├── lib/
│ ├── api/
│ │ ├── client.ts # apiFetch + api.{get,post,del} + ApiError + User/LoginResponse types
│ │ └── client.test.ts
│ ├── auth/
│ │ ├── store.svelte.ts # $user rune, bootstrap/login/logout
│ │ └── store.test.ts
│ ├── query/
│ │ └── client.ts # export const queryClient = new QueryClient({...})
│ └── components/
│ ├── Shell.svelte # header + sidebar + <main> slot
│ └── Shell.test.ts
├── routes/
│ ├── +layout.ts # load() → await auth.bootstrap()
│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare <slot/>
│ ├── +page.svelte # placeholder "Library" — replaced by the library plan
│ ├── login/
│ │ ├── +page.svelte # LoginForm
│ │ └── +page.test.ts
│ ├── search/+page.svelte # "Coming soon" placeholder
│ └── playlists/+page.svelte # "Coming soon" placeholder
└── app.d.ts # (untouched)
```
Files that change:
- `web/package.json` — add `@tanstack/svelte-query`, `@testing-library/svelte`, `@testing-library/jest-dom`.
- `web/vitest.config.ts` — include `./vitest.setup.ts` to install jest-dom matchers.
- `web/src/routes/+page.svelte` — replace the scaffold placeholder with a bare "Library" placeholder that survives into the library plan.
## Routes
| Path | Guard | Component |
|---|---|---|
| `/login` | public; if already authenticated, navigate to `/` | `routes/login/+page.svelte` |
| `/` | guarded | Shell + `routes/+page.svelte` (Library placeholder) |
| `/search` | guarded | Shell + placeholder |
| `/playlists` | guarded | Shell + placeholder |
| (anything else) | guarded | Shell + whatever the URL resolves to, or 404 |
The guard lives in `+layout.svelte` (not per-page `+page.ts`). On every render, it checks `user.value`. If `null` and path is not `/login`, it calls `goto('/login?returnTo=' + encodeURIComponent(path), {replaceState: true})`. If non-null and path is `/login`, it calls `goto('/', {replaceState: true})`. Otherwise it renders `<Shell><slot/></Shell>` (authed) or `<slot/>` (bare `/login`).
## `apiFetch` contract
```ts
// lib/api/client.ts
export type ApiError = { code: string; message: string; status: number };
export type User = { id: string; username: string; is_admin: boolean };
export type LoginResponse = { token: string; user: User };
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
});
const body = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) {
if (res.status === 401) {
// Avoid a circular import: lazy-require the auth store.
const { logout } = await import('$lib/auth/store.svelte');
logout({ silent: true });
}
const err = (body && body.error) || { code: 'unknown', message: res.statusText };
throw { ...err, status: res.status } as ApiError;
}
return body;
}
export const api = {
get: <T>(p: string) => apiFetch(p) as Promise<T>,
post: <T>(p: string, b: unknown) =>
apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise<T>,
del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise<void>,
};
```
Notes:
- `credentials: 'same-origin'` is correct for both the Vite proxy (dev) and the embedded production serve — both are same-origin. Cross-origin would need `'include'` plus a CORS policy, which the backend does not currently implement.
- `body.error` matches the backend's error envelope: `{"error":{"code":"...","message":"..."}}` (see `internal/api/errors.go`). Non-JSON error bodies degrade to `{code:'unknown', message: statusText}` — pragmatic.
- Lazy-importing `$lib/auth/store.svelte` inside the 401 branch avoids a circular import (`auth/store` imports `api`).
## `auth` store contract
```ts
// lib/auth/store.svelte.ts
import { api, type User, type LoginResponse } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
let _user = $state<User | null>(null);
export const user = { get value() { return _user; } };
export async function bootstrap(): Promise<void> {
try { _user = await api.get<User>('/api/me'); }
catch { _user = null; }
}
export async function login(username: string, password: string): Promise<void> {
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
_user = res.user;
}
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
if (!opts.silent) {
try { await api.post('/api/auth/logout', {}); } catch { /* best effort */ }
}
_user = null;
queryClient.clear();
}
```
The `silent: true` path is used only by `apiFetch`'s 401 interceptor. Without it, a 401 response would trigger a logout POST that itself 401s — pointless round-trip. Everywhere else (the Shell's "Log out" button), `logout()` is called without flags and does the full round-trip.
## Login page
**Layout.** Centered card on the dark palette. Minstrel wordmark above the card. The card contains the form: label+input for username, label+input for password, a full-width "Sign in" button, and a slot for error text below the button.
**Behavior.**
1. Username and password are bound to local `$state` with `$state('')`.
2. On submit (button click or Enter in either field):
- Set `pending = true`. Disable the button and set `aria-busy="true"`.
- `await login(username, password)`.
- On success: read `returnTo` from `$page.url.searchParams`; validate it matches `/^\/(?!\/)/` (starts with exactly one `/`, not `//`, which would be a protocol-relative URL) AND does not start with `/login`; `goto(validatedReturnTo || '/', {replaceState: true})`.
- On `ApiError{code: 'invalid_credentials', status: 401}`: set `error = 'Invalid username or password.'`, clear password.
- On any other error: set `error = 'Sign-in unavailable. Try again in a moment.'`, leave fields intact.
- Finally: `pending = false`.
3. Enter in either input triggers form `submit`.
4. The error region has `role="alert"` so screen readers announce it on change.
## Shell component
**Structure.**
```
<div class="h-screen grid grid-cols-[auto_1fr] grid-rows-[auto_1fr]">
<header class="col-span-2 ...">
Minstrel [username ▾] (dropdown has "Log out")
</header>
<nav class="hidden md:block ...">
<a href="/">Library</a>
<a href="/search">Search</a>
<a href="/playlists">Playlists</a>
</nav>
<main class="overflow-y-auto">{@render children()}</main>
</div>
```
- Sidebar hides below `md:`. (Mobile gets a hamburger button in a later plan — not this one.)
- Active nav item uses `aria-current="page"` so the active-styling CSS selector is semantic.
- The user-menu dropdown is a minimal disclosure: click to toggle, outside-click closes, Escape closes. Inside it is a single `<button>Log out</button>` that calls `auth.logout()` then `goto('/login', {replaceState: true})`.
## Error handling summary
| Source | Shape | User sees |
|---|---|---|
| Login, bad creds | `ApiError{code:'invalid_credentials', status:401}` | Inline text under form: "Invalid username or password." |
| Login, server 500 / network | `ApiError{status:500}` or fetch rejection | Inline text: "Sign-in unavailable. Try again in a moment." |
| 401 mid-session on any API call | `ApiError{code:'unauthorized', status:401}` | Store clears; guard navigates to `/login?returnTo=<current>`; no visible error |
| Non-2xx elsewhere | `ApiError` thrown into TanStack Query | TanStack Query `error` state; each consuming component renders its own UI (out of scope for this plan — the library plan handles it) |
## Testing
**Unit (Vitest, jsdom).**
- `api/client.test.ts``fetch` stubbed via `vi.stubGlobal`.
- `api.get` resolves with parsed JSON on 200.
- `api.post` serializes body and sets `Content-Type`.
- Non-2xx throws `ApiError` with `code`, `message`, `status`.
- 401 calls `auth.logout({silent: true})` — spy on the dynamically-imported `logout` via module mocking.
- 204 resolves to `null` without calling `.json()`.
- JSON parse failure on an error body degrades to `{code:'unknown', message: statusText}`.
- `auth/store.test.ts``api` module mocked.
- `bootstrap()` sets `user` on success; leaves `null` on rejection.
- `login()` stores the returned `user`.
- `logout()` calls `api.post('/api/auth/logout', {})`, clears `user`, calls `queryClient.clear()`.
- `logout({silent: true})` does NOT call `api.post`; still clears store and cache.
- `components/Shell.test.ts` — render with a non-null `user`.
- Header shows `user.username`.
- Sidebar has exactly three `<a>` elements to `/`, `/search`, `/playlists`.
- When `$page.url.pathname === '/'`, the Library link has `aria-current="page"`.
- Clicking the user menu reveals "Log out"; outside-click hides it.
**Component test — `routes/login/+page.test.ts`** (using `@testing-library/svelte`).
- Renders two inputs + submit button.
- Invalid creds: stub `api.post` to reject with `{code:'invalid_credentials', status:401}`; submit; assert inline "Invalid username or password." is shown; password input is empty; username input is preserved.
- Server error: stub to reject with `{status:500}`; assert generic error message; both inputs preserved.
- Success: stub to resolve; set `?returnTo=/artists/abc` in `$page.url`; assert `goto` called with `/artists/abc` and `{replaceState: true}`.
- Invalid `returnTo` variants: `http://evil.com`, `//evil.com`, `/login` → each should fall back to `goto('/')`, never the original value.
- Loading: during a pending promise, `aria-busy="true"` on the button and button is disabled.
**Manual integration (Plan Task 8).**
- `npm run dev` with backend running on `:4533`. Seed a user via the existing auth plan's flow.
- Visit `/artists/abc` → redirected to `/login?returnTo=%2Fartists%2Fabc`.
- Sign in → land on `/artists/abc` (which 404s from SPA fallback — that's expected; library plan fixes it).
- Hard-refresh `/` while authenticated → Shell renders immediately, no login flash.
- Click user menu → "Log out" → redirected to `/login`. Visit `/` → redirected back to `/login`.
- In a second tab, also authenticated: open dev tools, delete the `minstrel_session` cookie manually, navigate in the SPA → auto-redirected to `/login`.
## Dependencies added
| Package | Kind | Why |
|---|---|---|
| `@tanstack/svelte-query` | runtime | Data-layer caching/invalidation per design section 1. |
| `@testing-library/svelte` | dev | Component test rendering. |
| `@testing-library/jest-dom` | dev | `toBeInTheDocument`, `toHaveAttribute`, etc. |
Versions pinned to latest Svelte-5-compatible majors at plan-writing time.
## Success criteria
- A user can sign in with valid creds, see the Shell, navigate to `/`, `/search`, `/playlists`, and log out.
- Deep link `/artists/<any>` while signed out lands on `/login?returnTo=<path>`; after login, lands on `<path>`.
- Refreshing any guarded page while authenticated renders immediately with no login flash.
- A 401 on any API call silently clears auth and redirects to `/login`.
- All unit and component tests pass in CI (`npm test` in the `web/` directory).
- No backend changes required; the plan is pure frontend.
@@ -1,320 +0,0 @@
# Web UI Library Views — Design Spec
**Date:** 2026-04-23
**Status:** Design approved
**Follows:** [Web UI Auth](2026-04-22-web-ui-auth-design.md) — this spec is the first data-consuming feature on top of the auth foundation.
## Goal
Let an authenticated user browse the server's music library — artists, artist detail with albums, album detail with tracks — entirely in the SPA. After this lands, `/` is a real library index, deep links like `/artists/abc-123` resolve to pages with data, and the `cover_url`/`stream_url` forward references emitted by the JSON API finally have consumers.
## Non-goals
Explicit YAGNI — documented so they don't drift back in:
- No track detail page. Track metadata is already visible inline in the album view; a dedicated `/tracks/:id` page would show identical fields with no action affordances (the player plan adds playback; that's where click-to-play belongs).
- No search UI. `/search` keeps the auth plan's "Coming soon" placeholder. Search deserves its own sub-plan — different UX concerns (debouncing, faceted results, empty states).
- No playback. All track rows are non-interactive; `<audio>` never mounts. The player sub-plan wires everything.
- No infinite scroll, virtualization, or hover-play previews. "Load more" is enough; we upgrade when the library actually feels slow.
- No "Recently Added" / recommendation surfaces. We don't have play signals yet.
- No theme switcher. Dark-only.
- No artist images / avatars from MusicBrainz or similar. Initial-letter circle is the placeholder forever (or until we add something better in a dedicated plan).
## Architecture
**Three routes, three queries.** Route-per-view matches every widely-used music browser (Navidrome, Plex, Spotify web):
| Route | Page responsibility | Query |
|---|---|---|
| `/` | Artists list with sort + pagination | `createInfiniteQuery(['artists', {sort}])` |
| `/artists/:id` | Artist detail — name header, albums grid | `createQuery(['artist', id])` |
| `/albums/:id` | Album detail — hero with cover, track list | `createQuery(['album', id])` |
Modal-detail layouts (single-page with overlays) and three-pane split views were considered and rejected — they either break deep-linking or fight the existing sidebar Shell.
**Data layer.** TanStack Query owns all fetches and caching. A small `web/src/lib/api/queries.ts` module owns the query-key convention and exports `createArtistsQuery`, `createArtistQuery`, `createAlbumQuery` helpers so every page imports the same plumbing.
Query-key convention:
```ts
['artists', { sort: 'alpha' | 'newest' }] // infinite query; pageParam = offset
['artist', id] // single artist + nested albums
['album', id] // single album + nested tracks
```
**Component decomposition.** Each repeating visual unit is its own small Svelte component. Pages stay short; components stay testable in isolation.
- `ArtistRow` — one row in the artists list. 40×40 avatar (first letter), name, album count, chevron. Whole row is an `<a href="/artists/:id">`.
- `AlbumCard` — one card in the albums grid. Cover image, title, year. `<a href="/albums/:id">`.
- `TrackRow` — one row in the album track table. Non-interactive until the player plan.
- `LibrarySkeleton` — shimmer placeholder with `variant="list" | "grid" | "album"` and `count` props.
- `ApiErrorBanner` — retry-capable error card shared by all three pages.
**Cache lifecycle.** `queryClient.clear()` already runs on logout (auth plan), so library data from user A never leaks to user B. `staleTime: 30_000` from the existing query-client defaults means tab-switching back within 30s avoids refetch; anything older is background-refreshed on mount.
## File structure
**New files under `web/src/`:**
```
lib/
├── api/
│ ├── queries.ts # qk.* helpers + createArtistsQuery/createArtistQuery/createAlbumQuery
│ └── types.ts # ArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>
├── components/
│ ├── ArtistRow.svelte
│ ├── ArtistRow.test.ts
│ ├── AlbumCard.svelte
│ ├── AlbumCard.test.ts
│ ├── TrackRow.svelte
│ ├── TrackRow.test.ts
│ ├── LibrarySkeleton.svelte
│ ├── LibrarySkeleton.test.ts
│ ├── ApiErrorBanner.svelte
│ └── ApiErrorBanner.test.ts
├── media/
│ ├── covers.ts # FALLBACK_COVER data URL + coverUrl(id) helper
│ └── duration.ts # formatDuration(seconds): '3:42' or '1:02:03'
└── utils/
└── useDelayed.svelte.ts # delayed-boolean rune helper for skeleton suppression
routes/
├── +page.svelte # Library root — artists list (REPLACE existing placeholder)
├── artists.test.ts # tests for the artists list page
├── artists/
│ └── [id]/
│ ├── +page.svelte
│ └── artist.test.ts
└── albums/
└── [id]/
├── +page.svelte
└── album.test.ts
test-utils/
└── query.ts # shared vi.mock helpers for @tanstack/svelte-query
```
**Modified files:**
| File | Change |
|---|---|
| `web/src/routes/+page.svelte` | Replace "Library" placeholder with the real artists list. |
Nothing touches the `lib/api/client.ts` transport — the api facade from the auth plan is sufficient. Nothing touches `lib/auth/` — auth is a done surface.
## `queries.ts` shape
```ts
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
import { api } from './client';
import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types';
export type ArtistSort = 'alpha' | 'newest';
const ARTIST_PAGE_SIZE = 50;
export const qk = {
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
artist: (id: string) => ['artist', id] as const,
album: (id: string) => ['album', id] as const,
};
export function createArtistsQuery(sort: ArtistSort) {
return createInfiniteQuery({
queryKey: qk.artists(sort),
queryFn: ({ pageParam = 0 }) =>
api.get<Page<ArtistRef>>(
`/api/artists?sort=${sort}&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 createArtistQuery(id: string) {
return createQuery({
queryKey: qk.artist(id),
queryFn: () => api.get<ArtistDetail>(`/api/artists/${id}`),
});
}
export function createAlbumQuery(id: string) {
return createQuery({
queryKey: qk.album(id),
queryFn: () => api.get<AlbumDetail>(`/api/albums/${id}`),
});
}
```
`types.ts` — new file mirroring the backend `internal/api/types.go` shapes (`ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page<T>`). Lives next to `client.ts` for discoverability.
## Per-page layout
### Artists list — `/`
- **Top bar**: left "Library" title (h1); right, `<select>` bound to `?sort=alpha|newest` via `goto('?sort=...', {replaceState: true})`. Query key re-derives from URL; TanStack Query fetches the new sort. Subtitle below: "1,247 artists" (pulled from the first page's `total`).
- **Body**: stacked `<ArtistRow>` items. Dark row background with lighter hover. Each row ~60px tall: 40×40 circle avatar (first letter), name, muted-color `N albums`, chevron.
- **Footer**: full-width `<button class="Load more">`. Disabled + internal spinner while `isFetchingNextPage`. Becomes `<p>End of library</p>` when `hasNextPage === false`.
- **Empty state** (total === 0): "No artists yet — scan a library folder via the server's config."
- **Loading state** (isPending): `<LibrarySkeleton variant="list" count={12} />`.
- **Error state**: `<ApiErrorBanner error={query.error} onRetry={query.refetch} />`.
### Artist detail — `/artists/:id`
- **Top**: back chevron link "← Library" that routes to `/`. h1 with artist name; subtitle `N albums`.
- **Body**: responsive grid of `<AlbumCard>`. Tailwind breakpoints — `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5`. Each card ~200px: cover (square), title, year below; hover scales the cover via `hover:scale-[1.03] transition-transform`.
- **Empty state**: "This artist has no albums in the library." (Rare — backend only surfaces artists that have at least one album; included for defense in depth.)
- **Not-found state** (ApiError `{code: 'not_found', status: 404}`): "Artist not found" with a back link.
- **Loading / error**: standard skeleton + banner patterns.
### Album detail — `/albums/:id`
- **Top**: back chevron link "← {artistName}" routing to `/artists/:artistId`.
- **Hero**: flex row on `md:+`, column stack on mobile. Left/top: 240px square cover. Right/bottom: h1 title, artist name as a link to `/artists/:artistId`, year (if present), `N tracks · H:MM:SS` total duration.
- **Body**: track table. Columns: `#` (32px right-aligned), title, duration (right-aligned). Track rows alternate subtle background tint. No play icons, no hover effects — player plan adds those. Duration renders via `formatDuration(seconds)``mm:ss` when under an hour (`242 → "4:02"`), `h:mm:ss` at or above (`3723 → "1:02:03"`).
- **Empty state**: "This album has no tracks." (Rare; defense in depth.)
- **Not-found state**: "Album not found" with a back link to `/`.
- **Loading**: skeleton with cover-sized gray square + title/metadata bars + ~10 gray track rows.
## Cover rendering
```svelte
<img
src="/api/albums/{id}/cover"
alt=""
class="aspect-square w-full object-cover"
loading="lazy"
onerror={(e) => {
// fallback: inline SVG data URL with a music-note glyph on the surface color
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}}
/>
```
- Same-origin in dev (Vite proxy) and prod (embedded SPA) — the browser attaches the `minstrel_session` cookie automatically.
- `loading="lazy"` means off-screen cards in the artist grid don't issue requests until scrolled.
- Alt text is empty because the visible label (album title) is adjacent and screen readers read both; doubling the announcement is worse than skipping.
- `FALLBACK_COVER` is a module-level data-URL constant exported from a small `covers.ts` helper.
## Loading/error patterns
**Delayed skeleton.** A small shared helper prevents flash-of-skeleton on cache hits:
```ts
// lib/utils/useDelayed.svelte.ts
export function useDelayed(getValue: () => boolean, delayMs = 100) {
let delayed = $state(false);
let timeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const v = getValue();
if (v) {
timeout = setTimeout(() => { delayed = true; }, delayMs);
} else {
if (timeout) clearTimeout(timeout);
delayed = false;
}
return () => { if (timeout) clearTimeout(timeout); };
});
return { get value() { return delayed; } };
}
```
Use: `const showSkeleton = useDelayed(() => query.isPending);` — skeleton renders only if `isPending` persists beyond 100ms. Cache-hit navigation feels instant; genuinely-loading views still show feedback.
**Error banner.** Single component reused across pages:
```svelte
<!-- ApiErrorBanner.svelte -->
<script lang="ts">
import type { ApiError } from '$lib/api/client';
let { error, onRetry }: { error: unknown; onRetry: () => void } = $props();
const apiErr = error as ApiError | undefined;
const message = apiErr?.message ?? 'Something went wrong.';
</script>
<div role="alert" class="rounded border border-danger/40 bg-surface p-4">
<p class="text-text-primary">{message}</p>
<button type="button" class="mt-2 rounded bg-accent px-3 py-1 text-sm text-background" onclick={onRetry}>
Try again
</button>
</div>
```
**404 branch.** Detail pages check `apiErr?.code === 'not_found'` specifically and render a non-retryable "not found" card with a back link — retry buttons on 404s just confuse users.
## Testing
**Component unit tests (Vitest + Testing Library + jest-dom):**
- `ArtistRow.test.ts` — renders initial (`"alice"``"A"`), name, `12 albums`, href `/artists/{id}`.
- `AlbumCard.test.ts` — renders `<img src="/api/albums/{id}/cover">`, title, year, href `/albums/{id}`. Broken-cover handler swaps src to the fallback data URL.
- `TrackRow.test.ts` — renders `#` (track number), title, duration — with `formatDuration(242)``"4:02"`, `formatDuration(3723)``"1:02:03"`.
- `LibrarySkeleton.test.ts` — variants `list | grid | album` each render a distinctive structure; `count` prop controls item count.
- `ApiErrorBanner.test.ts` — shows `error.message` when present; falls back to "Something went wrong"; clicking the button calls `onRetry`.
**Page integration tests (mocked TanStack Query):**
Shared test util at `src/test-utils/query.ts`:
```ts
// Returns a minimal synthetic infinite-query store.
export function mockInfiniteQuery<T>(opts: {
pages?: T[];
isPending?: boolean;
isError?: boolean;
error?: unknown;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}) { /* ... */ }
export function mockQuery<T>(opts: {
data?: T;
isPending?: boolean;
isError?: boolean;
error?: unknown;
refetch?: () => void;
}) { /* ... */ }
```
Then:
- `artists.test.ts`
- Renders `<ArtistRow>` for each artist in mocked pages.
- Clicking "Load more" calls `fetchNextPage`.
- `hasNextPage === false` renders "End of library".
- Sort `<select>` change triggers `goto('?sort=newest', ...)`.
- Pending renders `<LibrarySkeleton variant="list">`; error renders `<ApiErrorBanner>`.
- Empty state (total === 0) renders the empty-library message.
- `artist.test.ts`
- Renders `<AlbumCard>` for each album in `ArtistDetail.albums`.
- Back link `href="/"` with text "← Library".
- 404 error renders "Artist not found" with back link and NO retry button.
- Pending renders skeleton; non-404 error renders banner with retry.
- `album.test.ts`
- Renders cover (src pointing at `/api/albums/:id/cover`), title, artist link, year, metadata summary.
- Renders `<TrackRow>` for each track.
- Back link `href="/artists/:artistId"` with text `"← {artistName}"`.
- 404 error renders "Album not found" with back link.
**Manual browser check** (Plan Task N verification step):
1. `docker compose up --build -d` + login with seeded user.
2. `/` shows the artists list; counts visible; "Load more" appends pages without reloading.
3. Change sort dropdown → URL updates to `?sort=newest`; list reorders (oldest-added artists move to the bottom).
4. Click an artist → albums grid renders with covers. Broken covers (if any) show the fallback glyph.
5. Click an album → hero + track list. Back link returns to the artist (not the root).
6. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found".
7. Refresh `/artists/abc` while authenticated → no flash of login; data re-fetches and renders.
## Success criteria
- A user can start at `/`, drill into any artist, then into any album, then back — all via deep-linkable URLs.
- Cover images render; broken covers fall back gracefully.
- "Load more" extends the artist list; sort dropdown changes order and reflects in the URL.
- 404s (not-found artist / album) render a specific not-found card, not a retry banner.
- Cache-hit navigation (e.g., pressing Back) is instant — no skeleton flash.
- All unit and page tests pass in CI (`npm test` in `web/`).
- No backend changes required; this is pure frontend consumption of the existing `/api/artists`, `/api/artists/{id}`, `/api/albums/{id}`, `/api/albums/{id}/cover` endpoints.
@@ -1,423 +0,0 @@
# Web UI Player — Design Spec
**Date:** 2026-04-24
**Status:** Design approved
**Follows:** [Web UI Library Views](2026-04-23-web-ui-library-views-design.md) — tracks surfaced by the library plan finally become playable.
## Goal
Let an authenticated user click any track on an album page and hear it — plus skip/seek/shuffle/repeat/volume, a persistent bottom-bar UI, and OS-level media controls via the MediaSession API. After this lands, the web client is a functional music player for sequential playback through the library.
## Non-goals
Explicit YAGNI — kept out of this plan so scope stays tight:
- **No server-side listen-history recording.** The spec's `play_events` / `sessions` tables exist in the server design but no `/api/*` endpoints are implemented yet. Once those land, a follow-up "session reporting" sub-plan wires the player to POST events.
- **No scrobbling to ListenBrainz.** Dependent on server event endpoints + scrobble queue — later plan.
- **No contextual likes or "like this vibe".** Out of scope until session reporting exists.
- **No cross-refresh persistence.** Reload drops playback state (queue, position, current track). Volume is the only thing that persists.
- **No queue UI / "up next" panel.** The user sees the current track in the bar; they don't get a separate list view of what's coming. Queue visibility is a natural follow-up.
- **No keyboard shortcuts.** Space/arrow keys don't control playback in this plan — a separate "keyboard shortcuts" sub-plan will add them.
- **No crossfade, gapless playback, replay-gain, equalizer.** Baseline HTMLAudio only.
- **No cast / AirPlay / Chromecast.** Browser-local playback only.
- **No keyboard-driven seeking beyond the native `<input type="range">` behavior.**
## Architecture
**Single global player store** as a Svelte 5 rune module — imported by `+layout.svelte`, `Shell`, `PlayerBar`, and `TrackRow`. The store is pure state + action functions; it never touches the `<audio>` element directly.
The `<audio>` element lives once in `+layout.svelte` (survives navigation because the root layout never unmounts). The layout drives the element FROM the store via `$effect`:
- `audioEl.src` follows `player.current?.stream_url`
- `audioEl.volume` follows `player.volume`
- `audioEl.play()` / `pause()` follows `player.isPlaying`
And reports back INTO the store via DOM event handlers: `timeupdate`, `loadedmetadata`, `playing`, `pause`, `waiting`, `ended`, `error`. This inversion makes the store pure-logic testable — unit tests don't need jsdom to implement `HTMLMediaElement`.
**Seek writes go directly** via a small `registerAudioEl(el)` helper the layout calls once at mount. `seekTo(sec)` then writes `audioEl.currentTime = sec` without going through a `$effect` on `position` (avoids a write-loop where audio `timeupdate` → store `position``$effect` → audio `currentTime``timeupdate` again).
**MediaSession integration** (`useMediaSession()`) is a thin `$effect`-based module imported once from the root layout. It reads the store and writes `navigator.mediaSession.metadata`, registers action handlers (play/pause/previous/next/seekto), and keeps `playbackState` + `setPositionState` in sync.
**Click-to-play lifecycle:**
```
User clicks <TrackRow> for album tracks[5]
→ store.playQueue(album.tracks, 5)
→ _queue=tracks, _index=5, _state='loading', _position=0
→ Shell mounts <PlayerBar> (current !== undefined)
→ layout's src $effect → audio.src = tracks[5].stream_url
→ layout's isPlaying $effect → audio.play()
→ 'loadedmetadata' → reportDuration
→ 'playing' → reportStateFromAudio('playing') → _state='playing'
→ PlayerBar updates
```
## File structure
**New files under `web/src/`:**
```
lib/
├── player/
│ ├── store.svelte.ts # Rune state + action functions
│ ├── store.test.ts # Pure state-machine tests
│ ├── mediaSession.svelte.ts # navigator.mediaSession glue
│ └── mediaSession.test.ts # MediaSession spy-based tests
└── components/
├── PlayerBar.svelte # Bottom bar UI
└── PlayerBar.test.ts # Controls wired correctly; disabled states
```
**Modified files:**
| File | Change |
|---|---|
| `lib/components/Shell.svelte` | Grid grows a 3rd row. `<PlayerBar>` slot spans both columns. Mounted only when `player.current !== undefined`. |
| `lib/components/TrackRow.svelte` | Becomes a `<button>` that calls `playQueue(tracks, index)`. New props `tracks: TrackRef[]` and `index: number`. |
| `lib/components/TrackRow.test.ts` | Updated — clicks fire `playQueue` (mocked). |
| `routes/albums/[id]/+page.svelte` | Passes `tracks={album.tracks}` and `index={i}` to each `<TrackRow>`. |
| `routes/+layout.svelte` | Adds `<audio>` element, `$effect` bindings that drive it from the store, and a `useMediaSession()` call. |
## State shape
```ts
// lib/player/store.svelte.ts
import type { TrackRef } from '$lib/api/types';
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume()); // initial from localStorage
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
export const player = {
get queue() { return _queue; },
get index() { return _index; },
get current() { return _queue[_index] as TrackRef | undefined; },
get state() { return _state; },
get isPlaying() { return _state === 'playing'; },
get position() { return _position; },
get duration() { return _duration; },
get volume() { return _volume; },
get shuffle() { return _shuffle; },
get repeat() { return _repeat; },
get error() { return _error; },
};
```
## Action contract
```ts
// From outside the module (UI components and the layout call these).
export function playQueue(tracks: TrackRef[], startIndex = 0): void;
export function togglePlay(): void;
export function skipNext(): void;
export function skipPrev(): void;
export function seekTo(sec: number): void;
export function setVolume(v: number): void;
export function toggleShuffle(): void;
export function cycleRepeat(): void;
// Audio-event reports FROM the layout's <audio> element into the store.
export function reportTimeUpdate(sec: number): void;
export function reportDuration(sec: number): void;
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void;
// Wiring for seek writes (avoids $effect loop on position).
export function registerAudioEl(el: HTMLAudioElement | null): void;
```
## Per-control semantics
**Skip-previous (3-second rule).** Skip-prev never wraps to the end of the queue — repeat modes only affect auto-advance and skip-next.
- If `_position < 3` AND `_index > 0`: `_index--`, position=0, play.
- Else (position ≥ 3, OR at index 0): seek to 0 of current track.
- Disabled visually when `_index === 0` AND `_position < 3` — nothing useful left to do.
**Skip-next.**
- `_index + 1 < _queue.length``_index++`, position=0.
- At end: consult `_repeat`:
- `off``_state='paused'`, `_index = queue.length - 1`, `_position = _duration`.
- `all``_index = 0`, keep playing.
- `one` → treated as `off` here (explicit user skip overrides one-track repeat).
- Disabled when `_index === _queue.length - 1` AND `_repeat === 'off'`.
**Auto-advance on `ended` event** (`reportStateFromAudio('ended')`):
- `repeat === 'one'``_position = 0`, `_state='loading'`; layout sees state change, `currentTime` resets (via `seekTo(0)` internally), play continues.
- `repeat === 'all'``_index = (_index + 1) % _queue.length`, `_position = 0`.
- `repeat === 'off'` → same as skip-next past end: `_state='paused'`.
**Seek.**
- UI: `<input type="range" min=0 max={duration}>` bound to `player.position` with `oninput → seekTo(newValue)`.
- `seekTo` writes `_position = sec` AND `audioEl.currentTime = sec` via `registerAudioEl`-stored reference.
**Volume.**
- UI: `<input type="range" min=0 max=1 step=0.01>`.
- `setVolume(v)` clamps to `[0, 1]`, writes `_volume = v`, persists via `localStorage.setItem('minstrel.volume', String(v))`.
- Layout's `$effect` on `player.volume` pushes to `audioEl.volume`.
- Module-load-time helper `readStoredVolume(): number` reads `localStorage.getItem('minstrel.volume')` with `1.0` fallback and numeric validation.
**Shuffle.**
- `toggleShuffle()`:
- Enabling: Fisher-Yates shuffle `_queue[_index+1..end]` in place. The played-and-current tracks stay in position; only what's ahead is randomized.
- Disabling: flag flips. Queue is not unshuffled — shuffle is a one-shot randomization; toggling back off only matters for new `skipNext`/`ended` behavior, which at the moment IS "walk through queue in order" so no difference. (Simpler than tracking an original order.)
- Test-determinism: the shuffle function takes an injectable RNG; default is `Math.random`. Tests override via a module-level setter.
**Repeat.**
- `cycleRepeat()`: `off → all → one → off`.
- UI renders three distinct glyphs.
**Click-to-play.**
- `TrackRow` `onclick``playQueue(tracks, index)`.
- Clicking a track currently playing restarts it.
- Clicking a track on a DIFFERENT album replaces queue with that album's tracks, `index=N`.
## Audio wiring in `+layout.svelte`
```svelte
<script lang="ts">
/* existing imports unchanged */
import {
player,
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
seekTo
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
let audioEl: HTMLAudioElement | undefined = $state();
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url ?? '';
if (audioEl.src !== new URL(src, window.location.origin).href && src) {
audioEl.src = src;
} else if (!src) {
audioEl.removeAttribute('src');
audioEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
$effect(() => {
if (!audioEl) return;
if (player.isPlaying) audioEl.play().catch(() => {/* play() rejected (autoplay / interrupted) — store will receive a 'pause' event */});
else audioEl.pause();
});
useMediaSession();
</script>
<!-- existing layout markup -->
<audio
bind:this={audioEl}
preload="metadata"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => audioEl && reportDuration(audioEl.duration)}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
```
## MediaSession glue
```ts
// lib/player/mediaSession.svelte.ts
import { player, togglePlay, skipNext, skipPrev, seekTo } from './store.svelte';
export function useMediaSession(): void {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
$effect(() => {
const t = player.current;
if (!t) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: t.title,
artist: t.artist_name,
album: t.album_title,
artwork: [{
src: `/api/albums/${t.album_id}/cover`,
sizes: '512x512',
type: 'image/jpeg'
}]
});
});
navigator.mediaSession.setActionHandler('play', () => togglePlay());
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
navigator.mediaSession.setActionHandler('previoustrack', () => skipPrev());
navigator.mediaSession.setActionHandler('nexttrack', () => skipNext());
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
});
$effect(() => {
if (player.duration > 0 && 'setPositionState' in navigator.mediaSession) {
try {
navigator.mediaSession.setPositionState({
duration: player.duration,
position: Math.min(player.position, player.duration),
playbackRate: 1
});
} catch { /* setPositionState throws if numbers are NaN/Infinity briefly between tracks */ }
}
});
$effect(() => {
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
});
}
```
## Bottom-bar layout
```
┌────────────────────────────────────────────────────────────────┐
│ [cover] Title 2:14 [════════▓═══════] 4:02 🔀 🔁 🔊 │
│ Artist ⏮ ⏯ ⏭ │
└────────────────────────────────────────────────────────────────┘
```
- **Left** (fixed 240px, collapses below `sm:`): 48px cover thumbnail (click → `/albums/:id`), title, artist name linked to `/artists/:id`. Broken cover → `FALLBACK_COVER` from library plan.
- **Center** (flex-1, stacked):
- Top row: `{elapsedTime} {seek slider (flex-1)} {totalDuration}` — single row, elapsed/total use `tabular-nums` and a fixed `ch` width so the slider doesn't jitter as `position` ticks.
- Bottom row: `⏮ ⏯ ⏭` centered under the slider. Play/pause glyph swaps based on `isPlaying`. During `loading`/`waiting`, swap play icon for a small spinner.
- **Right** (~220px): shuffle button, repeat button (badge for 'one' state), volume slider (150px wide).
- Total bar height ~80px. Grid spans both columns (sidebar + main).
Error variant: the center column's content is replaced with an inline error card — "Playback failed." + "Try again" button that calls `playQueue(_queue, _index)`. Transport controls disabled.
## Shell modifications
`Shell.svelte` grid grows a 3rd row:
```
grid grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto]
```
The last row spans both columns. PlayerBar renders there only when `player.current` is defined. When nothing has played yet, the row collapses (zero height).
## Loading / error / empty states
- `state === 'loading' | 'waiting'` — play/pause button shows spinner glyph. Other controls unchanged.
- `state === 'error'` — error card replaces transport controls; retry button calls `playQueue(_queue, _index)`.
- `current === undefined` — PlayerBar doesn't render. Shell's 3rd grid row collapses.
401 mid-playback: already handled globally by `apiFetch`'s interceptor → `auth.logout({silent: true})` → auth store clears → root layout's guard redirects to `/login`. The audio element errors out; the PlayerBar unmounts because Shell is gone. No player-specific handling needed.
## Testing
**Unit tests — `store.test.ts`:**
State-machine semantics, no audio element. All tests seed a fresh store via `vi.resetModules()` + dynamic `import('./store.svelte')` in `beforeEach` so `_*` state isn't shared across tests. Explicit cases:
- `playQueue([...], 2)` sets queue, index=2, state='loading', position=0.
- `togglePlay`: idle → no-op. paused → playing. playing → paused.
- `skipNext` mid-queue: index++, position=0.
- `skipNext` at end with `repeat='off'`: state='paused', index=queue.length-1, position=duration.
- `skipNext` at end with `repeat='all'`: index=0.
- `skipNext` at end with `repeat='one'`: same as 'off' (explicit skip overrides one-track repeat).
- `skipPrev` with position < 3 AND index > 0: index--.
- `skipPrev` with position >= 3: index unchanged; position=0.
- `skipPrev` at index 0: position=0 regardless.
- `reportStateFromAudio('ended')` with `repeat='one'`: position=0, index unchanged, state='loading'.
- `reportStateFromAudio('ended')` with `repeat='all'`: wraps `_index = (_index + 1) % _queue.length`.
- `reportStateFromAudio('ended')` with `repeat='off'`: state='paused'.
- `toggleShuffle` (with injected seeded RNG): queue[index+1..end] is reordered; queue[0..index] unchanged.
- `cycleRepeat`: off → all → one → off (3 consecutive calls).
- `setVolume(0.5)`: volume=0.5; localStorage key 'minstrel.volume' equals '0.5'.
- `setVolume(-1)` clamps to 0; `setVolume(2)` clamps to 1.
- Module load with `localStorage['minstrel.volume']='0.3'`: initial `_volume === 0.3`.
- Module load with absent / invalid localStorage value: initial `_volume === 1.0`.
- `reportStateFromAudio('error', 'foo')`: state='error', error='foo'.
- `reportStateFromAudio('playing')`: state='playing'; clears `_error`.
- `seekTo(30)`: position=30; if `registerAudioEl` has been called with a mock element, that element's `currentTime === 30`.
**Unit tests — `mediaSession.test.ts`:**
Fakes `navigator.mediaSession` with a spy object containing `setActionHandler`, `setPositionState`, `metadata`, `playbackState`. Calls `useMediaSession()` inside `$effect.root(() => {...})`.
- Metadata: when `player.current` is set, `navigator.mediaSession.metadata` has title/artist/album/artwork matching the current track.
- Metadata cleared to `null` when `player.current` becomes undefined.
- All four action handlers registered with callables that dispatch to the corresponding store action.
- `playbackState` updates when `isPlaying` flips.
- `setPositionState` called with duration/position whenever duration>0 changes.
**Unit tests — `PlayerBar.test.ts`:**
Mocks `$lib/player/store.svelte` to control state and spy on actions.
- Renders nothing when `player.current === undefined` (or Shell doesn't mount it — tests render PlayerBar directly with a current track).
- Renders cover, title, artist link (`href='/artists/:id'`), cover link (`href='/albums/:id'`).
- Play button click calls `togglePlay`.
- Skip-next/prev click call `skipNext`/`skipPrev`.
- Skip-prev disabled when index=0 AND position < 3.
- Skip-next disabled when index=length-1, repeat='off'.
- Seek slider input calls `seekTo(value)`.
- Volume slider input calls `setVolume(value)`.
- Shuffle button click calls `toggleShuffle`; active state reflects `player.shuffle`.
- Repeat button click calls `cycleRepeat`; three visual states render distinctly (assert on `aria-label` variants).
- State='loading' or 'waiting': play icon replaced by spinner (assert on a `data-testid` or SVG class).
- State='error': transport region replaced by retry card; clicking retry calls `playQueue` with `player.queue` + `player.index`.
- Elapsed + total use `formatDuration` (reuse from library plan).
**Unit tests — `TrackRow.test.ts` (updated):**
Keep existing tests; add:
- Clicking the row calls `playQueue(tracksProp, indexProp)` — mock the store module.
- Row renders as a `<button>` (or `<div role="button" tabindex="0">` — spec requires a keyboard-activatable element; `<button>` is simpler).
**Manual integration (Plan Task N verification):**
1. Sign in, navigate to an album, click track 3 → bar appears, track plays.
2. OS-level check: lock-screen / media notification shows the track metadata and allows play/pause.
3. Press media keys on keyboard → play/pause toggles.
4. Drag seek slider mid-track → audio jumps to new position.
5. Click skip-next through end of album → stops.
6. Turn on repeat=all → skip-next past end wraps to track 1.
7. Turn on repeat=one → track loops at end.
8. Turn on shuffle → future skips land on random remaining tracks.
9. Adjust volume; refresh the page; navigate to a new track — volume restored from localStorage.
10. Disconnect network mid-track → error card appears; click "Try again" → recovers when network returns.
11. Navigate between artists / albums / the library root while a track is playing — playback continues, bar stays put.
## Dependencies added
None. Everything uses the existing stack (SvelteKit 2, Svelte 5 runes, Vitest, Testing Library, jsdom). The MediaSession API is native to browsers; no polyfill.
## Success criteria
- Clicking any track starts playback with the rest of the album queued.
- All controls (play/pause, skip ±1, seek, volume, shuffle, repeat) behave per Section 3 of this spec.
- MediaSession metadata and action handlers are populated; OS media controls work.
- Playback survives navigation within the SPA.
- Volume persists across page loads.
- Broken streams show the error card; retry recovers.
- All new unit + page tests pass (`npm test` in `web/`).
- No backend changes — the plan is pure frontend consumption of the existing `/api/tracks/:id/stream`.
@@ -1,221 +0,0 @@
# M2 Events Sub-Plan — Design Spec
**Status:** approved 2026-04-25
**Slice:** M2 (Events, sessions, general likes), spec §13 step 5. General likes (step 6) deferred to a follow-up sub-plan.
**Fable tasks:** #322 (schema), #323 (session service), #324 (events API), #325 (web UI wiring) — bundled into a single PR for this slice.
## Goal
Ship the play-event ingestion pipeline end-to-end: schema, session service, `POST /api/events` handler, Subsonic `/rest/scrobble` integration, and the SvelteKit player wiring that emits the events. Output is a populated `play_events` / `skip_events` / `sessions` set that M3's recommendation engine can read from. No recommendation logic in this slice; this is the data foundation.
## Non-goals
- General likes table, `/api/likes` endpoint, Subsonic `star/unstar` wiring — own M2 sub-plan.
- `contextual_likes`, `artist_preferences` — M3 (algorithm consumers).
- `session_vector_at_play` JSONB population — M3 (the algorithm computes it). The column ships in this slice as nullable.
- Background janitor for abandoned `ended_at IS NULL` rows. Auto-close-on-next-play handles the common case; user-never-comes-back leaves at most one open row per user, which doesn't impact recommendation queries (they filter `ended_at IS NOT NULL`).
- Multi-device session merging. Each `play_started` opens a new row regardless of whether one is already open elsewhere; M3 decides what to do when interleaved.
- Listen-history UI. The Web UI surfaces nothing about events in this slice — the wiring is invisible to the user.
## Architecture
A new database migration adds `play_events`, `skip_events`, and `sessions` per spec §5. A small Go package `internal/sessions` exposes a `FindOrCreate` helper implementing the 30-minute rolling-window rule from spec §6. A second package `internal/playevents` owns the write paths (start, end, skip, synthetic-completed) so both the JSON API handler and the existing Subsonic scrobble handler share one source of truth. A new HTTP handler `POST /api/events` accepts a discriminated-union JSON payload covering the three client-side event types. The web SPA gains a sibling module to the player store that watches state transitions and dispatches events.
**Event lifecycle:** start + end (option 1 from brainstorm). Each track produces one `play_events` row inserted at start (`ended_at` NULL) and updated at end. The skip-classification rule (spec §6) runs at end-time: a play is a SKIP if `completion_ratio < 0.5 AND duration_played_ms < 30000`. Both conditions must fail (i.e., either ≥50% OR ≥30s) for it to count as a play.
**Subsonic scrobble integration:** Third-party Subsonic clients call `/rest/scrobble` to record plays. Today the handler is a no-op stub. After this slice, `submission=false` writes a `play_started` (replacing the in-memory `nowPlayingMap`); `submission=true` writes a synthetic completed play (`play_started + play_ended` in one transaction). This puts mobile/desktop Subsonic clients on equal footing with the web SPA in M3's recommendation pipeline.
**Abandoned plays:** The web SPA uses `navigator.sendBeacon('/api/events', play_skipped)` on `pagehide` so graceful tab-close still completes the row. For ungraceful cases (network drop, browser crash, OS kill), the events handler's `play_started` path begins by auto-closing any prior `ended_at IS NULL` row for the same user. The close uses `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)` (in ms), and `was_skipped = true`. The completion ratio is recomputed and the skip rule from spec §6 is *not* applied (auto-closed rows are flagged as skipped regardless, since we don't know how much the user actually heard). At most one open row per user at any time.
## Schema (migration `0005_events.up.sql`)
```sql
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
last_event_at timestamptz NOT NULL,
track_count integer NOT NULL DEFAULT 0,
client_id text
);
CREATE INDEX sessions_user_last_event_idx ON sessions (user_id, last_event_at DESC);
CREATE TABLE play_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_played_ms integer,
completion_ratio double precision,
was_skipped boolean NOT NULL DEFAULT false,
client_id text,
session_vector_at_play jsonb,
scrobbled_at timestamptz
);
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
CREATE TABLE skip_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
skipped_at timestamptz NOT NULL,
position_ms integer NOT NULL
);
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
```
`session_vector_at_play` lands now as nullable so M3 doesn't need a follow-up migration; M2 writes NULL.
## API contracts
### `POST /api/events`
```ts
type EventRequest =
| { type: 'play_started'; track_id: string; at?: string; client_id?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number; at?: string }
| { type: 'play_skipped'; play_event_id: string; position_ms: number; at?: string };
type EventResponse =
| { play_event_id: string; session_id: string } // for play_started
| { ok: true }; // for play_ended / play_skipped
```
`at` defaults to server `now()` if omitted. `client_id` defaults to null.
**Errors:**
- `400 bad_request` — missing/invalid `type`; required fields missing or malformed; UUIDs not parseable; `duration_played_ms` or `position_ms` negative.
- `404 not_found``track_id` doesn't exist, or `play_event_id` doesn't exist.
- `403 forbidden``play_event_id` belongs to a different user (event UUIDs aren't secrets, but writes must respect ownership).
- `500 server_error` — DB issue.
### `/rest/scrobble` (Subsonic — existing endpoint, behavior change only)
Request shape unchanged. Behavior:
- `submission=false&id=X&time=T` → calls `playevents.RecordPlayStarted(userID, trackID, clientID=c, at=T)` then returns the standard Subsonic `ok` envelope. Replaces the in-memory `nowPlayingMap`.
- `submission=true&id=X&time=T` → calls `playevents.RecordSyntheticCompletedPlay(userID, trackID, clientID=c, at=T)` which writes `play_started` and `play_ended` together in a single transaction. `started_at = T`, `ended_at = T + track.duration_ms`, `duration_played_ms = track.duration_ms`, `was_skipped = false` (Subsonic clients don't tell us if a play was skipped — assume completion).
- Other `submission` values: ignored, ack with `ok` (matches current behavior).
Response stays the standard Subsonic `ok` envelope; no new fields.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0005_events.up.sql` + `.down.sql` | Schema for `play_events`, `skip_events`, `sessions`. |
| `internal/db/dbq/events.sql` (sqlc input) + generated `events.sql.go` | `InsertSession`, `UpdateSessionLastEvent`, `GetMostRecentSessionForUser`, `InsertPlayEvent`, `UpdatePlayEventEnded`, `InsertSkipEvent`, `CloseAbandonedPlayEventsForUser`, `GetOpenPlayEventByUser`. |
| `internal/sessions/service.go` | `FindOrCreate(ctx, tx, userID, eventTime, clientID, timeout) (sessionID, error)`. Composable into a larger transaction. |
| `internal/sessions/service_test.go` | Live-DB tests covering the four scenarios above. |
| `internal/playevents/writer.go` | `RecordPlayStarted`, `RecordPlayEnded`, `RecordPlaySkipped`, `RecordSyntheticCompletedPlay`. Owns the auto-close-prior step, skip classification rule, skip_events writes. |
| `internal/playevents/writer_test.go` | Direct tests of rule edges (49/50% × 29/30s), auto-close logic, synthetic write. |
| `internal/api/events.go` | `handleEvents` HTTP handler — JSON decode, dispatch by `type`, call into `playevents.writer`. |
| `internal/api/events_test.go` | HTTP-level coverage for happy path, validation errors, ownership-mismatch. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/api.go` | Register `authed.Post("/events", h.handleEvents)`. |
| `internal/subsonic/stream.go` | `handleScrobble` calls `playevents.writer` instead of `nowPlayingMap`. The `nowPlayingMap` struct + `newNowPlayingMap` + the `nowPlaying` field are deleted. The `mediaHandlers` constructor signature simplifies. |
| `internal/subsonic/stream_test.go` | Update existing scrobble test for the new behavior; add a `submission=true` case. |
| `internal/config/config.go` + `config.example.yaml` | New `events:` section: `session_timeout_minutes` (default 30), `skip_max_completion_ratio` (default 0.5), `skip_max_duration_played_ms` (default 30000). |
### New web files
| Path | Responsibility |
|---|---|
| `web/src/lib/player/events.svelte.ts` | `useEventsDispatcher()``$effect`-based watcher on `player.current` / `player.state`; owns `_playEventId` state; calls `api.post`; installs `pagehide` listener that uses `navigator.sendBeacon`. |
| `web/src/lib/player/events.svelte.test.ts` | State-machine tests with mocked `api.post` and spied `navigator.sendBeacon`. |
| `web/src/lib/player/clientId.ts` | `getOrCreateClientId(): string` — sessionStorage-backed UUID per tab. |
### Modified web files
| Path | Change |
|---|---|
| `web/src/lib/api/types.ts` | Add `EventRequest`, `EventResponse` types matching the server contracts. |
| `web/src/lib/api/client.ts` | Add `api.post<T>(path, body)` helper if not already present. |
| `web/src/routes/+layout.svelte` | Call `useEventsDispatcher()` once at mount, alongside the existing `useMediaSession()`. |
## Data flow
**Web SPA path:**
1. User clicks track → store mutates: `_state = 'loading'`, `_queue = tracks`, `_index = 0`, `_position = 0`.
2. `<audio>` loads → fires `playing` → store transitions to `_state = 'playing'`.
3. Events module's `$effect` sees `(player.current, player.state === 'playing')` for the first time on this track → `POST /api/events { type: 'play_started', track_id, client_id, at }` → server returns `{ play_event_id }` → cached in module state.
4. Track plays. No emissions during playback.
5. Track ends naturally → audio fires `ended` → store advances to next track or pauses → events module sees the transition out of `playing` for THIS track → `POST { type: 'play_ended', play_event_id, duration_played_ms, at }` → server updates the row, applies skip classification, optionally writes a `skip_events` row.
6. Cycle repeats.
**Skip mid-track:** `skipNext()` advances queue → audio src changes → events module sees `player.current` change while `_playEventId` is still set → `POST { type: 'play_skipped', play_event_id, position_ms = _position, at }` → then on the new track's `playing` event, fires `play_started` for the new track.
**Tab close:** `pagehide` listener fires → `navigator.sendBeacon('/api/events', { type: 'play_skipped', play_event_id, position_ms = _position, at })`.
**Crash / network drop:** No `play_ended` ever sent. Row stays `ended_at IS NULL`. Next time the user starts ANY track on any client, the events handler's auto-close step closes the abandoned row before inserting the new one. Per the formula above: `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)`, `was_skipped = true`.
**Subsonic path:**
- `scrobble?submission=false` → handler calls `playevents.RecordPlayStarted`. Server has live "now playing" via `play_events WHERE ended_at IS NULL` (not used yet but available for M3+).
- `scrobble?submission=true` → handler calls `playevents.RecordSyntheticCompletedPlay` which writes both rows in one transaction.
**Idempotency:** `play_ended` / `play_skipped` are naturally idempotent (each carries the row id). `play_started` is not — re-sending creates a new row. The auto-close-prior step protects the table from leaking open rows.
## Testing
### Server (`go test`)
- **Migration smoke** (`internal/db/db_test.go` extension): applying `0005_events` on a fresh test DB succeeds; round-trip insert+select for each new table.
- **Session service** (`internal/sessions/service_test.go`):
- No prior session → creates one with `started_at = eventTime`, `track_count = 0`.
- Within window → returns existing session id, updates `last_event_at`.
- Beyond window → creates a new session.
- Concurrent inserts (`t.Parallel` with shared user, transactional fixture) don't double-create.
- Uses a `Clock` interface so tests fast-forward without `time.Sleep`.
- **playevents writer** (`internal/playevents/writer_test.go`):
- Skip rule boundaries: `completion=0.49, duration=29s` → skip; `completion=0.5, duration=29s` → not skip; `completion=0.49, duration=30s` → not skip.
- Auto-close-prior: prior open row gets closed with computed `ended_at` and `was_skipped=true` when a new `play_started` arrives.
- Synthetic completed play writes both rows + the `was_skipped=false` flag in a single transaction.
- **Events handler** (`internal/api/events_test.go`):
- Happy path for all three event types.
- 400 on missing `type`; 400 on `play_ended` referencing a malformed UUID; 400 on negative `duration_played_ms`.
- 404 on `track_id` that doesn't exist; 404 on `play_event_id` that doesn't exist.
- 403 on `play_event_id` belonging to a different user.
- **Subsonic scrobble update** (`internal/subsonic/stream_test.go` extension):
- Existing `submission=false` test now asserts a `play_events` row was inserted.
- New `submission=true` test asserts both `play_started + play_ended` rows are written and the response is the standard Subsonic envelope.
- Test-only code that referenced the deleted `nowPlayingMap` is removed.
### Web (`vitest`)
- **`events.svelte.test.ts`** with `vi.mock('$lib/api/client', ...)` and `vi.spyOn(navigator, 'sendBeacon')`:
- Track 1 starts → exactly one `POST` with `type: 'play_started'`.
- Server returns `play_event_id: 'pe1'`, then track ends → exactly one POST with `play_ended` carrying `pe1`.
- User skips mid-track 2 → POST with `play_skipped` carrying that row's id.
- Loading a new track via `playRadio()` while track 2 is still playing → POSTs `play_skipped` for track 2 BEFORE `play_started` for the new track.
- `pagehide` event with an active play → `navigator.sendBeacon` called once with `play_skipped` payload.
### End-to-end manual
Final task in the implementation plan, against `docker compose up --build -d`:
1. Sign in to web SPA. Play a track. `psql -c "SELECT * FROM play_events"` shows one row, `ended_at IS NULL`, correct user/track/session.
2. Let track finish. Same row now has `ended_at`, `was_skipped=false`.
3. Skip mid-track (within 30s of start). New row created on next track; previous row's `was_skipped=true` and `skip_events` has the matching row.
4. Wait > 30 minutes. Play. New `sessions` row with different id from the first session.
5. Open Feishin/Symfonium pointed at the same instance. Play a track to completion. `play_events` shows a synthetic row.
6. Close the web tab mid-track. `play_events` last row was closed via sendBeacon (timestamp ≈ tab close).
7. Disconnect network mid-play, force-quit browser. Reconnect, sign back in, start a new track. Previous abandoned row gets auto-closed.
## Risks & mitigations
- **Event spam from buggy clients.** A misbehaving client could fire `play_started` in a loop, leaking rows. Mitigation: the auto-close-prior step caps each user at one open row at a time. Worst-case is one closed-row insert per `play_started` call, which is bounded by client-side sanity. Future M2.5 could add per-user rate limiting.
- **Clock skew between client and server.** The client sends `at` timestamps; if the client's clock is wildly off, sessions could be assigned incorrectly (same user might see a "new session" for events that should extend the current one). Mitigation: server uses its own `now()` if `at` is omitted, and the events module always omits `at` (lets the server timestamp). The `at` field exists for the Subsonic compatibility path (Subsonic clients send `time=` and we honor it).
- **Subsonic synthetic completed plays are too coarse.** A client that scrobbles after a 90% play is treated identically to a 100% play. Mitigation: Subsonic protocol doesn't expose finer info; this is a known limitation. M3's recommendation engine should weight Subsonic-sourced plays slightly lower if it ever matters in practice.
- **`nowPlayingMap` deletion breaks an unknown caller.** Verified there are no consumers of the existing in-memory map outside `handleScrobble` itself; the constructor change is local. Migration is safe.
- **JSONB `session_vector_at_play` ships nullable but is referenced in M3 plans.** No M3 task starts in this slice; the column is purely forward-compatibility. Documented in the schema comment.
@@ -1,189 +0,0 @@
# Web UI Search — Design Spec
**Status:** approved 2026-04-25
**Slice:** M6 sub-plan #5 (after Player). Backend `GET /api/search` already exists; this slice ships the SPA UI plus a small `/api/radio` stub.
## Goal
Replace the `/search` placeholder with a working search experience: a persistent header search box, live debounced results across artists/albums/tracks, per-facet overflow pages, click-to-play-as-radio for tracks, and an "add to queue" affordance on tracks and albums.
## Non-goals
- Real radio recommendations. The `/api/radio` endpoint ships as a stub returning `{ tracks: [seed] }`. The full M4 implementation (similarity-driven candidate pool + scoring + 80% queue refresh) lands later; only the request/response contract is locked here.
- Search-result pre-fetch / typeahead suggestions / autocomplete. Plain results page only.
- Mobile-specific layout polish. Header bar fits on desktop; mobile responsive pass is its own future slice.
- Keyboard shortcuts (e.g. Cmd-K to focus search). Out of scope.
- Cross-page search history / recent-searches list.
## Architecture
A persistent `SearchInput` component lives in `Shell.svelte`'s header. It binds a local `value` state, debounces 250 ms, and uses `goto()` to keep `/search?q=…` synced. First nav from elsewhere is a normal push; subsequent typing while on `/search` uses `replaceState` so back-navigation skips the typing log. Empty input on `/search` clears the param.
`/search/+page.svelte` reads `?q=` reactively and runs a TanStack Query against `GET /api/search?q=…&limit=10`, which returns three small paged facets. The page renders up to three sections (Artists / Albums / Tracks), each reusing the existing library components. Each section also renders a "See all N →" link to its overflow page when `total > items.length`. Empty facets are not rendered.
Three overflow pages — `/search/artists/`, `/search/albums/`, `/search/tracks/` — each read `?q=` and run their own `createInfiniteQuery` against the same `/api/search` endpoint with `limit=50`. They render only their own facet, ignoring the other two facets in the response (minor over-fetch, simpler than three new endpoints). "Load more" pulls the next page.
The Track click handler on the search results page calls a new player-store action `playRadio(seedTrackId)` which fetches `/api/radio?seed_track=…` and pipes the response into `playQueue(tracks, 0)`. On the album-detail page, Track clicks keep their current behavior (queue the album).
A new `+ queue` button on each track row and album card calls `enqueueTrack` / `enqueueTracks`, which append to the existing `_queue` rune state without disturbing the current playback or `_index`.
## API contracts
### `GET /api/search?q=&limit=&offset=` (existing — no change)
```ts
type SearchResponse = {
artists: Page<ArtistRef>;
albums: Page<AlbumRef>;
tracks: Page<TrackRef>;
};
type Page<T> = { items: T[]; total: number; limit: number; offset: number };
```
Server applies the shared `limit/offset` to each facet's `LIMIT/OFFSET` query independently. `q` is required; empty/whitespace is `400`. Default `limit` is bounded server-side; this slice sends `10` from the main `/search` page and `50` from each overflow page.
### `GET /api/radio?seed_track=<uuid>` (new — stub)
```ts
type RadioResponse = { tracks: TrackRef[] };
```
Stub behavior: `200` with `{ tracks: [<seed_track>] }` (one entry, the seed itself). `404 { code: 'not_found' }` if the track id doesn't exist. `400 { code: 'bad_request' }` if `seed_track` is missing or empty. Same `TrackRef` shape used everywhere else; `resolveTrackRefs` builds the response.
When M4 fills the endpoint, the response shape is final: extra entries appended after the seed, same `TrackRef` shape.
## Components & files
### New
| Path | Responsibility |
|---|---|
| `web/src/lib/components/SearchInput.svelte` | Header search box; debounced URL sync via `goto`; pre-fills from `page.url.searchParams` |
| `web/src/lib/components/SearchSkeleton.svelte` | Three-section placeholder (artist list / album grid / track list shapes) for `/search` loading state |
| `web/src/routes/search/+page.svelte` | Main results page — reads `?q=`, fires search query, renders three sections |
| `web/src/routes/search/artists/+page.svelte` | Artists overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/albums/+page.svelte` | Albums overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/tracks/+page.svelte` | Tracks overflow with `createInfiniteQuery` + Load more |
| `web/src/lib/components/SearchInput.test.ts` | Debounce / pre-fill / clear behavior |
| `web/src/routes/search/search.test.ts` | Page integration: 3 sections, empty hide, "See all" link, no-results, empty `?q=` prompt |
| `web/src/routes/search/artists/artists.test.ts` | Overflow tests (analogous for `albums.test.ts`, `tracks.test.ts`) |
| `internal/api/radio.go` | Stub handler |
| `internal/api/radio_test.go` | Stub tests (200/404/400) |
### Modified
| Path | Change |
|---|---|
| `web/src/lib/components/Shell.svelte` | Insert `<SearchInput />` in the header between brand and user-menu |
| `web/src/lib/components/TrackRow.svelte` | Refactor outer element from `<button>` to `<div role="button" tabindex="0">` with keyboard handlers (Enter/Space) so a real `<button>` for `+ queue` can nest inside without invalid HTML. Add optional `onPlay?: (track: TrackRef, index: number) => void` prop; default behavior remains `playQueue(tracks, index)` |
| `web/src/lib/components/TrackRow.test.ts` | Update for `role=button` keyboard activation; add `+ queue` click test; add `onPlay` override test |
| `web/src/lib/components/AlbumCard.svelte` | Wrap `<a href="/albums/:id">` around cover+title; add an absolutely-positioned `+ queue` button as a sibling. Click on the link still navigates; click on `+` calls `event.preventDefault()` + `event.stopPropagation()` then `api.get<AlbumDetail>('/api/albums/' + album_id)` (one-shot fetch, not the live `createAlbumQuery` store) and calls `enqueueTracks(detail.tracks)` |
| `web/src/lib/components/AlbumCard.test.ts` | Add `+ queue` click test |
| `web/src/lib/api/types.ts` | Add `SearchResponse`, `RadioResponse` |
| `web/src/lib/api/queries.ts` | Add `createSearchQuery(q)`, `createSearchArtistsInfiniteQuery(q)`, `createSearchAlbumsInfiniteQuery(q)`, `createSearchTracksInfiniteQuery(q)` |
| `web/src/lib/player/store.svelte.ts` | Add `enqueueTrack(t)`, `enqueueTracks(ts)`, `playRadio(seedTrackId)` actions |
| `web/src/lib/player/store.test.ts` | Cover `enqueueTrack` / `enqueueTracks` / `playRadio` |
| `internal/api/api.go` | Register `r.Get("/api/radio", h.handleRadio)` |
## URL & state behavior
- `SearchInput` debounces 250 ms before navigating. Each settled value either pushes (first nav from elsewhere) or replaces (already on `/search`) the URL.
- Empty input on `/search``goto('/search')` (drops `?q=`).
- `Enter` blurs the input; no extra nav.
- `Escape` clears `value` (which then navigates per the rules above).
- `/search/+page.svelte` reads `q` via `$derived((page.url.searchParams.get('q') ?? '').trim())`. When `q === ''`, no query is created (empty-state copy renders).
- Overflow pages read `q` the same way; they show their own empty state copy when `q === ''`.
- TanStack Query caches per-`q` results, so backspacing through previous values returns instantly.
## States
| Condition | Render |
|---|---|
| `q === ''` (main page) | "Start typing to search artists, albums, and tracks." |
| `q` non-empty AND `useDelayed(() => query.isPending) === true` AND no cached data | `SearchSkeleton` |
| `query.isError` | `ApiErrorBanner` with `onRetry={query.refetch}` |
| All three facets `items.length === 0` | "No matches for '<q>'." |
| Otherwise | Up to three sections; empty facets are skipped |
Overflow pages: same skeleton/error/empty pattern (using `LibrarySkeleton` for the per-facet variant). "Load more" button hides when `hasNextPage === false`.
## Player-store additions
```ts
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}
```
`enqueueTrack`/`enqueueTracks` deliberately do NOT change `_state` — appending while playing should not pause; appending while paused should not auto-play. The `idle → 0` reset only matters when the queue was previously empty so that `_index` points at a valid position; the rest of the playback machinery (`+layout.svelte` audio effects, MediaSession glue) reacts to `_state` and `player.current` and continues to work unchanged.
## Testing
**Server (`internal/api/radio_test.go`):**
- 200 success path with valid `seed_track` (response shape, single-item array, full `TrackRef`).
- 404 on nonexistent track id.
- 400 on missing/empty `seed_track`.
**Player store (`store.test.ts`):**
- `enqueueTrack` appends; preserves `_index` when queue was non-empty; sets `_index=0` when state was `'idle'`.
- `enqueueTracks([])` no-ops; non-empty concatenates.
- `playRadio` (mocked `api.get`) hits `/api/radio?seed_track=<id>` and forwards response to `playQueue`.
**`SearchInput.test.ts`:**
- Single keystroke fires `goto` once after 250 ms (`vi.useFakeTimers` + `flushSync`).
- Burst of keystrokes in <250 ms fires `goto` once after settle.
- Empty input on `/search` calls `goto('/search')`.
- Mount pre-fills `value` from `page.url.searchParams.get('q')`.
**`search/search.test.ts`:**
- All three sections render when all three facets have items.
- Empty facets are hidden.
- "See all N →" links render when `total > items.length`.
- "No matches" copy renders when all three facets are empty.
- Empty `?q=` shows "Start typing" prompt and fires NO query.
**`search/artists/artists.test.ts`** (and analogous `albums.test.ts`, `tracks.test.ts`):
- Items render.
- "Load more" calls `fetchNextPage`.
- Button hides when `hasNextPage === false`.
**`TrackRow.test.ts` (updates):**
- `role=button` Enter/Space activation calls the play handler.
- Click on the nested `+` button calls `enqueueTrack` and does NOT trigger the row's play handler.
- `onPlay` prop overrides the default `playQueue` behavior.
**`AlbumCard.test.ts` (additions):**
- Click on `+` calls `enqueueTracks(album.tracks)` after fetching album detail; does NOT navigate.
- Click on the card area still navigates to `/albums/:id`.
**Manual end-to-end** (verified during the plan's final task):
1. From any page, type "miles" in the header bar → URL becomes `/search?q=miles`, three sections appear.
2. Click a track → radio plays (one-track queue today via the stub); the bottom bar shows the seed.
3. Click `+` on another track → appended to queue (verify queue length increments via `player.queue.length` in dev tools).
4. Click `+` on an album card → all the album's tracks append; current playback uninterrupted.
5. Click "See all 47 artists →" → overflow page loads; "Load more" pulls additional pages.
6. Click an artist → navigates to `/artists/:id`; the header search bar still shows "miles".
7. Browser back → returns to `/search?q=miles` with cached results.
8. Backspace through the input → URL clears; main page returns to "Start typing" prompt.
## Risks & mitigations
- **Radio stub looks broken.** A click on a track currently produces a one-track queue, which feels like "click did nothing meaningful." Mitigation: skeleton MVP framing; the contract is forward-compatible and the M4 task will deliver the real candidate-pool logic. The fallback playback works; the limitation is honesty about scope.
- **TrackRow refactor regresses album page.** The `<button>``<div role="button">` change touches an already-shipped surface. Mitigation: existing TrackRow tests run as part of the slice; the album-page tests (`src/routes/albums/[id]/album.test.ts`) re-run; manual click + keyboard activation verified before the slice ships.
- **Header search bar squeezes mobile layout.** Mitigation: the input gets `flex-1 min-w-0` so it shrinks gracefully; deeper mobile polish is a separate slice.
- **Per-keystroke `goto` history pollution.** Already mitigated via `replaceState` once the user is on `/search`.
- **Over-fetch on overflow pages.** Each overflow page receives all three facets in the response. Acceptable: the request is one round-trip and the unused fields are bounded by `limit=50` × 2 facets = ≤ 100 unused records. Migrating to per-facet endpoints later is a backward-compatible move if it ever becomes load-bearing.
@@ -1,242 +0,0 @@
# M2 Likes Sub-Plan — Design Spec
**Status:** approved 2026-04-26
**Slice:** M2 (Events, sessions, general likes), spec §13 step 6 + scope extension. The original spec only covers track-level `general_likes`; this slice extends to album and artist starring for full Subsonic compatibility.
**Fable tasks:** #326 (server + Subsonic) and #327 (web UI heart + Liked view); closes M2 along with #328 (milestone gate verification).
## Goal
Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page.
## Non-goals
- Per-context likes, session vector capture on like (`contextual_likes`) — that's M3 territory; the table already exists from migration `0005_events`.
- Aggregate `artist_preferences` (computed weighted signal) — M3.
- Liked-state propagation via WebSocket / SSE. Subsonic-driven changes only show up after the next refetch (manual reload or TanStack's window-focus refetch).
- Bulk operations (like-all-from-album, etc.). Single-entity like/unlike only.
- Sorting controls on `/library/liked`. Default `liked_at DESC` is the only order in v1.
- Importing likes from Last.fm / ListenBrainz / Spotify. v1 only persists what the user does in this server.
## Architecture
Three new tables — one per entity type — keyed on `(user_id, entity_id)` with `liked_at timestamptz`. Three native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus a single `/api/likes/ids` endpoint that returns flat id sets for client-side heart-rendering. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` handlers route through the same per-table writes.
The web client exposes one `LikeButton.svelte` component that takes `(entityType, entityId)` and reads from a single `createLikedIdsQuery()` TanStack Query whose data shape is `{ track_ids: string[], album_ids: string[], artist_ids: string[] }`. Mutations do optimistic `setQueryData` updates with rollback on error. Heart placements: `TrackRow`, `AlbumCard`, `ArtistRow`, `PlayerBar`. New `/library/liked` page with three sections backed by infinite queries.
**No `liked` flag on entity refs.** Server doesn't add a join to every track/album/artist response. Liked state is computed on the client via O(1) Set lookups from the cached `likedIdsQuery`. Single source of truth; cache is shared across all hearts in the app; mutations invalidate it once and every component re-renders.
## Schema (migration `0006_likes.up.sql`)
```sql
CREATE TABLE general_likes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
CREATE TABLE general_likes_albums (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, album_id)
);
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
CREATE TABLE general_likes_artists (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, artist_id)
);
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
```
Down migration drops all three tables.
## API contracts
### Native `/api/likes/...`
```ts
// Idempotent upsert. 204 on success, 404 if entity doesn't exist, 400 on bad UUID.
POST /api/likes/tracks/:track_id 204
POST /api/likes/albums/:album_id 204
POST /api/likes/artists/:artist_id 204
// Idempotent delete. 204 regardless of prior state.
DELETE /api/likes/tracks/:track_id 204
DELETE /api/likes/albums/:album_id 204
DELETE /api/likes/artists/:artist_id 204
// Paginated detail listings, sorted by liked_at DESC.
GET /api/likes/tracks?limit=&offset= Page<TrackRef>
GET /api/likes/albums?limit=&offset= Page<AlbumRef>
GET /api/likes/artists?limit=&offset= Page<ArtistRef>
// Flat id set for heart-button rendering.
GET /api/likes/ids { track_ids: string[], album_ids: string[], artist_ids: string[] }
```
All gated by the existing `RequireUser` middleware. `user_id` comes from request context; nothing in the URL or body identifies the user. Cross-user isolation is enforced by the queries (`WHERE user_id = $1`).
### Subsonic `/rest/...`
```
GET /rest/star?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/unstar?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/getStarred → <starred> with song/album/artist children
GET /rest/getStarred2 → <starred2> (OpenSubsonic JSON variant)
```
`star`/`unstar` accept any combination of `id`, `albumId`, `artistId` (zero or more of each). For each present param, upsert (or delete) the matching row in the matching table. Missing-entity → standard Subsonic `<error code="70">` (data not found). Repeated params (e.g. `id=A&id=B`) — accept the first occurrence only for v1; document as known limitation.
`getStarred` and `getStarred2` return the user's stars under a `<starred>` (or `starred2` JSON) wrapper containing arrays of `song`, `album`, `artist` children, each sorted `liked_at DESC`. The existing `TrackRef`/`AlbumRef`/`ArtistRef` Subsonic serialization (used by search3, getAlbum, getArtist) is reused here.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Three tables + indexes. |
| `internal/db/queries/likes.sql` | sqlc queries: `Insert*Like`, `Delete*Like`, `List*Likes` (paginated), `Count*Likes`, `List*LikedIds` for each of the three tables. |
| `internal/db/dbq/likes.sql.go` | Generated bindings. |
| `internal/api/likes.go` | Handlers for all seven native routes. |
| `internal/api/likes_test.go` | HTTP-level coverage. |
| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. |
| `internal/subsonic/star_test.go` | Subsonic coverage. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/api.go` | Register `/api/likes/{tracks,albums,artists}/:id` (POST + DELETE), `/api/likes/{tracks,albums,artists}` (GET, paginated), `/api/likes/ids` (GET). Inside the authed group. |
| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2` in the Mount table. |
### New web files
| Path | Responsibility |
|---|---|
| `web/src/lib/api/likes.ts` | TanStack Query helpers: `createLikedIdsQuery()` and per-entity infinite-query factories. Mutation factories: `likeTrack(id)`, `unlikeTrack(id)`, plus album and artist variants. Each mutation does optimistic update via `setQueryData` and rolls back on error. |
| `web/src/lib/api/likes.test.ts` | Cache-update unit tests. |
| `web/src/lib/components/LikeButton.svelte` | Heart icon. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. Reads `createLikedIdsQuery()`; click toggles via the right mutation; `event.stopPropagation()` so it doesn't bubble to `TrackRow`'s row-click. |
| `web/src/lib/components/LikeButton.test.ts` | Renders correct fill state; click toggles correctly per entity type; click stops propagation. |
| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists / Albums / Tracks) backed by infinite queries. |
| `web/src/routes/library/liked/liked.test.ts` | Page integration. |
### Modified web files
| Path | Change |
|---|---|
| `web/src/lib/api/types.ts` | Add `LikedIdsResponse = { track_ids: string[]; album_ids: string[]; artist_ids: string[] }`. |
| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks(q)`, `qk.likedAlbums(q)`, `qk.likedArtists(q)` keys. Re-export from `likes.ts` (or leave them in their own module — match prior convention). |
| `web/src/lib/components/TrackRow.svelte` | Insert `<LikeButton entityType="track" entityId={track.id} />` next to the existing `+ queue` button. Grid template grows to `[32px_1fr_auto_auto_auto]`. |
| `web/src/lib/components/AlbumCard.svelte` | Add a second overlay button row beneath the existing `+ queue` overlay (or stack them vertically) with `<LikeButton entityType="album" entityId={album.id} />`. |
| `web/src/lib/components/ArtistRow.svelte` | Add `<LikeButton entityType="artist" entityId={artist.id} />` to the row's right side, before the chevron. |
| `web/src/lib/components/PlayerBar.svelte` | Add `<LikeButton entityType="track" entityId={current.id} />` next to the title in the left cluster (cover + title + artist link). |
| `web/src/lib/components/Shell.svelte` | Add a "Liked" entry to `navItems` pointing at `/library/liked`. |
## Data flow
**Like a track from the album page:**
1. User clicks heart on a `TrackRow`.
2. `LikeButton` reads `createLikedIdsQuery()` data; sees `track_ids` doesn't include this id; fires `likeTrack(id)` mutation.
3. Mutation does optimistic `setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))` so heart fills instantly. Then `POST /api/likes/tracks/:id`.
4. Server inserts into `general_likes` with `ON CONFLICT DO NOTHING`. Returns 204.
5. Mutation `onSuccess` invalidates `qk.likedIds` and `qk.likedTracks` (so `/library/liked` refetches if visible). `onError` rolls back the optimistic update from a `previousIds` snapshot captured in `onMutate`.
**Star from a Subsonic client:**
1. Client POSTs `/rest/star?id=<track_uuid>&u=admin&t=...`.
2. `internal/subsonic/star.go::handleStar` resolves auth, parses any combination of `id`/`albumId`/`artistId`, upserts each non-empty into the matching table, returns `ok` envelope.
3. Web SPA's `createLikedIdsQuery()` doesn't see it until next refetch (TanStack default refetch-on-window-focus or manual invalidation). Acceptable — Subsonic-driven changes flow through eventually.
**View liked items:**
1. User clicks "Liked" → `/library/liked`.
2. Page mounts three `createInfiniteQuery` instances. Each fires `GET /api/likes/{type}?limit=50&offset=0`.
3. Server SQL: e.g. `SELECT t.* FROM tracks t JOIN general_likes l ON l.track_id = t.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC LIMIT $2 OFFSET $3`. Plus `SELECT count(*) FROM general_likes WHERE user_id = $1` for the `total`.
4. Returns `Page<TrackRef>` (and analogous for albums/artists).
5. Page renders three sections; each has its own "Load more" per the existing infinite-query convention. Empty section shows "No liked X yet".
**Heart on PlayerBar:**
`<LikeButton entityType="track" entityId={current.id} />` — same component as TrackRow's heart. The fill state is reactive: when `player.current` changes, the derived id-membership check re-runs against the cached id set.
**Liked-state propagation across the app:** every heart subscribes to the same `qk.likedIds` query. A mutation invalidation triggers all of them to re-render. No prop drilling or pub-sub needed.
## States
| Condition | Render |
|---|---|
| `likedIdsQuery.isPending` | Heart is disabled and shows a neutral state (outline). Action defers; quick to load. |
| `likedIdsQuery.isError` | Heart shows outline; clicks are no-ops; no error toast (likes are non-critical). |
| Set contains entity id | Filled heart, `aria-pressed="true"`. |
| Set does NOT contain entity id | Outline heart, `aria-pressed="false"`. |
`/library/liked` page states (per section, reusing patterns from search):
- `q.isPending && items.length === 0``LibrarySkeleton` for that section
- `q.isError``ApiErrorBanner`
- `total === 0` → "No liked X yet" hint
- otherwise → list/grid + "Load more" if `hasNextPage`
## Testing
### Server (`go test`)
- **Migration smoke** — applying `0006_likes` round-trips the three tables; insert+select for each.
- **Native handlers** (`internal/api/likes_test.go`):
- Like-track happy path (204; row exists; idempotent — 204 on repeat).
- Like with malformed UUID → 400; nonexistent UUID → 404.
- Unlike happy path (204; row gone; idempotent).
- List endpoint returns paginated `Page<TrackRef>` sorted `liked_at DESC`; `total` reflects user's full liked set; `limit`/`offset` work.
- `GET /api/likes/ids` returns three string arrays containing exactly the ids of the user's rows.
- Cross-user isolation — Alice likes track X, Bob's `/api/likes/ids` does not contain X.
- One coverage case per entity type (album, artist) on the like+unlike+list+ids paths.
- **Subsonic handlers** (`internal/subsonic/star_test.go`):
- `star?id=<track>` writes to `general_likes`, returns `ok` envelope.
- `star?albumId=<album>&artistId=<artist>` writes to both album and artist tables in one call.
- `star?id=<unknown>` returns Subsonic error 70 (data not found).
- `unstar` removes the row, returns `ok`. Idempotent.
- `getStarred2` returns artist/album/song arrays correctly populated, sorted `liked_at DESC`. Cross-user isolation verified.
### Web (`vitest`)
- **`likes.test.ts`** — TanStack Query factory + mutations:
- `createLikedIdsQuery` shape (`{ track_ids, album_ids, artist_ids }`).
- `likeTrack(id)` optimistic update inserts id into `track_ids`; mocked failure rolls back.
- `unlikeTrack(id)` removes id; rollback restores.
- Same coverage for album/artist mutations.
- **`LikeButton.test.ts`**:
- Renders empty heart when id is NOT in the matching set; filled heart when it IS.
- Click on empty heart fires the like mutation (mocked).
- Click on filled heart fires the unlike mutation.
- Click stops propagation (doesn't bubble to parent `<div role="button">` on TrackRow).
- **`liked.test.ts`** (`/library/liked` page):
- Three sections render with mocked infinite queries.
- Empty section shows "No liked X yet" hint (gate on `total === 0` per the search-page pattern).
- "Load more" calls `fetchNextPage` per section.
- **`TrackRow.test.ts` / `AlbumCard.test.ts` / `ArtistRow.test.ts` / `PlayerBar.test.ts`** updates: each gets one new test asserting `<LikeButton>` is rendered with the right `entityType` + `entityId`. The button's behavior is covered in `LikeButton.test.ts`; we just verify wiring.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Click heart on a track → fills instantly. Refresh page → still filled. `psql` shows the row in `general_likes`.
2. Click heart again → empties. Row gone.
3. Click heart on an album card. Click heart on an artist row. Verify `general_likes_albums` and `general_likes_artists` rows.
4. Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
5. Unstar from Subsonic. Web SPA's heart empties on next refetch.
6. Visit `/library/liked`. Three sections render with the items above. "Load more" works once you have > 50 of any type.
7. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered.
## Risks & mitigations
- **Likes diverge from Subsonic on partial failures.** If a Subsonic client sends `star?id=A&albumId=B` and the album doesn't exist, the track gets starred but album fails. Mitigation: validate all entity ids first; if any is missing, return error 70 BEFORE writing any. Atomicity matters less than predictability for clients.
- **`createLikedIdsQuery` payload grows unbounded for power users.** For 10k liked tracks, the array is ~370 KB — acceptable. For 100k+ users it becomes a concern. Mitigation: not a v1 problem; document and revisit if telemetry shows large libraries. A per-entity-type "is liked" check endpoint would be a clean upgrade path.
- **Optimistic update / refetch race.** If a mutation is in-flight when a refetch lands, the rollback might overwrite the actual server state with stale "previous" data. TanStack's mutation flow handles this — `onError` only fires on actual error, and the `setQueryData` we do in `onMutate` is the optimistic value, not stale.
- **Cross-table star with mixed presence.** `star?id=&albumId=` where one exists and the other doesn't — option A: 404 the whole call; option B: succeed for the existing one, ignore the missing one. We pick A (atomicity) per the first risk above. Validation pass before any insert.
- **`liked_at` clock skew across replicas.** Single-instance deployment for v1 — defer.
@@ -1,230 +0,0 @@
# M3 Weighted Shuffle v1 — Design Spec
**Status:** approved 2026-04-27
**Slice:** M3 sub-plan #1 of 3 (recommendation engine v1 + contextual likes). Spec §13 step 7. Step 8 (session vectors + contextual_match_score) is the next sub-plan; this slice ships the scoring foundation without the contextual term.
**Fable task:** #340.
## Goal
Replace the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. After this slice, clicking a track in the SPA's search/track-row "play radio" surface produces a meaningful 50-track queue instead of a one-track stub.
## Non-goals
- Session vectors / `contextual_match_score` (sub-plan #2 / Fable #341 covers the write path; sub-plan #3 / Fable #342 folds the score into this function).
- Real "radio" candidate pools from ListenBrainz similarity / similar artists / similar tags (M4).
- Album-seed or artist-seed radio (`?seed_album=`, `?seed_artist=`). v1 is track-seeded only; the Fable task covers that scope explicitly.
- Periodic radio refresh (the spec's "regenerate at 80% consumed"). Player-side concern; lands later.
- Persistent radio queues. Each `/api/radio` call returns a fresh selection — stateless on the server.
- Performance optimization for very large libraries (>50k tracks). At v1 scale the wide-pool scan is fine; M3.5 / M4 can add sampling if telemetry shows it matters.
- `Subsonic` star-radio compatibility. Subsonic's `getRandomSongs` is a different shape; we don't try to map weighted shuffle into it for v1.
## Architecture
A new `internal/recommendation` package owns:
- A pure scoring function `Score(inputs, weights, now, rng) → float64` with no DB dependency. The `rng` parameter is injectable so tests pin jitter to deterministic values.
- A candidate loader `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` that runs ONE SQL query joining `tracks`, `general_likes`, and aggregated `play_events` to produce the per-track stats the scoring function needs. Excludes the seed and any track played within the recently-played window.
- A `Shuffle(candidates, weights, now, rng, limit)` orchestrator that scores each candidate, sorts descending, truncates to `limit`. Pure.
The `/api/radio` handler becomes a thin shim: validate the seed → call `LoadCandidates` → call `Shuffle` → prepend the seed to the result → project to `[]TrackRef` → return JSON.
**Why split into three layers:**
- `score.go` is the math; trivially unit-testable, no infra.
- `candidates.go` is the data access; integration-tested against a live DB.
- `shuffle.go` is the composition; pure-function tests against fake candidate sets.
- The HTTP handler is the I/O glue; lives in `api/radio.go` like the existing pattern.
This separation also sets up sub-plan #3 (contextual scoring) cleanly — we'll add `LoadContextualSimilarity` next to `LoadCandidates`, extend `ScoringInputs` with `ContextualMatchScore`, and the rest of the chain doesn't change.
## Schema
No migration. All inputs are computed on demand from existing tables:
- `general_likes` for `is_general_liked`.
- `play_events` for `last_played_at`, `play_count`, `skip_count`. Aggregations done in the candidate-loader query.
- `tracks` provides the candidate set itself.
The recently-played-in-the-last-hour filter is applied at candidate-load time (a single `WHERE NOT EXISTS (... AND started_at > now() - interval '1 hour')` clause). Hard suppression — these tracks aren't even scored.
## Scoring math
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
}
type ScoringWeights struct {
BaseWeight float64 // default 1.0
LikeBoost float64 // default 2.0
RecencyWeight float64 // default 1.0
SkipPenalty float64 // default 1.0
JitterMagnitude float64 // default 0.1
}
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
s := w.BaseWeight
if in.IsGeneralLiked {
s += w.LikeBoost
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
```
**`recencyDecay(lastPlayed *time.Time, now time.Time) float64`** — returns `[0, 1]`:
- Never played (`lastPlayed == nil`) → `1.0`. Cold-start tracks compete favorably with stale ones; otherwise the recommendation engine never surfaces music the user hasn't tried.
- Played within last hour: doesn't reach this function (filtered earlier at the candidate-pool stage).
- Otherwise: `min(age_days / 30.0, 1.0)`. Linear ramp; tracks ≥ 30 days stale hit max recency boost.
**`skipRatio(plays, skips int) float64`** — returns `[0, 1]`:
- `plays == 0``0.0`. Don't penalize never-played tracks.
- Otherwise → `float64(skips) / float64(plays)`.
**Random jitter**`(rng()*2 - 1) * JitterMagnitude` produces values in `[-magnitude, +magnitude]`. The `rng` parameter is `func() float64` (matches `math/rand.Float64`); tests inject a fixed-value version for deterministic ordering.
**Score range under defaults:**
- Min (unliked, recent, all-skips): `1.0 + 0 + 0 - 1.0 - 0.1 = -0.1`
- Max (liked, ≥30d stale, never skipped): `1.0 + 2.0 + 1.0 - 0 + 0.1 = 4.1`
- Liked-track structural advantage (`LikeBoost = 2.0`) dominates the jitter band (`±0.1`), so liked tracks always rank above identical unliked tracks.
Configurable via YAML / env. Operators can crank `LikeBoost` up if their library is dominated by stuff they don't actually like, or `JitterMagnitude` up if they want more variety.
## API contracts
**Request:**
```
GET /api/radio?seed_track=<uuid>&limit=<int>
```
- `seed_track` (required) — UUID of the track that seeds the radio.
- `limit` (optional, default 50, max 200) — total number of tracks to return *including* the seed.
**Response (shape unchanged from M2 stub, only contents grew):**
```ts
type RadioResponse = { tracks: TrackRef[] };
```
- `tracks[0]` is always the seed track (so `playQueue(resp.tracks, 0)` plays the seed).
- `tracks[1..]` are the top-scored candidates from the user's library, descending. Length up to `limit - 1`.
- Cold-start (empty library beyond the seed): returns `{ tracks: [<seed>] }`.
**Errors (unchanged from M2 stub):**
- `400 bad_request` — missing `seed_track`, malformed UUID, or `limit < 1`.
- `404 not_found``seed_track` doesn't exist.
- `500 server_error` — DB issue.
**No web client changes.** The existing `playRadio(seedTrackId)` already calls this endpoint and feeds `resp.tracks` into `playQueue(tracks, 0)`. After this slice the queue is fuller; the call shape is identical.
**No Subsonic changes.** Track-seeded radio isn't part of Subsonic's surface in our v1 scope.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/recommendation/score.go` | Pure scoring function + `recencyDecay` + `skipRatio` helpers. No DB. |
| `internal/recommendation/score_test.go` | Boundary cases: every term, cold-start, jitter determinism, score ranges. |
| `internal/recommendation/candidates.go` | `LoadCandidates(...)` — single SQL query returning `[]Candidate` (`Track + ScoringInputs`). |
| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. |
| `internal/recommendation/shuffle.go` | `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. Pure. |
| `internal/recommendation/shuffle_test.go` | Pure-function tests: liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder-structural-winners, limit. |
| `internal/db/queries/recommendation.sql` | sqlc query: `LoadRadioCandidates` — SELECT tracks WITH LEFT JOIN general_likes, LEFT JOIN aggregated play_events stats. WHERE clause excludes seed_id and last-hour plays. |
| `internal/db/dbq/recommendation.sql.go` | Generated bindings. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/radio.go` | Replace stub. New flow: validate `seed_track` and `limit`, call `recommendation.LoadCandidates`, call `recommendation.Shuffle`, prepend seed, project to `[]TrackRef`, return JSON. |
| `internal/api/radio_test.go` | Replace stub tests with: cold-start, typical (seed + scored picks), 404 on unknown seed, 400 on bad seed/limit, cross-user isolation. |
| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` struct: `BaseWeight` (1.0), `LikeBoost` (2.0), `RecencyWeight` (1.0), `SkipPenalty` (1.0), `JitterMagnitude` (0.1), `RecentlyPlayedHours` (1), `RadioSize` (50), `RadioSizeMax` (200). YAML key `recommendation:`. |
| `internal/api/api.go` | `handlers` struct gains `recCfg config.RecommendationConfig`. `Mount` signature gains the config arg; constructs the handler with it; the radio handler builds `ScoringWeights` from it per request. |
| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. |
### No web changes
The existing `playRadio(seedTrackId)` already consumes `RadioResponse`. The web slice for this PR is empty — server-only.
## Data flow
1. SPA calls `playRadio(seedTrackId)` (existing player-store action). It POSTs `GET /api/radio?seed_track=<id>` (no explicit `limit`, server defaults to 50).
2. `handleRadio` validates auth + seed UUID. Looks up the track to confirm it exists (404 otherwise).
3. Reads `limit` from query (default `cfg.Recommendation.RadioSize`, clamped to `cfg.Recommendation.RadioSizeMax`).
4. Calls `recommendation.LoadCandidates(ctx, q, userID, seedID, cfg.Recommendation.RecentlyPlayedHours)`. The SQL query joins:
- `tracks t` — base set
- `LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id``is_liked`
- `LEFT JOIN LATERAL (SELECT max(started_at) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_last → last_played_at`
- `LEFT JOIN LATERAL (SELECT count(*), count(*) FILTER (WHERE was_skipped) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_stats → play_count, skip_count`
- `WHERE t.id <> seed_id AND NOT EXISTS (SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - interval '$2 hours')`
5. Iterates the candidates, calls `Score(...)` per track, sorts descending, truncates to `limit - 1`.
6. Prepends the seed track, projects each `Candidate.Track` to `TrackRef` (existing `trackRefFrom` helper), writes `RadioResponse{ Tracks: ... }`.
7. SPA receives 50 tracks, plays from index 0 (the seed).
**Stateless** — every call recomputes from scratch. No persisted radio queue, no cursor tracking, no "you already heard this" memory beyond the recently-played-hours window.
## Testing
### Server (`go test`)
**`internal/recommendation/score_test.go`** (pure unit tests):
- Base case (never played, not liked, no skip data) with deterministic RNG `() => 0.5` → score = `BaseWeight + RecencyWeight + 0` exactly.
- Liked boost: same inputs but `IsGeneralLiked=true` → score increases by `LikeBoost`.
- Recency ramp: `lastPlayedAt = now - 15d``recencyDecay = 0.5`. `lastPlayedAt = now - 60d``1.0` (capped).
- Skip ratio: `PlayCount=4, SkipCount=2` → ratio `0.5`, score loses `0.5 * SkipPenalty`.
- Cold-start skip: `PlayCount=0, SkipCount=0` → ratio `0.0`, no penalty.
- Jitter bounds: 1000 `Score(...)` calls with `math/rand.Float64`, every result within `[mid - JitterMagnitude, mid + JitterMagnitude]` where `mid` is the deterministic-RNG score.
- Determinism: same `(inputs, weights, now, rng)` returns the same score (regression guard for hidden global state).
**`internal/recommendation/shuffle_test.go`** (pure unit tests):
- Liked-vs-not: two otherwise-identical candidates → liked one ranks higher.
- High-skip rejected: candidate with `skipRatio=1.0` ranks last among otherwise-identical candidates.
- Limit truncates: 100 candidates, `limit=10` → result has 10.
- Jitter doesn't reorder structural winners: 100 random RNG seeds → liked track ALWAYS ranks above an equivalent unliked track.
- Empty input → empty output.
**`internal/recommendation/candidates_test.go`** (live DB):
- Seed exclusion: 5 tracks in library, ask for radio with one as seed → returns the other 4.
- Recently-played exclusion: seed a `play_events` row with `started_at = now - 30 minutes` for track A → radio excludes A.
- Stat join: track with one play + one skip → `play_count=1, skip_count=1`. Liked track → `is_liked=true`. Never-played → `last_played_at IS NULL`.
- Cross-user: Alice's plays / likes do NOT appear in Bob's stats.
**`internal/api/radio_test.go`** (HTTP integration, replacing existing stub tests):
- Cold-start: seed only in library → response is `{ tracks: [seed] }`.
- Typical: seed + 5 other tracks → response is `[seed, 5 ranked]`. Seed always at index 0.
- 404 on unknown seed.
- 400 on missing/malformed seed.
- 400 on `limit=0` or `limit < 0`.
- `limit > RadioSizeMax` clamped (returns at most `RadioSizeMax`, no error).
- Cross-user isolation: Alice has many plays + likes → Bob's radio uses Bob's clean stats.
**Coverage:** `go test -coverprofile=cover.out ./internal/recommendation/...` → ≥ 70% per the M3 milestone description. The pure-function tests should hit ~100% on `score.go` + `shuffle.go`; `candidates.go` reaches similar coverage via the integration tests.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Play 3 tracks all the way through (build `play_count` history).
2. Skip a 4th track within 10 seconds (creates a high `skip_ratio`).
3. Like 2 tracks via the heart button.
4. Click radio on a 5th track.
5. Inspect the response in dev tools network tab — 50 tracks, seed first.
6. The 2 liked tracks appear early in the list (within first ~20).
7. The just-skipped track appears late or absent.
8. The 3 just-played tracks are absent (recently-played exclusion).
9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently.
## Risks & mitigations
- **Wide-pool scan performance.** For a 50k-track library: 50k rows × scoring overhead × sort ≈ a few hundred ms in Go. Below the user-perceptible threshold for a click-to-play. If telemetry later shows >1s P95 latency, M3.5 / M4 can add sampling (random-N candidate pre-filter) or a partial index. The candidate-pool function is the swap point; the scoring function and HTTP handler don't change.
- **Recently-played window edge effects.** If the user plays 50 tracks in an hour, all of them get excluded from the next radio call. Library < 100 tracks could leave the candidate pool too thin to fill `RadioSize`. Mitigation: handler returns whatever it can fit (length might be < `RadioSize`); SPA's `playQueue` works on any non-empty array. Document as acceptable degradation; users with tiny libraries get short radios.
- **Skip-ratio gaming.** A user who skips a track once (during initial library discovery) gets a `skipRatio=1.0` for that track. With `SkipPenalty=1.0` that's a `-1.0` hit — could permanently demote the track even if they like it. Mitigation: weights are tunable; user can lower `SkipPenalty`. Future: smoothing (e.g. `(skips + α) / (plays + β)`) instead of raw ratio. Out of scope for v1.
- **Cold-start RNG dominance.** A brand-new user with zero plays / likes gets every track scored at `BaseWeight + RecencyWeight + jitter`. Top-N is essentially random. That's fine — it matches user expectation ("I haven't told you anything about me, give me random music"). The recency-decay max-for-never-played avoids amplifying new tracks just because they're new.
- **Score formula evolution.** Adding `contextual_match_score` in sub-plan #3 means changing `ScoringInputs` and the `Score` function signature. Mitigation: the function is internal-package only; sub-plan #3 changes both ends of the call atomically. No external consumers.
@@ -1,301 +0,0 @@
# M3 sub-plan #3 — Session similarity + contextual_match_score in scoring
**Status:** Spec draft, 2026-04-27
**Tracking:** Fable #342
**Closes:** M3 milestone (recommendation engine v1)
**Builds on:**
- #340 — weighted shuffle baseline (`internal/recommendation` package, `Score`/`Shuffle`, `LoadRadioCandidates`)
- #341 — session vectors at play_started + `contextual_likes` capture on like
## 1. Goal
Add the `contextual_match_score` term to the recommendation scoring formula. For each
candidate track, compute the maximum weighted-Jaccard similarity between the user's
*current* session vector and the session vectors stored on that track's active
`contextual_likes` rows. Fold that scalar into `Score()` as
`+ contextual_match_score * ContextWeight`.
When this slice ships, `/api/radio` produces context-aware recommendations: tracks the
user has previously liked while in similar musical contexts get an additive boost on
top of the v1 weighted-shuffle baseline.
## 2. Non-goals
- No new UI surface — `/api/radio` response shape is unchanged.
- No tag enrichment beyond `tracks.genre` (MBID tags / BPM remain post-v1).
- No similarity-axis weight exposure in YAML (hardcoded `0.7 / 0.3` for v1).
- No caching of the current session vector across requests.
- No "why this track?" debug endpoint.
- No ListenBrainz / external-similarity retrieval (M4).
- No GIN index on `play_events.session_vector_at_play` — we read the user's most
recent play by id, not by similarity. Existing `(user_id, started_at)` access
pattern is sufficient.
## 3. Architecture overview
Three additions to `internal/recommendation`, two adjustments to existing files,
one new sqlc query.
### 3.1 New: `internal/recommendation/similarity.go` (pure)
```go
type SimilarityWeights struct {
TagsWeight float64
ArtistsWeight float64
}
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
// Hardcoded; not exposed via YAML — operators can't tune this for v1.
var DefaultSimilarityWeights = SimilarityWeights{
TagsWeight: 0.7,
ArtistsWeight: 0.3,
}
// Similarity returns weighted-Jaccard similarity in [0, 1]. Returns 0 if
// either input is Seed=true (low-confidence vectors don't contribute).
func Similarity(a, b SessionVector, w SimilarityWeights) float64
// ContextualMatchScore is the per-candidate scalar fed into ScoringInputs.
// Returns 0 if current.Seed is true OR likes (after filtering Seed=true
// entries) is empty. Otherwise: max(Similarity(current, l) for l in likes).
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64
```
**Per-axis semantics (classic set Jaccard):**
- Tags axis flattens `map[string]int` keysets and computes `|A ∩ B| / |A B|`.
Bag-of-counts data is preserved on disk; we discard counts at similarity time.
Generalized Jaccard remains a one-line upgrade path if telemetry justifies it.
- Artists axis is already a `[]string` (deduplicated artist UUIDs); same Jaccard.
- Both axes empty (zero union) → axis returns 0.0, not NaN.
### 3.2 New: `internal/db/queries/contextual_likes.sql`
```sql
-- name: ListActiveContextualLikesForUser :many
-- Returns all the user's active (non-soft-deleted) contextual_likes with
-- non-null vectors. Cardinality bounded by the user's actual like-while-
-- playing history — typically tens to low hundreds.
SELECT track_id, session_vector
FROM contextual_likes
WHERE user_id = $1 AND deleted_at IS NULL AND session_vector IS NOT NULL;
```
### 3.3 New: `internal/db/queries/events.sql` (or `recommendation.sql`)
```sql
-- name: GetCurrentSessionVectorForUser :one
-- Returns the session_vector_at_play of the user's most recent play_event
-- in a still-active (un-timed-out) session. NoRows means no current vector
-- — caller treats this as Seed=true sentinel.
SELECT pe.session_vector_at_play
FROM play_events pe
JOIN play_sessions s ON s.id = pe.session_id
WHERE pe.user_id = $1
AND s.ended_at IS NULL
ORDER BY pe.started_at DESC
LIMIT 1;
```
(Final placement decided at implementation time — wherever sqlc and existing
query files line up best.)
### 3.4 Modified: `internal/recommendation/score.go`
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time
PlayCount int
SkipCount int
ContextualMatchScore float64 // NEW — in [0, 1], 0 when no signal
}
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64 // NEW
}
// Score gains: + in.ContextualMatchScore * w.ContextWeight
```
Zero-value defaults: a `ScoringInputs{}` with zero `ContextualMatchScore` and a
`ScoringWeights{}` with zero `ContextWeight` produce the v1 score. Existing callers
not passing the new fields see no behavior change.
### 3.5 Modified: `internal/recommendation/candidates.go`
```go
func LoadCandidates(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector, // NEW
) ([]Candidate, error)
```
Body adds:
1. Existing `LoadRadioCandidates` call.
2. New `ListActiveContextualLikesForUser(userID)` call.
3. Group result by `track_id` into `map[pgtype.UUID][]SessionVector`, unmarshaling
each `jsonb` column into `SessionVector`. Unmarshal failures are logged and
skipped (don't poison the entire response over one bad row).
4. For each candidate, set `Inputs.ContextualMatchScore =
ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights)`.
### 3.6 Modified: `internal/api/radio.go`
Before calling `LoadCandidates`, fetch the current session vector:
```go
var currentVec recommendation.SessionVector
if raw, err := q.GetCurrentSessionVectorForUser(ctx, user.ID); err == nil && len(raw) > 0 {
if jerr := json.Unmarshal(raw, &currentVec); jerr != nil {
h.logger.Warn("api: radio: bad session_vector_at_play", "err", jerr)
currentVec = recommendation.SessionVector{Seed: true}
}
} else {
currentVec = recommendation.SessionVector{Seed: true}
}
```
Pass `currentVec` into `LoadCandidates`. Pass `recCfg.ContextWeight` through into the
`ScoringWeights` struct alongside the existing weights.
### 3.7 Modified: `internal/config/config.go`
```go
type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"` // NEW, default 2.0
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
```
`Default()` populates `ContextWeight: 2.0`.
## 4. Request flow
```
GET /api/radio?seed_track=<uuid>&limit=N
handleRadio
1. Auth, parse seed_track, parse limit (unchanged)
2. q.GetCurrentSessionVectorForUser(userID) (NEW)
NoRows / NULL / unmarshal fail → SessionVector{Seed: true}
3. recommendation.LoadCandidates(...) (extended)
a. q.LoadRadioCandidates (unchanged)
b. q.ListActiveContextualLikesForUser (NEW)
c. group by track_id → map[uuid][]SessionVector
d. per candidate: ContextualMatchScore() → ScoringInputs
4. recommendation.Shuffle(candidates, weights, now, rng, limit-1)
Score() now folds in ContextualMatchScore * ContextWeight
5. Resolve album/artist, build response (unchanged)
```
## 5. Cold-start handling
Every cold-start path collapses to `contextual_match_score = 0` for all candidates,
so scoring degrades cleanly to v1 behavior:
| Condition | Path |
|------------------------------------------------------|------------------------------------------------------|
| User has no `play_events` at all | `NoRows` → `Seed=true` sentinel → `ContextualMatchScore` returns 0 |
| User has plays but no active session | `NoRows` (joined `s.ended_at IS NULL` filters) |
| Active session but `session_vector_at_play` is NULL | `len(raw) == 0` → `Seed=true` sentinel |
| Vector populated but `Seed=true` | `ContextualMatchScore` short-circuits to 0 |
| Candidate has no `contextual_likes` | absent from map → empty slice → returns 0 |
| Candidate has only `Seed=true` likes | filtered out → empty → returns 0 |
| Candidate has only soft-deleted likes | excluded by `deleted_at IS NULL` in the SQL |
## 6. Test plan
### 6.1 `similarity_test.go` (pure, table-driven)
- Identical vectors → `1.0`.
- Fully disjoint axes → `0.0`.
- Mixed: shared tags, no shared artists → `0.7 * tagJaccard`.
- Mixed: no shared tags, shared artists → `0.3 * artistJaccard`.
- Either input `Seed=true` → `0.0`.
- Both vectors fully empty → `0.0` (not NaN).
- One side empty on an axis, other side populated → that axis contributes 0.
- Weight balance: shared all tags, default weights → exactly `0.7`.
### 6.2 `score_test.go` extensions
- Perfect contextual match (`ContextualMatchScore=1.0`) at `ContextWeight=2.0` adds
exactly `+2.0` to the base score.
- Half match (`0.5`) adds `+1.0`.
- Zero match (`0.0`) leaves score unchanged from v1 behavior — guards backward compat.
### 6.3 `candidates_test.go` (integration vs test DB)
- Candidate with one matching `contextual_like` → `ContextualMatchScore > 0`.
- Candidate with multiple `contextual_likes` → max similarity wins.
- Candidate whose only `contextual_likes` are `Seed=true` → score 0.
- Candidate whose only `contextual_likes` are soft-deleted → score 0 (SQL filter).
- User with no `contextual_likes` anywhere → every candidate scores 0.
- User with only soft-deleted `contextual_likes` → every candidate scores 0.
### 6.4 `radio_test.go` (integration, end-to-end)
- Seed a current session vibe (3+ tracks of one genre/artist set) by inserting
`play_events` with populated `session_vector_at_play`.
- Insert a `contextual_like` whose `session_vector` matches that vibe, on track T.
- Insert an unrelated control track C with no contextual signal.
- Call `/api/radio` with a deterministic RNG and seed track distinct from T and C.
- Assert: T ranks above C in the response.
### 6.5 Coverage gate
Combined `internal/recommendation` coverage stays ≥ 70% (currently 78.5% combined
with `internal/playevents` post-#341; this slice's pure functions are highly
testable so we expect to land closer to 85%+).
## 7. Backwards compatibility
- `/api/radio` request and response shapes are unchanged — same query params, same
JSON output. Web client requires no edits.
- `ScoringInputs.ContextualMatchScore` and `ScoringWeights.ContextWeight` default to
`0` in zero-value structs. Pre-existing tests that construct these directly continue
passing without modification because the new term contributes nothing when both are
zero.
- `LoadCandidates` gains a `currentVector SessionVector` parameter — this is a
signature change, but the only caller is `internal/api/radio.go`, which we update
in this slice. No external consumers.
- DB schema is unchanged (migrations 0007 already shipped the table + indexes
needed for this slice).
## 8. Out-of-scope (deferred)
- Generalized (bag-of-counts) Jaccard if telemetry shows tag-dominance discrimination
matters.
- YAML exposure of `SimilarityWeights`.
- Per-user override of any recommendation weight.
- Caching `currentVector` across requests within a session.
- ListenBrainz / similar-artist retrieval (M4).
- `/api/radio?explain=true` style debug endpoint.
- Tag enrichment beyond `tracks.genre`.
## 9. Milestone gate (closes M3)
After this slice merges:
- Recommendation engine has all three v1 components: weighted shuffle, session
vectors written, contextual matching folded into scoring.
- Manual end-to-end verification: like a track during a session of one vibe, build a
similar session later, observe the track surfaces above unrelated controls in
`/api/radio` output.
- M4 (radio refinements + scrobble polish) unblocked.
@@ -1,251 +0,0 @@
# M3 Session Vectors + Contextual Likes — Design Spec
**Status:** approved 2026-04-27
**Slice:** M3 sub-plan #2 of 3 (recommendation engine v1 + contextual likes). Spec §6 "Session vector" + §13 step 8.
**Fable task:** #341.
## Goal
Add the two write paths that produce the data the recommendation engine consumes for contextual matching:
1. **At every play_started**, compute a session vector from the prior tracks in the user's current play_session and persist it to `play_events.session_vector_at_play` (column already exists nullable since migration 0005).
2. **At every like**, if the user has an open play_event with a populated session_vector, snapshot it into a new `contextual_likes` row.
This slice is backend-only — no UI surface. It accumulates the data; sub-plan #3 (Fable #342) reads it to add the `contextual_match_score` term to the scoring formula.
## Non-goals
- Adding `contextual_match_score` to the scoring function — that's sub-plan #3.
- Album/artist contextual likes. `contextual_likes` is track-only per spec §5.
- MBID-derived tags. v1 uses `tracks.genre` (denormalized text from migration 0002) as the only tag source. MBID tags are a post-v1 enrichment.
- Audio-feature tags (BPM, energy, key). Require an analysis pipeline we don't have. Post-v1.
- Cross-session vector enrichment. Each session's vector only sees prior tracks within the same play_session row.
- A purge/expiry policy on `contextual_likes`. Rows accumulate indefinitely. v1 problem only when a user has tens of thousands of likes; not a concern at v1 scale.
## Important catch from exploration
The `contextual_likes` table does NOT exist yet. The M2 events migration (0005) commented that "contextual_likes ships nullable now," but the actual `CREATE TABLE` was missed. This slice ships the table in a new migration (`0007`).
## Architecture
A new pure function `BuildSessionVector(priorTracks) → SessionVector` in `internal/recommendation/sessionvector.go`. The vector struct serializes to JSONB with the spec §6 shape: `seed` flag, `artists` set, `tags` bag-of-counts, `recent_track_ids` ordered list.
`internal/playevents.Writer.RecordPlayStarted` extends inside its existing transaction:
1. Auto-close prior open row (existing).
2. FindOrCreate session (existing).
3. Insert play_event (existing).
4. **NEW:** Query the prior tracks in the session via a new sqlc query `ListRecentSessionTracks(sessionID, beforeTime, limit)`.
5. **NEW:** Build the vector via the pure function.
6. **NEW:** UPDATE the just-inserted play_event's `session_vector_at_play` column (new sqlc query `UpdatePlayEventVector`).
`internal/api/likes.handleLikeTrack` extends:
1. `LikeTrack` upsert (existing) — but the sqlc query becomes `:execrows` so the handler can detect "actually inserted" vs "already exists."
2. **NEW:** if rows == 1, look up the user's open play_event. If present and its `session_vector_at_play` is non-NULL, INSERT into `contextual_likes` with the vector + session_id snapshot.
`internal/api/likes.handleUnlikeTrack` extends:
1. `UnlikeTrack` (existing).
2. **NEW:** UPDATE all the user's `contextual_likes` rows for this track to set `deleted_at = now() WHERE deleted_at IS NULL`. Idempotent.
Subsonic `/rest/star` and `/rest/unstar` already call into `dbq.LikeTrack` / `dbq.UnlikeTrack` through `internal/subsonic/star.go`. After this slice, those calls naturally pick up the contextual capture / soft-delete behavior — no code changes in `internal/subsonic` beyond a verification test.
## Schema (migration `0007_contextual_likes.up.sql`)
```sql
-- contextual_likes captures the per-session-context snapshot at the time
-- of each like. The recommendation engine in M3 sub-plan #3 uses these
-- to compute contextual_match_score per (current session, candidate track).
--
-- Multiple rows per (user, track) are allowed and expected — each row
-- represents a like in a specific session context. Soft-deleted rows
-- remain in the table but are filtered out of the engine's queries via
-- the partial index. Re-liking a previously-unliked track adds a NEW row
-- (does not undelete the old one).
CREATE TABLE contextual_likes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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(),
deleted_at timestamptz,
session_vector jsonb,
session_id uuid REFERENCES play_sessions(id) ON DELETE SET NULL
);
-- Partial index: the engine queries only active rows; this is the hot path.
CREATE INDEX contextual_likes_active_idx
ON contextual_likes (user_id, track_id)
WHERE deleted_at IS NULL;
-- GIN index for vector similarity queries (used by sub-plan #3's
-- contextual_match_score lookup). Even though M3 sub-plan #3 doesn't yet
-- exist, this slice adds the index because the recommendation engine
-- will need it; better to migrate once.
CREATE INDEX contextual_likes_vector_idx
ON contextual_likes USING gin (session_vector);
-- Lookup support for the soft-delete update (UPDATE ... WHERE user_id = $1
-- AND track_id = $2 AND deleted_at IS NULL). Already covered by the
-- partial index above — no separate index needed.
```
`down.sql`:
```sql
DROP TABLE IF EXISTS contextual_likes;
```
## Session vector shape
```go
type SessionVector struct {
Seed bool `json:"seed"`
Artists []string `json:"artists"` // distinct, ordered by first appearance
Tags map[string]int `json:"tags"` // bag-of-counts
RecentTrackIDs []string `json:"recent_track_ids"` // newest last
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector
```
Rules:
- `Seed = len(priorTracks) < 3`. The engine in #342 filters seed vectors out of the contextual-match query (they don't represent enough context to match against).
- `Artists` is the deduplicated list of `priorTracks[i].ArtistID` values, formatted as UUID strings. Order is "first appearance" (older tracks first).
- `Tags` is a `map[string]int` counting how many tracks have each genre. `priorTracks[i].Genre == nil || == ""` means no contribution. Tags are case-sensitive (we don't normalize — `"Rock"` and `"rock"` are different bins; future cleanup can add normalization).
- `RecentTrackIDs` is the ordered list, newest last (matches the spec's "ordered list of the N track ids").
- The function does NOT truncate. The caller passes in at most N tracks; the function works with whatever it's given (extra coverage flexibility).
## API contracts
No HTTP shape changes. `POST /api/events`, `POST/DELETE /api/likes/...`, and Subsonic `/rest/star`, `/rest/unstar` all keep their current request/response contracts. The new behavior is internal data-write side effects.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | Schema for `contextual_likes` + partial index + GIN index. |
| `internal/db/queries/contextual_likes.sql` | `InsertContextualLike`, `SoftDeleteContextualLikesForUserTrack`. |
| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. |
| `internal/recommendation/sessionvector.go` | `SessionVector` struct + `BuildSessionVector(priorTracks)` pure function. |
| `internal/recommendation/sessionvector_test.go` | Unit tests for the pure function (boundary cases for the seed flag, dedup, tag counts, ordering, JSON round-trip). |
### Modified server files
| Path | Change |
|---|---|
| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` (joins play_events ↔ tracks, orders by started_at DESC). Add `UpdatePlayEventVector(id, vector) :exec`. |
| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` so the handler detects insert vs already-exists. |
| `internal/playevents/writer.go::RecordPlayStarted` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → UpdatePlayEventVector. All inside the existing transaction. |
| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same vector computation as RecordPlayStarted (Subsonic synthetic plays should also carry context). Single source of truth via a private helper. |
| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated vector, session-scope isolation). |
| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, GetOpenPlayEventForUser; if open + non-null vector, InsertContextualLike. |
| `internal/api/likes.go::handleUnlikeTrack` | After UnlikeTrack, SoftDeleteContextualLikesForUserTrack. |
| `internal/api/likes_test.go` | Five new tests (capture during open play, no-op without play, no duplicate on idempotent like, soft-delete on unlike, history accumulation across like/unlike/like). |
| `internal/subsonic/star_test.go` | One new test confirming Subsonic star captures contextual_likes when a now-playing event is open. |
### No web changes
The web client already calls `/api/events` and `/api/likes/...`. After this slice, behavior is identical from the SPA's perspective; the side-effects are server-side only.
## Data flow
**Play started:**
1. SPA calls `POST /api/events` with `type: 'play_started', track_id`.
2. `handleEventPlayStarted``events.Writer.RecordPlayStarted(ctx, userID, trackID, clientID, at)`.
3. Inside the transaction:
- Auto-close any prior open row (existing).
- `playsessions.FindOrCreate(...)``sessionID` (existing).
- `q.InsertPlayEvent(...)``playEventID` (existing).
- **NEW:** `priorTracks := q.ListRecentSessionTracks(ctx, sessionID, at, 5)`. Returns up to 5 tracks where `started_at < at` (excluding the just-inserted row).
- **NEW:** `vec := recommendation.BuildSessionVector(priorTracks)`.
- **NEW:** `vecJSON, _ := json.Marshal(vec)`.
- **NEW:** `q.UpdatePlayEventVector(ctx, UpdatePlayEventVectorParams{ID: playEventID, SessionVectorAtPlay: vecJSON})`.
4. Returns `StartedResult{PlayEventID, SessionID}`.
**Subsonic synthetic completed play:** `RecordSyntheticCompletedPlay` follows the same vector-computation path so Subsonic-driven plays carry context too.
**Like a track:**
1. `POST /api/likes/tracks/:id` (or Subsonic `/rest/star?id=X`).
2. `handleLikeTrack` validates auth + UUID + entity exists.
3. `q.LikeTrack(...)` returns rows-affected count (sqlc `:execrows`).
4. **If rows == 0** (already liked): return 204. No contextual capture.
5. **If rows == 1** (freshly inserted):
- `event, err := q.GetOpenPlayEventForUser(ctx, userID)`.
- If `errors.Is(err, pgx.ErrNoRows)` → return 204 (no open event, no contextual capture).
- If `event.SessionVectorAtPlay` is NULL → return 204 (event opened before this slice landed; no vector).
- Else: `q.InsertContextualLike(ctx, InsertContextualLikeParams{UserID, TrackID, SessionVector: event.SessionVectorAtPlay, SessionID: &event.SessionID})`.
6. Return 204.
**Unlike a track:**
1. `DELETE /api/likes/tracks/:id` (or Subsonic `/rest/unstar?id=X`).
2. `handleUnlikeTrack` validates UUID.
3. `q.UnlikeTrack(...)` (existing).
4. **NEW:** `q.SoftDeleteContextualLikesForUserTrack(ctx, userID, trackID)``UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL`.
5. Return 204.
**Edge cases:**
- **Like with no open play_event.** No contextual capture; only `general_likes`. Same outcome as today.
- **Like during cold-start session.** Vector has `seed: true`. Row is still inserted (the seed flag is informational; the engine in #342 filters on it).
- **Re-like of an already-liked track in the same session.** `LikeTrack` returns 0 affected rows; contextual capture skipped. No spam.
- **Re-like after unlike, in the same session.** Unlike soft-deleted prior rows. New like inserts a fresh row. The (user, track) pair has both rows in the table; only the fresh one is `deleted_at IS NULL`.
- **Unlike of a never-liked track.** `UnlikeTrack` is a no-op. `SoftDeleteContextualLikesForUserTrack` runs but updates 0 rows. Both safe.
- **Album / artist likes.** `contextual_likes` is track-only. Album/artist like handlers are untouched in this slice.
- **Subsonic `star?albumId=&artistId=`.** Album/artist branches don't trigger contextual capture (track-only). The track-id branch picks it up via the shared `dbq.LikeTrack` call.
## Testing
### Server (`go test`)
**`internal/recommendation/sessionvector_test.go`** (pure unit tests):
- Empty input → seed=true, all collections empty.
- 1 track → seed=true, artists has 1 entry, tags has 1 entry (count=1), recent_track_ids has 1 entry.
- 2 tracks → seed=true.
- 3 tracks → seed=false. Single-artist case: artists deduplicated to 1 entry. Multi-genre case: tags accumulates counts.
- 5 tracks across 3 artists, mixed genres → all populated, dedup correct, counts correct.
- Track with empty/nil genre → tag entry not added.
- 10 tracks input → function processes all 10 (caller controls truncation; document the contract).
- JSON round-trip via `encoding/json`: marshal, unmarshal, fields equal.
**`internal/playevents/writer_test.go`** (live-DB integration, three new):
- `TestRecordPlayStarted_PersistsSessionVector_Seed`: first play in fresh session → row's `session_vector_at_play.seed == true`, empty arrays.
- `TestRecordPlayStarted_PersistsSessionVector_Populated`: after 4 plays, the 5th's vector has `seed: false` and the 4 prior tracks' artists/tags/ids.
- `TestRecordPlayStarted_VectorScopedToSession`: open a play_event, advance > 30 minutes, start another (new session) — second play's vector is `seed: true` with nothing from the prior session.
**`internal/api/likes_test.go`** (live-DB integration, five new):
- `TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike`: user starts playing track A (creates play_event with non-null vector); likes track B; assert `contextual_likes` has one row for (user, B) with the matching `session_vector` and `session_id`.
- `TestLikeTrack_NoOpenPlayEvent_NoContextualLike`: user likes a track without playing — `general_likes` has the row, `contextual_likes` is empty.
- `TestLikeTrack_RepeatedLike_NoDuplicate`: like the same track twice — first call writes both rows; second call no-ops (LikeTrack returns 0 rows; contextual capture skipped).
- `TestUnlikeTrack_SoftDeletesContextualLikes`: like → unlike. `general_likes` row gone, `contextual_likes` row's `deleted_at` is set, row count unchanged.
- `TestLikeUnlikeRelike_HistoryAccumulates`: like → unlike → like. Two `contextual_likes` rows for that (user, track): one with `deleted_at` set, one without.
**`internal/subsonic/star_test.go`** (live-DB, one new):
- `TestHandleStar_DuringNowPlaying_WritesContextualLike`: send `submission=false` for track A (creates open play_event with vector), then `star?id=B``contextual_likes` row exists for (user, B).
**Migration smoke**: existing `internal/db` test runs the new 0007 migration; verify no errors.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Play 4 tracks all the way through.
2. `psql ... SELECT id, session_vector_at_play FROM play_events ORDER BY started_at DESC LIMIT 5` — vectors are populated; first 3 have `seed: true`, 4th onward `seed: false`.
3. While the 4th track is playing, like it via the heart button. `SELECT * FROM contextual_likes` shows a row with that track's session_vector.
4. Like a different track NOT currently playing. `general_likes` row appears; no `contextual_likes` row.
5. Unlike the first track. `general_likes` deleted; `contextual_likes` row still exists but `deleted_at` is set.
6. Re-like that track in a new session. New `contextual_likes` row with `deleted_at IS NULL` and a different `session_vector` (different artists/tags from the new session).
7. From Feishin: send a now-playing for track A, then star track B. Confirm contextual capture worked through the Subsonic path.
## Risks & mitigations
- **Vector size bloat**: each play_event row carries up to 5 artist UUIDs + a tag map + 5 track UUIDs. ~500 bytes per play_event in JSON. For a user with 50,000 plays, that's 25 MB of JSONB. Acceptable; PostgreSQL handles it. If telemetry shows the column dominates row width, future work can extract to a separate table or hash older vectors.
- **Tag normalization**: `tracks.genre` is denormalized free-text. `"Rock"` and `"rock"` end up as different bins. v1 doesn't normalize. Mitigation: documented; M3.5/M4 cleanup can `lower()` consistently; the recommendation engine in #342 can do its similarity calculation on lowercased tags as a workaround.
- **Eager column read**: extending `RecordPlayStarted` adds 1 SELECT (recent tracks) + 1 UPDATE (vector) per play. Single-digit ms overhead at v1 scale. Reasonable cost for the data we get.
- **NULL vector after this slice ships**: existing play_events from before the migration have NULL `session_vector_at_play`. The like-handler's contextual-capture path skips when the vector is NULL — those old plays simply don't generate contextual likes. No backfill; new plays going forward populate the vector.
- **Subsonic synthetic plays carry vector**: synthetic plays from `submission=true` use the same vector logic. Subsonic clients that scrobble in bulk after-the-fact (e.g. submit 10 plays in 5 seconds) generate vectors quickly across each new session/track — vectors may show artificial 5-track sliding windows during the catch-up. Acceptable for v1; treats Subsonic clients as first-class citizens of the recommendation pipeline.
- **`contextual_likes` table forgotten in 0005**: this slice adds it. Existing migration history is unchanged; the new 0007 migration brings the schema up to spec.
- **Soft-delete query without explicit unique constraint**: `SoftDeleteContextualLikesForUserTrack` updates ALL active rows for (user, track), which may be more than one if a user liked the same track in multiple sessions. That's the correct behavior — unliking means "all my historical likes for this track no longer count." Documented.
@@ -1,403 +0,0 @@
# M4a — ListenBrainz outbound scrobble worker
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #345
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
## 1. Goal
Send the user's qualifying plays to ListenBrainz with batching and exponential
retry. Reads from the existing M2 `play_events` stream; backed by a new
`scrobble_queue` table that the worker drains every 30 seconds. Per-user token
configuration via a new minimal `/settings` page in the SPA.
When this slice ships, a play that meets ListenBrainz's "≥240s OR ≥50%
completion" threshold appears in the user's LB profile within ~30 seconds, and
the system survives LB outages, bad networks, and process restarts without
losing or duplicating scrobbles.
## 2. Non-goals (explicit)
- **`now-playing` endpoint** — real-time "user is currently listening" status.
Requires push architecture; defer until a UI surface needs it.
- **Listen import** — backfilling historical plays into LB. Useful for migration
but not part of the core flow.
- **MusicBrainz ID resolution at scan time** — payload uses MBIDs only when
already populated in `tracks.mbid`/`albums.mbid`/`artists.mbid`. Better
scanner-side MBID coverage is a future improvement.
- **Encrypted-at-rest token storage** — chose plaintext for v1 (industry norm
for self-hosted scrobble apps).
- **Scrobble status indicator in PlayerBar** — latency-sensitive UX; bundle
with the future now-playing push.
- **Multi-instance queue safety** — single worker assumes single Minstrel
process. ROW LOCK FOR UPDATE SKIP LOCKED is a future change if/when Minstrel
goes multi-instance.
- **CLI admin token rotation** — operators can `UPDATE users …` for
break-glass; CLI tooling lands with M6 packaging.
- **Proactive rate limiting** — we honor LB's `Retry-After` headers but don't
throttle ourselves. Human-scale play rate is well below LB's limits.
- **Multiple scrobble services** (Last.fm, Maloja, etc.) — LB-only by design.
The `internal/scrobble/listenbrainz/` package structure leaves room for
siblings later if demand emerges.
## 3. Architecture overview
```
┌──────────────────────────┐
play_event closed ──► │ scrobble.MaybeEnqueue │ (called from
(M2 RecordPlayEnded) │ - apply LB threshold │ Writer hooks)
│ - INSERT scrobble_queue │
└────────────┬─────────────┘
┌────────────────────────┐
│ scrobble_queue table │ status: pending | failed
│ (work list, not log) │
└────────────┬───────────┘
tick 30s ─────► ┌────────────────────────────────────┐
(goroutine) │ scrobble.Worker │
│ - SELECT pending, next_attempt<= now │
│ - batch POST to LB submit-listens │
│ - on 2xx: DELETE row + │
│ UPDATE play_events.scrobbled_at │
│ - on 5xx/network: increment │
│ attempts, schedule next │
│ - on 4xx: mark failed, log │
│ - on 429 Retry-After: respect │
│ header │
└────────────────────────────────────┘
ListenBrainz https://api.listenbrainz.org
```
### 3.1 New Go packages
- **`internal/scrobble/listenbrainz/`** — pure HTTP client. One method:
`SubmitListens(ctx, token, listens)`. No DB, no worker plumbing. Tested
against `httptest.Server`.
- **`internal/scrobble/threshold.go`** — `Qualifies(durationPlayedMs,
completionRatio) bool`. Pure function.
- **`internal/scrobble/queue.go`** — `MaybeEnqueue(ctx, q, playEventID)`.
Reads user config + threshold, inserts row.
- **`internal/scrobble/worker.go`** — the goroutine. Started from
`cmd/minstrel/main.go` alongside `playevents.Writer`.
### 3.2 Hooked into existing code
- **`playevents.Writer.RecordPlayEnded`** — after the close transaction
commits, call `scrobble.MaybeEnqueue(ctx, q, playEvent.ID)` as a
best-effort post-commit step. Errors are logged and swallowed (matches
the M3 `CaptureContextualLikeIfPlaying` pattern: scrobble delivery is
enrichment, not a precondition for the canonical play history).
- **`playevents.Writer.RecordSyntheticCompletedPlay`** — same hook (Subsonic
`submission=true` plays from `/rest/scrobble`).
- **`internal/api/me.go`** — two new handlers: `handleGetListenBrainz` and
`handlePutListenBrainz`. Wired in `Mount(...)` under `/api/me/listenbrainz`.
- **`cmd/minstrel/main.go`** — start the worker:
```go
worker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger)
go worker.Run(ctx)
```
## 4. Database schema
New migration `0008_scrobble.up.sql`:
```sql
ALTER TABLE users
ADD COLUMN listenbrainz_token TEXT NULL,
ADD COLUMN listenbrainz_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE scrobble_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
play_event_id uuid NOT NULL REFERENCES play_events(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error TEXT NULL,
enqueued_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (play_event_id)
);
CREATE INDEX scrobble_queue_pending_idx
ON scrobble_queue (next_attempt_at)
WHERE status = 'pending';
```
Down migration drops the table and the user columns.
**Reused (no schema changes):**
- `play_events.scrobbled_at` — already exists from M2 migration 0005,
currently always NULL. Worker stamps it on successful submit.
- `play_events.duration_played_ms`, `completion_ratio` — already populated
by M2; used for the LB eligibility threshold.
**Lifecycle:**
- Successful submit → `DELETE FROM scrobble_queue WHERE id = $1` AND
`UPDATE play_events SET scrobbled_at = now() WHERE id = $1`. Canonical
record stays in `play_events`; queue is purely a work list.
- `UNIQUE(play_event_id)` makes `MaybeEnqueue` idempotent: re-running the
close path on a play_event with an existing queue row is a no-op via
`ON CONFLICT DO NOTHING`.
- No `'sent'` status enum value — the simpler binary `pending`/`failed`
state machine is sufficient because successful rows are deleted.
## 5. Backend components
### 5.1 ListenBrainz client (`internal/scrobble/listenbrainz/client.go`)
```go
type Listen struct {
ListenedAt int64
Track Track
}
type Track struct {
ArtistName string
TrackName string
ReleaseName string
DurationMs int
RecordingMBID string
ArtistMBIDs []string
ReleaseMBID string
}
type Client struct {
BaseURL string // default https://api.listenbrainz.org
HTTP *http.Client
}
// Errors are typed so the worker can branch on response semantics.
var (
ErrAuth = errors.New("listenbrainz: auth rejected") // 401
ErrPermanent = errors.New("listenbrainz: permanent error") // other 4xx
ErrTransient = errors.New("listenbrainz: transient error") // 5xx, network
)
type RetryAfterError struct{ Wait time.Duration }
func (e *RetryAfterError) Error() string { ... }
func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error
```
Sets `Authorization: Token <token>`. POSTs to `/1/submit-listens` with
`payload_type=import` for batches >1, `single` for batches of 1 (LB convention).
On 429, parses the `Retry-After` header and returns `*RetryAfterError`.
### 5.2 Threshold (`internal/scrobble/threshold.go`)
```go
// Qualifies returns true if a closed play_event meets ListenBrainz's
// recommended scrobble threshold.
func Qualifies(durationPlayedMs int, completionRatio float64) bool {
return durationPlayedMs >= 240_000 || completionRatio >= 0.5
}
```
### 5.3 Enqueue (`internal/scrobble/queue.go`)
```go
// MaybeEnqueue inserts a scrobble_queue row for the given play_event if:
// 1. The user has listenbrainz_enabled = true with a non-empty token.
// 2. The play_event passes Qualifies().
// Idempotent via UNIQUE(play_event_id) — re-runs are no-ops.
// Returns nil even when the row is skipped (no-op is not an error).
// Best-effort: callers (the playevents.Writer hooks) should log returned
// errors but not propagate them, since scrobble delivery is enrichment
// and must not block the canonical play history.
func MaybeEnqueue(ctx context.Context, q *dbq.Queries, playEventID pgtype.UUID) error
```
### 5.4 Worker (`internal/scrobble/worker.go`)
```go
type Worker struct {
pool *pgxpool.Pool
client *listenbrainz.Client
logger *slog.Logger
tick time.Duration // 30s in production, injectable for tests
now func() time.Time // injectable for tests
batch int // up to 50 rows per tick
}
func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker
// Run blocks until ctx is cancelled.
func (w *Worker) Run(ctx context.Context)
// tickOnce drains up to `batch` pending rows. Exposed for tests.
func (w *Worker) tickOnce(ctx context.Context) error
```
Backoff schedule (constants in `worker.go`):
```go
var backoffSchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
6 * time.Hour,
}
const maxAttempts = 5
// backoffDelay maps a failure count (the value of the `attempts` column
// AFTER the failure has been recorded) to the delay before the next
// attempt. attempts=1 (first failure just happened) → backoffSchedule[0]
// = 1m. attempts=5 → backoffSchedule[4] = 6h. attempts >= 6 → give up.
// Returns (_, false) when attempts > maxAttempts.
func backoffDelay(attempts int) (time.Duration, bool)
```
Per-row outcome handling:
- 2xx → `DELETE FROM scrobble_queue WHERE id = $1` + `UPDATE play_events SET
scrobbled_at = now() WHERE id = $play_event_id`.
- `*RetryAfterError` (429) → `UPDATE … SET next_attempt_at = now() + Wait`
WITHOUT incrementing `attempts` (server told us to wait, not that we failed).
- `ErrTransient` (5xx, network) → increment `attempts`, look up
`backoffDelay(attempts)`. If `false`, mark `failed`. Otherwise schedule next.
- `ErrPermanent` (4xx) → mark `failed` immediately, no retry.
- `ErrAuth` (401) → mark `failed` immediately AND set
`users.listenbrainz_enabled = FALSE` (defensive: bad token shouldn't keep
retrying or feed future rows).
### 5.5 API endpoints (`internal/api/me.go`)
```
GET /api/me/listenbrainz
→ 200 { enabled: boolean, token_set: boolean, last_scrobbled_at: string|null }
PUT /api/me/listenbrainz
body: { token?: string, enabled?: boolean }
- token: any string sets the token. Empty string clears it AND forces enabled=false.
- enabled: requires a non-empty stored token. 400 if attempting enabled=true with no token.
→ 200 { enabled, token_set, last_scrobbled_at }
```
The token is **write-only** — `GET` never returns the actual value, only
`token_set: bool`. `last_scrobbled_at` is computed via `MAX(scrobbled_at)
FROM play_events WHERE user_id = $1`.
## 6. Frontend (minimal `/settings`)
New page `web/src/routes/settings/+page.svelte` with a single "ListenBrainz"
section. Form:
- **Token field**: password input with Save button when unset; masked
"••••••••• (set)" + Clear button when set. Save calls `PUT /api/me/listenbrainz
{ token }`. Clear calls `PUT { token: "" }`.
- **Enabled checkbox**: "Send my plays to ListenBrainz". Disabled when no
token. Toggle calls `PUT { enabled: !current }`.
- **Last scrobbled at**: localized timestamp of `last_scrobbled_at` from the
GET response.
- **Disclaimer**: "Tokens are stored unencrypted in this server's database —
treat as sensitive."
Shell nav (`web/src/lib/components/Shell.svelte`) gains `{ href: '/settings',
label: 'Settings' }` after `/playlists`.
Helpers in `web/src/lib/api/client.ts` — confirm `apiPut` exists; add it if
not (mirrors the existing `apiGet` shape).
## 7. Test plan
### 7.1 Pure unit tests
- **`threshold_test.go`**: boundary cases (exactly 240s, exactly 50%, just
under both, both true).
- **`worker_test.go`**: `backoffDelay(1) == 1m`, `backoffDelay(2) == 5m`,
`backoffDelay(3) == 30m`, `backoffDelay(4) == 2h`, `backoffDelay(5) == 6h`,
`backoffDelay(6)` returns `_, false`.
- **`listenbrainz/client_test.go`** (uses `httptest.Server`):
- 2xx → nil
- 401 → `ErrAuth`
- 400, 403 → `ErrPermanent`
- 500, 503 → `ErrTransient`
- 429 with `Retry-After: 60` → `*RetryAfterError{Wait: 60s}`
- Network failure mid-request → `ErrTransient`
- Body shape (JSON) and `Authorization: Token <token>` header asserted
### 7.2 Integration (live test DB)
- **`queue_test.go`**:
- Idempotent: `MaybeEnqueue` twice for same play_event = one row
- User with `listenbrainz_enabled=false` → no row
- User with empty token → no row
- Play_event below threshold → no row
- Play_event passing threshold → row inserted (status=pending, attempts=0,
next_attempt_at ≈ now())
- **`worker_integration_test.go`** (mocked LB via httptest):
- 1 pending row + 200 → row deleted, `play_events.scrobbled_at` populated
- 503 once → row remains, `attempts=1`, `next_attempt_at ≈ now() + 1m`
- 503 five times → row marked `failed`, `last_error` populated
- 401 → row marked `failed` immediately, `users.listenbrainz_enabled` set to
`FALSE`
- 429 with `Retry-After: 300` → row remains, `next_attempt_at ≈ now() + 5m`,
`attempts` NOT incremented
- Batch of 10 pending → single LB POST, 10 listens in payload
- **`internal/api/me_test.go`** extensions:
- GET when no token → `{enabled:false, token_set:false, last_scrobbled_at:null}`
- PUT `{token: "abc"}` → DB updated, GET shows `token_set:true`
- PUT `{token: ""}` → token cleared, `enabled` forced to false
- PUT `{enabled: true}` while no token → 400
- `last_scrobbled_at` populated from `MAX(play_events.scrobbled_at)`
### 7.3 Frontend (`web/src/routes/settings/settings.test.ts`)
- Token-not-set state: input + Save button render
- Token-set state: masked + Clear button render
- Save mutation: PUT body has the typed token
- Enabled checkbox: disabled when no token
- Last-scrobbled-at: localized timestamp renders when present
- Mocked `apiGet`/`apiPut`; no real network
### 7.4 Coverage target
`internal/scrobble/...` ≥ 80%. M3 combined coverage stays well above the 70%
floor with these additions.
### 7.5 Manual verification post-merge
1. Generate a token at https://listenbrainz.org/profile/
2. /settings → paste token → Save → toggle enabled
3. Play a track for ≥240s OR finish it
4. Within 30s, check listenbrainz.org/<your-username> → listen appears
5. /settings → "Last scrobbled" timestamp populates
## 8. Backwards compatibility
- New migration; no changes to existing schema beyond two nullable columns
on `users` and the new `scrobble_queue` table.
- `/api/me/listenbrainz` is new; no existing endpoints change.
- `MaybeEnqueue` is a new hook in `playevents.Writer`; if all users have
`listenbrainz_enabled=false` (the default after migration), the hook is a
no-op and behavior is unchanged.
- The new `/settings` page is additive; the rest of the SPA is unchanged.
- `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay`
signatures are unchanged. Only their bodies gain the enqueue call.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Per-user token (vs global) | Forward-compat to multi-user; cost is 2 nullable columns. |
| 2 | Plaintext token storage (vs encrypted) | Industry norm for self-hosted scrobble apps; key-management is a separate scope. |
| 3 | Threshold ≥240s OR ≥50% (separate from skip threshold) | Matches LB recommendation; skip threshold serves a different purpose. |
| 4 | Pull-based 30s timer (vs push) | Survives restarts/outages; failure handling natural; latency invisible at single-user scale. |
| 5 | Minimal `/settings` page in M4a (vs API-only) | User needs somewhere to put the token; scaffold for M6 to extend. |
| 6 | Patient backoff: 1m → 5m → 30m → 2h → 6h, 5 attempts (~9h window) | Survives overnight LB outages without unbounded queue growth. |
| 7 | Delete sent rows; keep failed rows | Canonical record in `play_events.scrobbled_at`; queue is a work list. |
## 10. Sub-plan progression (M4)
- **M4a (this) — outbound scrobble worker**.
- M4b — inbound similarity ingest (`track_similarity` table + LB similarity
fetcher; weekly cache).
- M4c — radio similarity-driven candidate pool + queue-refresh-at-80%
(closes M4).
@@ -1,338 +0,0 @@
# M4b — ListenBrainz inbound similarity ingest
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #346
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
**Builds on:** M4a (outbound scrobble worker — shipped as PR #26)
## 1. Goal
A periodic background worker that pulls track-track and artist-artist similarity
edges from ListenBrainz's `/explore/similar-recordings/{mbid}` and
`/explore/similar-artists/{mbid}` endpoints and stores them in two new tables
(`track_similarity`, `artist_similarity`). Refreshes each row at most once per
7 days; bounded scope to "tracks the user has played" so cost stays
proportional to actual usage.
When this slice ships, M4c can build candidate pools for radio from a
similarity graph rather than the user's whole library.
## 2. Non-goals (explicit)
- **Lazy fetch on radio request** — M4c. If a user clicks a never-played track
as seed and `track_similarity` is empty for it, M4c can synchronously call
`SimilarRecordings` then.
- **Score normalization to [0, 1]** — store raw LB scores
(`DOUBLE PRECISION`); M4c normalizes at query time if its scoring formula
needs it.
- **Symmetric edges** (storing both `(A, B)` and `(B, A)`) — store one-way as
LB returns. M4c queries `WHERE track_a_id = $seed`.
- **`musicbrainz_tag` and `user_cooccurrence` source values** — schema reserves
them via the `source` enum, but M4b only writes `'listenbrainz'`.
- **Suggested-additions / Lidarr integration** (LB-returned MBIDs not in the
library) — M5. Spec line 225 covers it.
- **Configurable LB algorithm parameter** — hardcoded for v1.
- **Per-user similarity overrides** — `track_similarity` has no `user_id`;
data is global per-instance.
- **Force-refresh HTTP endpoint** — operators can `UPDATE … SET fetched_at =
'1970-01-01' WHERE …` for break-glass.
- **Multi-instance worker safety** — single-process worker assumed (matches
M4a).
- **Frontend surface** — M4b is invisible until M4c uses the data.
## 3. Architecture overview
```
┌────────────────────────────────────┐
│ similarity.Worker │ hourly tick
│ - SELECT distinct played tracks │
│ with mbid where (no row OR │
│ fetched_at < now() - 7d) │
│ - LIMIT 510 per tick │
└────────────┬───────────────────────┘
┌──────────────────────────────────────┐
│ listenbrainz.Client │
│ (extended from M4a) │
│ GET /1/explore/similar-recordings/ │
│ {mbid} │
│ GET /1/explore/similar-artists/ │
│ {artist_mbid} │
│ No auth — public endpoints │
└────────────┬─────────────────────────┘
For each LB response:
- Filter to MBIDs we have in our local library
- Take top 20 (sorted by LB score)
- UPSERT into track_similarity / artist_similarity
```
### 3.1 New Go package
**`internal/similarity/`** — the worker:
- `Worker` struct (pool, client, logger, tick, batch, topK)
- `NewWorker(pool, client, logger) *Worker` — production defaults: 1h tick,
batch=5, topK=20
- `(w *Worker) Run(ctx)` — blocks until ctx cancelled
- `(w *Worker) tickOnce(ctx) error` — drains one batch of tracks AND one
batch of artists; injectable for tests
### 3.2 Existing-code extensions
- **`internal/scrobble/listenbrainz/client.go`** — gains two methods on the
existing `Client` (which already houses `SubmitListens` from M4a):
- `SimilarRecordings(ctx, mbid, limit) ([]SimilarRecording, error)`
- `SimilarArtists(ctx, mbid, limit) ([]SimilarArtist, error)`
- Both return the same typed errors as `SubmitListens` (`ErrTransient`,
`ErrPermanent`, `*RetryAfterError`). 401 is defensive only — these are
public endpoints.
- The package stays at its current path. A future cleanup could move it
to `internal/listenbrainz/`, but that's a non-blocking refactor.
- **`cmd/minstrel/main.go`** — start the worker alongside the M4a scrobble
worker:
```go
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
```
### 3.3 Failure handling
**Passive retry via timer.** Unlike M4a's durable scrobble queue:
- A failed `SimilarRecordings` call does NOT update `fetched_at`. The next
hourly tick selects the row again (it still satisfies the "needs fetch"
predicate) and retries.
- 429 with `Retry-After`: the worker logs the value and **aborts the current
tick** without updating `fetched_at` on any in-flight rows. The next
hourly tick (typically far longer than any LB-suggested back-off) picks
the work back up. Avoids mid-tick sleeps that would block the goroutine.
- ErrPermanent (4xx): logged as a warning + skipped. Permanent errors on
similarity reads typically mean the MBID isn't in LB's graph — there's no
remediation, but `fetched_at` stays old so we'll just retry forever (cheap
no-op since LB returns 4xx fast). Acceptable; could mark "permanently
empty" in a future iteration if telemetry shows it matters.
- ErrTransient (5xx, network): logged + skipped, retry next tick.
No `scrobble_queue`-equivalent table needed; the work list IS the played-tracks
set + the `fetched_at` watermark.
## 4. Database schema
New migration `0009_similarity.up.sql`:
```sql
CREATE TABLE track_similarity (
track_a_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
track_b_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (track_a_id, track_b_id, source),
CHECK (track_a_id <> track_b_id)
);
CREATE INDEX track_similarity_a_score_idx
ON track_similarity (track_a_id, score DESC);
CREATE TABLE artist_similarity (
artist_a_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
artist_b_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (artist_a_id, artist_b_id, source),
CHECK (artist_a_id <> artist_b_id)
);
CREATE INDEX artist_similarity_a_score_idx
ON artist_similarity (artist_a_id, score DESC);
```
Down migration drops both tables.
**Notes:**
- Primary key includes `source` so the schema can hold multiple parallel
similarity sources (per spec line 119).
- `(track_a_id, score DESC)` index matches the M4c hot-path query: "for seed
T, give me top-N similar tracks descending by score."
- `CHECK (a <> b)` prevents self-edges.
- `ON DELETE CASCADE` from both endpoints so deleting a track cleans up edges
on either side.
## 5. New sqlc queries
`internal/db/queries/similarity.sql`:
```sql
-- name: ListPlayedTracksNeedingSimilarity :many
SELECT DISTINCT t.id, t.mbid
FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE t.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM track_similarity ts
WHERE ts.track_a_id = t.id
AND ts.source = 'listenbrainz'
AND ts.fetched_at > now() - interval '7 days'
)
ORDER BY t.id
LIMIT $1;
-- name: ListPlayedArtistsNeedingSimilarity :many
SELECT DISTINCT ar.id, ar.mbid
FROM artists ar
JOIN tracks t ON t.artist_id = ar.id
JOIN play_events pe ON pe.track_id = t.id
WHERE ar.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM artist_similarity asim
WHERE asim.artist_a_id = ar.id
AND asim.source = 'listenbrainz'
AND asim.fetched_at > now() - interval '7 days'
)
ORDER BY ar.id
LIMIT $1;
-- name: GetTracksByMBIDs :many
SELECT id, mbid FROM tracks WHERE mbid = ANY($1::text[]);
-- name: GetArtistsByMBIDs :many
SELECT id, mbid FROM artists WHERE mbid = ANY($1::text[]);
-- name: UpsertTrackSimilarity :exec
INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (track_a_id, track_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
```
## 6. Worker algorithm
`tickOnce(ctx)`:
1. **Track pass:**
- `q.ListPlayedTracksNeedingSimilarity(batch=5)` → `[(track_id, mbid)…]`
- For each `(track_id, mbid)`:
- Call `c.SimilarRecordings(ctx, mbid, 100)`
- On 429 → log the `Retry-After` and **return from `tickOnce` early**
(don't update `fetched_at`; the next hourly tick will pick up the
work, which is virtually always longer than LB's `Retry-After`)
- On other error → log warn, skip (no `fetched_at` update; next tick
retries)
- On success: collect returned MBIDs → `q.GetTracksByMBIDs(returnedMBIDs)`
→ take top 20 by score → for each `(local_id, score)` call
`q.UpsertTrackSimilarity(track_id, local_id, score)`
2. **Artist pass:** symmetric, using `ListPlayedArtistsNeedingSimilarity`,
`SimilarArtists`, `GetArtistsByMBIDs`, `UpsertArtistSimilarity`.
**Constants:**
- Tick interval: 1 hour (production); injectable to ms-scale for tests.
- Batch size: 5 (production). 5 LB calls per pass × 2 passes = 10 LB calls/tick
→ ~240/day, well under LB's documented rate limits (~100/5min unauth).
- Top-K: 20 per LB query.
- LB algorithm: hardcoded constant in the client (use LB's documented default
at implementation time).
## 7. Test plan
### 7.1 LB client unit tests (httptest)
In `internal/scrobble/listenbrainz/client_test.go`, add 7 tests for each new
method:
For `SimilarRecordings`:
- 200 + valid body → returns slice ordered by score
- 401 → `ErrAuth` (defensive — public endpoint shouldn't 401)
- 400 → `ErrPermanent`
- 503 → `ErrTransient`
- 429 with `Retry-After` → `*RetryAfterError`
- URL contains `algorithm=…`
- URL contains `limit=N`
Same 7 tests for `SimilarArtists`.
### 7.2 Worker integration tests (live DB + httptest)
In `internal/similarity/worker_integration_test.go`:
- `TickOnce_NoPlayedTracks_NoOp`: empty `play_events` → returns nil, no rows
in `track_similarity`
- `TickOnce_MapsLBResponseToLocalLibrary`: seed one played track with MBID;
LB returns 3 MBIDs (2 in library, 1 not); assert 2 rows inserted
- `TickOnce_TopKEnforced`: LB returns 50; assert
`count(*) WHERE track_a_id = $1` ≤ 20
- `TickOnce_RespectsSevenDayCap`: row with `fetched_at = now()` → not
re-queried (the LB endpoint isn't called)
- `TickOnce_RefreshesStaleRow`: row with `fetched_at = now() - interval '8
days'` → re-fetched, score updated, fetched_at bumps
- `TickOnce_429AbortsTick`: first response 429 with Retry-After → tickOnce
returns early; no `fetched_at` updates; subsequent tracks in the batch
are NOT processed (they're picked up on the next tick)
- `TickOnce_TransientErrorSkipsTrack`: 503 on one track → `fetched_at`
unchanged; other tracks in same batch process normally
- `TickOnce_FiltersInLibrary`: LB returns 5 MBIDs none of which are in the
library → 0 rows inserted (no error)
- `TickOnce_ArtistPassMirrors`: same coverage for the artist branch
- `TickOnce_NoMBIDOnTrack_Skipped`: track with `mbid IS NULL` → not selected
by `ListPlayedTracksNeedingSimilarity`
### 7.3 Coverage target
`internal/similarity` ≥ 75%. The worker has fewer error branches than M4a
because passive retry-via-timer eliminates the durable-queue state machine.
The new `Similar*` methods in `internal/scrobble/listenbrainz` add ~14 tests
to that package; should keep its coverage ≥ 85%.
### 7.4 Manual verification post-merge
1. Restart server with M4b code.
2. After ≥1 hour, `psql -c "SELECT count(*) FROM track_similarity WHERE
source = 'listenbrainz'"` → non-zero (assuming user has any MBID-tagged
played tracks).
3. After ≥7 days, observe a `fetched_at` timestamp that's recent —
confirms re-fetch cadence.
4. If MBID coverage in the library is sparse, the table will be small. Not a
bug — M5 (Lidarr suggested-additions) is the eventual answer for
tracks-not-in-library; tagging coverage via Picard is a separate user-side
improvement.
## 8. Backwards compatibility
- New migration; no changes to existing schema.
- New package; no changes to consumer code (M4c will consume; M3's `Score()`
doesn't need the data until then).
- `MaybeEnqueue` and other M4a paths unchanged.
- Worker is new; production behavior identical to pre-M4b until the worker's
first tick fires (1 hour after restart).
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Both `track_similarity` and `artist_similarity` in M4b | Symmetric pattern, M4c needs both, single-PR cost is small |
| 2 | Played-tracks-only input scope | Bounds work to user's actual interaction graph; LB's response naturally surfaces in-library tracks the user hasn't played, preserving discovery |
| 3 | Hourly tick, batch=5 | 240 LB calls/day fits well under LB rate limits; initial backfill in 24-48h |
| 4 | Top-K=20 per LB query, in-library only | Cuts long-tail noise; out-of-library tracks deferred to M5/Lidarr |
| 5 | Public endpoint, no auth | Similarity data is global to the instance; LB requires no token for `/explore/*` |
| 6 | Passive retry via timer (no durable queue) | Failure cost is "1 hour of staleness"; durable queue would be over-engineering vs. M4a's "lost scrobble" stakes |
| 7 | Hardcoded `algorithm` parameter | YAGNI; expose as YAML if telemetry warrants |
## 10. Sub-plan progression (M4)
- M4a (done) — outbound scrobble worker (PR #26).
- **M4b (this) — inbound LB similarity ingest.**
- M4c — radio similarity-driven candidate pool + queue refresh at 80%
(closes M4). M4c also picks up the discovery-mitigation work flagged in
brainstorm: serendipity floor (% random library picks), fallback to wider
pool when similarity-row count is sparse, lazy fetch on radio for
never-played seeds.
@@ -1,444 +0,0 @@
# M4c — Radio similarity-driven candidate pool + queue refresh at 80% (closes M4)
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #347
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
**Builds on:** M4a (PR #26 — outbound scrobble), M4b (PR #27 — inbound similarity ingest)
## 1. Goal
Replace M3's "whole library minus seed minus recently-played" candidate pool
with a similarity-driven pool drawn from four sources (LB-similar tracks,
tracks by similar artists, MB-tag overlap, user's general likes overlapping
seed tags) plus a random-fill source that guarantees a minimum pool size.
Add a sixth scoring term (`SimilarityScore × SimilarityWeight`) so within-pool
ranking reflects per-track similarity strength. Web client auto-refreshes
the radio queue when 80% consumed.
When this slice merges, the M4 milestone closes: the engine has all three v1
components (scoring, session vectors, LB-derived similarity) wired through
both backend and frontend.
## 2. Non-goals (explicit)
- **Lazy LB fetch on radio click** — radio handler stays synchronous and
uses only data already in `track_similarity` / `artist_similarity`.
Sparse-data UX is handled by random-fill augmentation.
- **YAML-configurable per-source Ks** — hardcoded constants for v1.
- **Symmetric edge storage** — still one-way as M4b stored.
- **Per-source score weights as YAML** — tunable in code only.
- **Token-based queue-refresh continuation** — explicit `?exclude=...`
query param.
- **Pre-fetch similarity on track play** — M4b's hourly tick is the only
fetch trigger.
- **Suggested additions / Lidarr** — out-of-library LB matches discarded;
M5 territory.
- **Multi-seed / persistent radio stations** — every call is single-seed
and stateless.
- **Cross-user collaborative filtering source** — `user_cooccurrence`
schema slot reserved, not populated.
- **`SimilarityWeight` per-user override** — operator-only YAML for v1.
## 3. Architecture overview
```
GET /api/radio?seed_track=<uuid>&limit=N&exclude=t1,t2,...
handleRadio
1. Auth + parse params (existing M3)
2. Get seed track + album + artist (existing)
3. q.GetCurrentSessionVectorForUser() (existing — M3)
4. recommendation.LoadCandidatesFromSimilarity(...) ← NEW
- 5-way SQL UNION: LB-similar / similar-artist tracks /
tag-overlap / likes-overlap / random-fill
- Excludes seed + ?exclude= list + recently-played
- Returns []Candidate with per-row SimilarityScore
- On error → fallback to M3's LoadCandidates (logged)
5. recommendation.Shuffle(candidates, weights, ...) ← extended
- M3's Score() gains SimilarityScore × SimilarityWeight term
- Otherwise unchanged
6. Resolve album/artist for picks (existing)
7. Return RadioResponse{Tracks: [seed, pick1, ...]}
Web client
8. Player store watches currentIndex / queue.length ← NEW
- At ≥ 80% consumed, AND radioSeedId is set, AND no refresh
in-flight: GET /api/radio?seed_track=…&exclude=<queue>
- Append response.tracks (skipping index 0 = seed already in queue)
- radioSeedId cleared when user manually enqueues from elsewhere
```
## 4. Candidate pool SQL (`LoadRadioCandidatesV2`)
5-way UNION; each branch produces `(track_id, similarity_score)`. After UNION,
the outer SELECT joins `tracks` + general_likes (for `is_liked`) + LATERAL
play_events aggregation (for `last_played_at` / `play_count` / `skip_count`),
GROUP BY track id, taking `max(similarity_score)` across sources.
```sql
-- name: LoadRadioCandidatesV2 :many
WITH
seed_artist AS (SELECT artist_id FROM tracks WHERE id = $2),
seed_tags AS (
SELECT trim(g) AS tag
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g
WHERE t.id = $2 AND trim(g) <> ''
),
exclude_set AS (
SELECT unnest($5::uuid[]) AS id
UNION ALL
SELECT track_id FROM play_events
WHERE user_id = $1 AND started_at > now() - $4 * interval '1 hour'
),
lb_similar AS (
SELECT ts.track_b_id AS id, ts.score AS sim_score
FROM track_similarity ts
WHERE ts.track_a_id = $2
AND ts.source = 'listenbrainz'
AND ts.track_b_id NOT IN (SELECT id FROM exclude_set)
ORDER BY ts.score DESC
LIMIT $6
),
similar_artists AS (
SELECT t.id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'listenbrainz'
AND t.id NOT IN (SELECT id FROM exclude_set)
ORDER BY asim.score DESC, random()
LIMIT $7
),
tag_overlap AS (
SELECT t.id,
(count(DISTINCT trim(g_raw))::float8
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_raw
WHERE trim(g_raw) IN (SELECT tag FROM seed_tags)
AND t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
GROUP BY t.id
HAVING count(DISTINCT trim(g_raw)) > 0
ORDER BY sim_score DESC
LIMIT $8
),
likes_overlap AS (
SELECT gl.track_id AS id, 0.6::float8 AS sim_score
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
AND t.id NOT IN (SELECT id FROM exclude_set)
AND EXISTS (
SELECT 1 FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') g
WHERE trim(g) IN (SELECT tag FROM seed_tags)
)
ORDER BY random()
LIMIT $9
),
random_fill AS (
SELECT t.id, 0.0::float8 AS sim_score
FROM tracks t
WHERE t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
AND t.id NOT IN (
SELECT id FROM lb_similar
UNION SELECT id FROM similar_artists
UNION SELECT id FROM tag_overlap
UNION SELECT id FROM likes_overlap
)
ORDER BY random()
LIMIT $10
)
SELECT
sqlc.embed(t),
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
max(u.sim_score) AS similarity_score
FROM (
SELECT * FROM lb_similar
UNION ALL SELECT * FROM similar_artists
UNION ALL SELECT * FROM tag_overlap
UNION ALL SELECT * FROM likes_overlap
UNION ALL SELECT * FROM random_fill
) u
JOIN tracks t ON t.id = u.id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
count(*) AS play_count,
count(*) FILTER (WHERE was_skipped) AS skip_count
FROM play_events
WHERE user_id = $1 AND track_id = t.id
) pe ON true
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.created_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
```
### 4.1 Per-source K and score (defaults, hardcoded for v1)
| Source | K | sim_score per row |
|---|---|---|
| `lb_similar` (track_similarity) | 30 | LB raw score (01) |
| `similar_artists` | 30 | `artist_similarity.score × 0.5` |
| `tag_overlap` | 20 | jaccard: `shared_tags / seed_tag_count` |
| `likes_overlap` | 20 | constant `0.6` |
| `random_fill` | 30 | `0.0` |
Total ideal: 130 candidates pre-dedup; 60100 after dedup. Random fill is
drawn AFTER the 4 similarity sources are exhausted (`NOT IN (lb_similar
UNION similar_artists UNION tag_overlap UNION likes_overlap)`), so it
strictly augments rather than overlaps. On very small libraries (<130
tracks total), the pool is naturally smaller — there is no hard floor;
the design assumes typical libraries have hundreds of tracks. M3's `Score()`
+ `Shuffle()` happily ranks small pools.
`max(sim_score)` on dedup so a track in multiple sources keeps its strongest
signal (LB's 0.85 beats tag's 0.4).
## 5. Score() formula extension
`internal/recommendation/score.go`:
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time
PlayCount int
SkipCount int
ContextualMatchScore float64 // M3
SimilarityScore float64 // NEW — max across the 4 similarity sources, in [0,1]
}
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64 // M3
SimilarityWeight float64 // NEW — default 2.0
}
```
Updated formula:
```
score = base
+ (is_general_liked ? LikeBoost : 0)
+ recency_decay * RecencyWeight
- skip_ratio * SkipPenalty
+ contextual_match_score * ContextWeight
+ similarity_score * SimilarityWeight ← NEW
+ jitter
```
`config.RecommendationConfig` gains `SimilarityWeight float64` (yaml
`similarity_weight`, default `2.0`). Same magnitude as `LikeBoost` and
`ContextWeight` — at perfect similarity (1.0), an LB-similar track gets a
+2.0 boost equivalent to an explicit general like.
**Backwards compatible:** zero-value `ScoringInputs{}` and `ScoringWeights{}`
produce M3 behavior because both new fields are zero-defaulted.
## 6. Go-side wiring
### 6.1 `LoadCandidatesFromSimilarity`
`internal/recommendation/candidates.go` gains a sibling to `LoadCandidates`:
```go
type CandidateSourceLimits struct {
LBSimilar int // 30
SimilarArtist int // 30
TagOverlap int // 20
LikesOverlap int // 20
RandomFill int // 30 — drawn from tracks NOT already returned by the 4 similarity sources
}
func DefaultCandidateSourceLimits() CandidateSourceLimits
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// Returns []Candidate (same type as M3 LoadCandidates) so Shuffle() is
// unchanged. Caller falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
exclude []pgtype.UUID,
limits CandidateSourceLimits,
) ([]Candidate, error)
```
Body:
1. `q.LoadRadioCandidatesV2(...)` with the 10 params from §4
2. Existing `loadContextualLikesByTrack(...)` for the contextual scoring inputs
3. Project rows → `[]Candidate` with `Inputs.SimilarityScore = row.SimilarityScore`
`LoadCandidates` (M3 fallback) **stays in place**, still consumed by callers
that want whole-library scoring (unit tests, the radio handler's error path).
### 6.2 Radio handler change
`internal/api/radio.go`:
```go
exclude := parseExcludeParam(r.URL.Query().Get("exclude")) // []pgtype.UUID
exclude = append(exclude, seedID) // always exclude the seed
limits := recommendation.DefaultCandidateSourceLimits()
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec,
exclude, limits,
)
if err != nil {
h.logger.Warn("api: radio: similarity-pool failed, falling back",
"err", err)
candidates, err = recommendation.LoadCandidates(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec,
)
if err != nil {
h.logger.Error("api: radio: load candidates", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error",
"candidate load failed")
return
}
}
weights := recommendation.ScoringWeights{
BaseWeight: h.recCfg.BaseWeight,
LikeBoost: h.recCfg.LikeBoost,
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight, // NEW
}
```
`parseExcludeParam(s string) []pgtype.UUID` — splits on `,`, parses each
UUID, silently drops malformed entries. Returns nil for empty input.
## 7. Frontend (queue refresh at 80%)
`web/src/lib/player/store.svelte.ts`:
- New `radioSeedId` `$state<string | null>` set inside `playRadio()`.
- New `$effect` watching `(player.currentIndex + 1) / player.queue.length`:
- Fires when `radioSeedId` is set, queue is non-empty, ratio ≥ 0.8, and
no refresh in-flight.
- Calls `/api/radio?seed_track=<radioSeedId>&exclude=<queue.map(t=>t.id).join(',')>`.
- On success: appends `response.tracks.slice(1)` (drops the seed at index 0).
- On failure: logs + clears in-flight flag (next track-advance can retry).
- New `appendToQueue(tracks)` helper — pushes onto `player.queue` without
changing `currentIndex`.
- `radioSeedId = null` when user enqueues from non-radio paths (`playQueue`,
`enqueueTrack`, `enqueueTracks`) so the auto-refresh doesn't fire on
manually-built queues.
`RadioResponse` JSON shape unchanged. `TrackRef[]` array works for both
initial calls and refresh calls.
## 8. Test plan
### 8.1 Backend pure tests
`internal/recommendation/score_test.go` extensions:
- `TestScore_SimilarityScore_PerfectMatch_AddsWeightedTerm``1.0 × 2.0 = +2.0` over baseline
- `TestScore_SimilarityScore_HalfMatch``0.5 × 2.0 = +1.0`
- `TestScore_SimilarityScore_Zero_NoEffect` — random/serendipity tracks score same as M3 baseline
### 8.2 Backend integration tests
`internal/recommendation/candidates_v2_test.go` (new):
- `TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes`
- `TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute` (artist score × 0.5 verified)
- `TestLoadCandidatesFromSimilarity_TagOverlapContributes` (jaccard score)
- `TestLoadCandidatesFromSimilarity_LikesOverlapContributes` (0.6 constant)
- `TestLoadCandidatesFromSimilarity_RandomFillToTargetSize` (empty similarity tables → pool ≥ 60)
- `TestLoadCandidatesFromSimilarity_ExcludeListRespected`
- `TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded`
- `TestLoadCandidatesFromSimilarity_DedupTakesMaxScore` (LB 0.85 beats tag 0.4)
- `TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded`
- `TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError`
### 8.3 Backend HTTP tests
`internal/api/radio_test.go` extensions:
- `TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher` (deterministic via fixed RNG)
- `TestHandleRadio_ExcludeParam_FiltersOut`
- `TestHandleRadio_ExcludeParam_MalformedSkipped`
- `TestHandleRadio_FallbackToM3OnSimilarityError` (inject fault)
### 8.4 Frontend tests
`web/src/lib/player/store.test.ts` extensions:
- `radio refresh fires at 80% queue consumption` (5-track queue at index 3)
- `radio refresh appends new tracks (excluding seed)` (queue 5 → 9 after 5-track response)
- `radio refresh does NOT double-fire` (in-flight guard)
- `radio refresh resets when user enqueues from non-radio source`
- `radio refresh below threshold` (5-track queue at index 2 → no refresh)
### 8.5 Coverage targets
- `internal/recommendation` post-M4c: ≥ 80% (currently 73%)
- `internal/api/radio.go`: ≥ 75%
- Web `player/store`: ≥ 80% on the new refresh logic
### 8.6 Manual end-to-end gate (closes M4)
After deploy + M4b worker has filled `track_similarity` for ≥10 played tracks:
1. Click radio from a played track — queue should differ noticeably from M3
2. Listen through ~80% of the queue — observe queue length increase as
auto-refresh fires
3. `psql -c "SELECT count(*) FROM track_similarity"` shows non-trivial data
4. Subjectively: radio quality should feel meaningfully better than M3's
baseline. **This is the closing gate for M4.**
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | 4-source pool composition + random fill | Cross-source diversity; sparse-fallback is automatic via random fill |
| 2 | Always augment with random to floor of 60 | Never returns empty radio; serendipity built in; degrades gracefully on sparse libraries |
| 3 | New `SimilarityScore × SimilarityWeight` term in M3 Score() | Wastes the LB scores otherwise; consistent with the M3 multi-input pattern |
| 4 | No lazy LB fetch | Keeps radio handler synchronous; M4b worker + augmentation cover the gap |
| 5 | `?exclude=...` query param for queue refresh | Stateless, simple, no new server state |
| 6 | Per-source K + score: hardcoded for v1 | YAGNI; expose later if telemetry warrants |
| 7 | Fallback to M3 LoadCandidates on similarity-pool error | Defense in depth for the central radio surface |
## 10. Backwards compatibility
- New SQL query alongside existing `LoadRadioCandidates`; M3 callers
unaffected.
- M3 `LoadCandidates` retained for the fallback path and direct test usage.
- `Score()` signature unchanged; new fields are zero-defaulted so existing
zero-value `ScoringInputs{}`/`ScoringWeights{}` constructions produce M3
scores.
- `/api/radio` request shape extended (adds optional `?exclude=`); existing
callers that don't pass it work identically.
- `RadioResponse` shape unchanged.
- No schema migration — relies on M4b's `track_similarity` /
`artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`.
## 11. M4 closure
After this slice merges, M4 is complete:
- M4a (PR #26) — outbound LB scrobble worker
- M4b (PR #27) — inbound LB similarity ingest
- M4c (this) — radio similarity-driven candidate pool + 80% queue refresh
Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in
library). Out-of-library LB-returned tracks (currently filtered out by the
in-library JOIN) become the natural input for M5's "would you like to add
this?" workflow.
@@ -1,345 +0,0 @@
# M5a — Lidarr connection + search/add proxy + admin shell
> **Status:** Draft for review · 2026-04-29
>
> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). M5 was decomposed into three slices during brainstorming on 2026-04-29:
>
> - **M5a (this spec)** — Lidarr connection + search/add + admin shell. Foundation; ships first.
> - **M5b** — Quarantine workflow (per-user soft-hide, admin resolution UI). Depends on M5a only for the admin shell.
> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance). Depends on M5a's `lidarr_requests` table and add path.
>
> Each ships as its own PR with its own brainstorm/spec/plan cycle.
## 1. Goal
Connect Minstrel to a household Lidarr instance, give every user a search-and-request workflow at `/discover`, and give admins a moderation queue at `/admin/requests`. Approved requests fire Lidarr adds synchronously; a background reconciler matches the resulting downloaded tracks back to the originating request when the next library scan picks them up.
This slice does NOT introduce quarantine, soft-hide, or radio suggested-additions — those are M5b and M5c.
## 2. Goals and non-goals
### Goals
- Operator can connect, configure, test, and disconnect a Lidarr instance from `/admin/integrations` without editing YAML or restarting the server.
- Any authenticated user can search Lidarr at artist / album / track granularity from `/discover`.
- Any authenticated user can submit an add request, which gets persisted to `lidarr_requests` with status `pending`.
- Admin can review pending requests at `/admin/requests`, approve (with optional per-request override of quality profile / root folder) or reject (with optional note).
- Approved requests trigger a synchronous Lidarr add and a library scan.
- A background reconciler worker matches `approved` requests to newly scanned tracks and transitions them to `completed`.
- Hard route gating on `/admin/*` — non-admin users redirected before any admin content loads.
### Non-goals (this slice)
- Quarantine workflow, soft-hide on tracks, admin "delete via Lidarr" path. → M5b.
- Radio "suggested additions" surfacing out-of-library similar tracks. → M5c.
- Webhook ingestion from Lidarr (push notifications on download complete). → optional follow-up; the polling reconciler is sufficient for v1.
- "Pending too long" failure detection / requestor notification on stalled adds. → open question, deferred.
- Self-service password reset, OIDC, or any other identity work. → orthogonal.
- Per-user Lidarr accounts. Lidarr is a single household instance.
## 3. Architecture
### New Go packages
- **`internal/lidarr/`** — HTTP client for Lidarr's v1 API. Mirrors `internal/scrobble/listenbrainz/` shape: `Client` struct with `BaseURL`, `APIKey`, `HTTP` fields. Methods: `LookupArtist(ctx, query)`, `LookupAlbum(ctx, query)`, `LookupTrack(ctx, query)`, `AddArtist(ctx, params)`, `AddAlbum(ctx, params)`, `ListQualityProfiles(ctx)`, `ListRootFolders(ctx)`, `Ping(ctx)`. Returns typed structs; never leaks raw JSON to callers.
- **`internal/lidarrconfig/`** — singleton config service. Reads/writes the `lidarr_config` row, exposes `Get(ctx) (*Config, error)` returning a typed struct, `Save(ctx, *Config) error`. The `Get` method also handles the "config not yet set" case by returning a zero-value `Config{Enabled: false}` so callers can branch cleanly.
- **`internal/lidarrrequests/`** — request lifecycle service:
- `Service``Create(ctx, userID, params)`, `ListPending(ctx)`, `ListByStatus(ctx, status)`, `ListForUser(ctx, userID)`, `Approve(ctx, requestID, adminID, overrides)`, `Reject(ctx, requestID, adminID, notes)`, `Cancel(ctx, requestID, userID)`. `Approve` calls `lidarr.Client.Add*` synchronously and triggers a library scan via the existing scanner package.
- `Reconciler` — background worker analogous to `internal/similarity.Worker`. `Run(ctx)` loop with `tick` interval (default 5 min) calls `tickOnce(ctx)`, which:
1. SELECTs `lidarr_requests WHERE status = 'approved'` with row limits.
2. For each, joins against `tracks`/`albums`/`artists` by MBID hierarchy.
3. Transitions matched rows to `completed`, sets `matched_*_id`, `completed_at`.
### Wiring
- `cmd/minstrel/main.go` gains a third worker spin-up (alongside the scrobble and similarity workers). The reconciler skips its work when `lidarr_config.enabled = false`.
- New handler files: `internal/api/lidarr.go` (search proxy), `internal/api/requests.go` (user-facing endpoints), `internal/api/admin/lidarr.go` (config CRUD + profiles/folders lookups), `internal/api/admin/requests.go` (approval queue).
- New middleware: `RequireAdmin` — checks the user resolved by `RequireUser` has `is_admin = true`; 403s with `{"error":"not_authorized"}` otherwise. Mounted on the `/api/admin/*` route group.
### Data flow — happy path
1. User opens `/discover`, types query, SPA hits `GET /api/lidarr/search?q=…&kind=artist|album|track`.
2. Handler invokes the appropriate `lidarr.Client.Lookup*`, normalizes the response, returns JSON.
3. User clicks "Request" → `POST /api/requests` with `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`.
4. Handler validates the kind→fields invariant, creates a `lidarr_requests` row with status `pending`, returns 201.
5. Admin opens `/admin/requests`, SPA hits `GET /api/admin/requests?status=pending`.
6. Admin clicks "Approve" → `POST /api/admin/requests/:id/approve` with optional `{quality_profile_id, root_folder_path}`.
7. Handler snapshots the chosen values into the row, calls `lidarr.Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. Returns 200 (or surfaces Lidarr error in 4xx/5xx).
8. Reconciler worker on next tick (≤5 min) sees the `approved` row, joins against `tracks`, finds the new track, transitions to `completed` with `matched_track_id` set.
9. Requester's `/requests` page shows status `completed` with a "Listen" link to the now-playable track.
### SPA route gating
- `/admin/*` route group has a `+layout.svelte` (or `+layout.ts`) guard: if `currentUser.is_admin === false`, calls `goto('/')` before child routes load. Page-level gate, not content gating — the operator's instruction.
- Same pattern as the existing auth gate; small extension of the existing layout machinery.
## 4. Schema
Migration **0010_lidarr** in two files (`up.sql`, `down.sql`).
### `lidarr_config` (singleton)
```sql
CREATE TABLE lidarr_config (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled boolean NOT NULL DEFAULT false,
base_url text,
api_key text,
default_quality_profile_id int,
default_root_folder_path text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO lidarr_config (id, enabled) VALUES (1, false);
```
The `CHECK (id = 1)` plus seed row enforces "exactly one row, ever." The Settings UI shows "Connect Lidarr" instead of "Lidarr is connected" when `enabled=false` or `base_url IS NULL`.
### `lidarr_requests`
```sql
CREATE TYPE lidarr_request_status AS ENUM (
'pending', 'approved', 'rejected', 'completed', 'failed'
);
CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track');
CREATE TABLE lidarr_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status lidarr_request_status NOT NULL DEFAULT 'pending',
kind lidarr_request_kind NOT NULL,
lidarr_artist_mbid text NOT NULL,
lidarr_album_mbid text,
lidarr_track_mbid text,
artist_name text NOT NULL,
album_title text,
track_title text,
quality_profile_id int,
root_folder_path text,
decided_at timestamptz,
decided_by uuid REFERENCES users(id) ON DELETE SET NULL,
notes text,
completed_at timestamptz,
matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL,
matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id);
CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status);
CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid);
CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid)
WHERE lidarr_album_mbid IS NOT NULL;
```
**Shape notes:**
- Three matched-* FKs (one per kind) instead of polymorphic — clean SQL, `ON DELETE SET NULL` keeps the historical audit even if the matched track gets removed later.
- Track-kind requests still set `lidarr_album_mbid` (because that's what Lidarr actually adds); `lidarr_track_mbid` is preserved for "I requested this song specifically" display.
- `quality_profile_id` and `root_folder_path` are NULL until decision time. When admin approves, the override (or the config default) gets snapshotted into the row.
- `failed` is reserved in the enum for a future reconciler timeout (e.g., "approved >7 days ago, no match"), but no reconciler logic transitions to `failed` in this slice. It's a placeholder for an obvious follow-up.
**Indexes rationale:**
- `user_id``/requests` page scan ("show my requests").
- `status``WHERE status='pending'` for admin queue, `WHERE status='approved'` for reconciler.
- `lidarr_artist_mbid` / `lidarr_album_mbid` — reconciler joins against `tracks`, `albums` by MBID.
### Down migration
Drops in reverse: indexes → table → enum types → singleton row deletion (table goes anyway).
## 5. API surface
All endpoints under `/api/*`, JSON request/response, `{error: "<code>", message: "<human>"}` envelope on errors. `/api/admin/*` group passes through `RequireAdmin` middleware (403 with `not_authorized` for non-admin tokens).
### User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/lidarr/search?q=&kind=artist\|album\|track` | Proxies Lidarr lookup. Returns normalized list `[{mbid, name|title, secondary_text, image_url, in_library: bool, requested: bool}]`. The `in_library` and `requested` fields are computed server-side: `in_library` joins against `artists`/`albums`/`tracks` by MBID; `requested` is true when ANY user has a non-`rejected`, non-`failed` `lidarr_requests` row with the same MBID — covers `pending`, `approved`, and `completed`. (`completed` in the DB but `in_library=false` in the response would only happen briefly between Lidarr add and library scan; both flags can be true together — UI prefers `in_library` for that case.) Returns `503 lidarr_disabled` if `lidarr_config.enabled=false`. |
| `POST` | `/api/requests` | Create a request. Body: `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`. Server validates kind→required-fields invariant. Returns `201 {request}`. |
| `GET` | `/api/requests` | List the caller's own requests (any status). Ordered by `requested_at desc`. Pagination: `?limit=&before=` cursor. |
| `GET` | `/api/requests/:id` | Single request detail. 404 if not caller's own and caller is not admin. |
| `DELETE` | `/api/requests/:id` | Cancel a still-pending request the caller created. 409 `request_not_pending` if status != `pending`. |
### Admin-only
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/admin/lidarr/config` | Returns current config. `api_key` masked as `"***"` when set, `null` when unset. |
| `PUT` | `/api/admin/lidarr/config` | Body: `{base_url, api_key, default_quality_profile_id, default_root_folder_path, enabled}`. `api_key`: empty string = leave saved value unchanged; non-empty = update. URL validated. |
| `POST` | `/api/admin/lidarr/test` | Body: `{base_url?, api_key?}`. Each field independently falls back to the saved value when absent or empty. Calls `Client.Ping`. Always returns 200 with envelope `{ok: true, version: "..."}` or `{ok: false, error: "..."}` — never an HTTP-level error envelope, so the SPA can render results uniformly. |
| `GET` | `/api/admin/lidarr/quality-profiles` | Proxies Lidarr's quality profile list — populates the Settings dropdown. |
| `GET` | `/api/admin/lidarr/root-folders` | Proxies Lidarr's root folder list — populates the Settings dropdown. |
| `GET` | `/api/admin/requests?status=pending\|approved\|rejected\|completed\|failed&limit=` | Admin's queue view. Default `status=pending`. |
| `POST` | `/api/admin/requests/:id/approve` | Body: `{quality_profile_id?, root_folder_path?}` (override fields; absent = use config default). Snapshots chosen values, calls `Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. |
| `POST` | `/api/admin/requests/:id/reject` | Body: `{notes?}`. Sets status `rejected`, records `decided_*` and `notes`. |
### Error codes
`lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `lidarr_lookup_failed`, `mbid_required`, `request_not_pending`, `request_not_found`, `not_authorized`.
## 6. UI surfaces
All four screens land at the FabledSword design system bar (memory: `project_design_system.md`). Tokens are referenced by name; concrete values live in the design-system memory. Mockups produced during brainstorming live in `.superpowers/brainstorm/<session>/content/` (gitignored) — `discover-fs-v2.html`, `admin-integrations.html`, `admin-requests.html`, `user-requests.html`.
### `/discover` (user-facing)
Search input at top, kind tabs (Artists / Albums / Tracks), card grid of results. Card state derives from the search response's `in_library` and `requested` flags:
- **Kept** (`in_library=true`) — wins over `requested`. Disabled ghost button "In library", "Kept" pill in the badge slot (forest-teal at 15% opacity bg).
- **Requested** (`in_library=false && requested=true`) — disabled ghost button "Requested", subtitle line shows "awaiting review" for pending requests, "downloading" for approved.
- **Requestable** (`in_library=false && requested=false`) — Moss `Request` button with plus icon.
Card layout discipline:
- Card body is `display: flex; flex-direction: column`. Inside, a `.text` block has `min-height` reserving title + meta + badge-row even when fields are absent. Actions block uses `margin-top: auto` so the button always anchors to the bottom of the card.
- `.badge-row` reserves 22px regardless of badge presence — title sits at the same Y across cards.
- Grid `align-items: stretch` keeps cards on the same row equal-height.
Implementation: `<DiscoverResultCard>` Svelte component with props `{kind, artistName, albumTitle?, trackTitle?, imageUrl?, state: 'requestable'|'kept'|'requested', onRequest}`. Reused for any future "list of music things" surface.
Track-kind result requests open a confirmation modal: "Requesting *Track X* will add the album *Album Y*. Continue?" Confirm = Moss, Cancel = Bronze. Disclosure is explicit, not silent.
### `/admin` shell + sidebar
`/admin/*` routes share `+layout.svelte`:
- Role gate: `if (!currentUser.is_admin) goto('/')` before child routes load.
- 220px sidebar with Iron card surface. Active item: 12% accent-tinted background, 2px forest-teal left strip ("you are here").
- Nav items (this slice): Overview · **Integrations** · **Requests** · Quarantine (placeholder for M5b) · Users (future) · Library (future).
Lucide icons at 16px, 1px stroke. Sidebar text: Vellum default, Parchment on active.
### `/admin/integrations`
Lidarr panel (single section in this slice; designed to host more integrations later):
- Header status pill: "Lidarr · connected" (Moss-tinted) when `enabled && reachable`, "unset" (Pewter ghost) otherwise.
- Form rows: Base URL · API key (masked) · Default quality profile (dropdown) · Default root folder (dropdown).
- Action row: **Save changes** = Moss, **Test connection** = Pewter ghost, **Disconnect** = Oxblood + trash icon (right-aligned, separated).
- Inputs on Obsidian (inset feel), 0.5px Pewter borders, 8px radius, focus = `box-shadow: 0 0 0 2px var(--fs-accent)` (no layout shift).
A second placeholder section for "MusicBrainz overrides" with status `unset` foreshadows the panel's role as the integration hub. Not implemented in this slice.
### `/admin/requests`
Tabbed list (Pending / Approved / Completed / Rejected). Tab counts as accent-tinted pills. Default tab `Pending` with badge showing count.
Row anatomy: 56px album-art square (Slate fallback when Lidarr returns no cover) · meta-row with kind pill + "requested by alice · 2h ago" small caps · title in Parchment · meta in Vellum · action cluster: **Override** (Pewter ghost, opens modal) → **Approve** (Moss + check icon) → **Reject** (Bronze + ✕ icon).
Override modal: collapsed-by-default override of `quality_profile_id` and `root_folder_path` for the specific approval. Most approvals click "Approve" without opening this.
Track-kind row's meta line spells out "Approving will add the album *Geogaddi*" — explicit disclosure of the album-promotion behavior.
### `/requests` (user's own)
Single panel listing the caller's requests, ordered by `requested_at desc`. Row anatomy mirrors `/admin/requests` but action cluster is reduced:
- **Pending** → Cancel (Pewter ghost + ✕ icon).
- **Approved** → no actions, "Approved · downloading" status pill (Info-tinted).
- **Completed** → "Listen" link in forest-teal (the page's only brand-moment), navigates to the matched track.
- **Rejected** → no actions, admin's note rendered as Vellum meta when present.
Status pills use the doc's semantic palette (Warning · Info · Moss/Success · Error). Voice rule applied: "Kept" instead of "Completed", "Set aside" instead of "Rejected", "Awaiting review" instead of "Pending review."
## 7. Error handling
### Lidarr unreachable / auth-failed
- `Client.*` methods return typed errors: `lidarr.ErrUnreachable`, `lidarr.ErrAuthFailed`, `lidarr.ErrLookupFailed`.
- Search proxy translates to `503 lidarr_unreachable` or `401 lidarr_auth_failed` — the SPA shows a callout: "Lidarr is unreachable right now. Try again, or check Settings → Integrations." (Voice rule: this is an error/waiting moment, gets the flavored register.)
- Admin approval handler same pattern: returns the error to admin so they can retry without losing the request. The request stays `pending` if the Lidarr call fails — never advances to `approved` without confirmation Lidarr accepted the add.
### Test connection from Settings
- `POST /api/admin/lidarr/test` always returns 200 with `{ok: bool, error?: string, version?: string}` — never an error envelope. Lets the SPA always render the result without parsing HTTP-level errors.
### Reconciler
- Worker errors logged at `WARN`, never propagated. A failing tick doesn't stop the worker — next tick retries.
- If `lidarr_config.enabled = false`, worker silently no-ops each tick.
- Postgres unavailability is a global concern; reconciler errors with the same backoff pattern as `internal/similarity.Worker`.
### Form validation
- Settings: URL must parse, API key must be non-empty if `enabled=true`. Quality profile + root folder must be present and known to Lidarr (validated against `Client.ListQualityProfiles` / `ListRootFolders`).
- Request creation: kind→required-MBID-fields invariant enforced server-side. SPA validates client-side first to avoid round-trips.
## 8. Testing
### Unit tests
- `internal/lidarr/` — table-driven request/response parsing tests against canned fixtures (real Lidarr response samples committed under `internal/lidarr/testdata/`). Covers happy path + auth-failure + 5xx + bad-JSON for each method.
- `internal/lidarrconfig/``Get` returns sensible zero-value when row says `enabled=false`; `Save` updates `updated_at`.
- `internal/lidarrrequests.Service` — pure-logic tests: kind→required-fields validation, status-transition validation (can only approve `pending`, can only cancel `pending`, etc.).
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- Reconciler — seeds an `approved` request + a matching track row, runs `tickOnce`, asserts status transitions to `completed` with correct `matched_*_id`. Covers:
- artist-kind matched by `lidarr_artist_mbid` against `artists.mbid`
- album-kind matched by `lidarr_album_mbid` against `albums.mbid`
- track-kind matched by `lidarr_album_mbid` (track-promoted) against `albums.mbid`
- no match (no transition)
- already-completed row not re-processed
- `lidarr_config.enabled=false` short-circuits to no-op
### HTTP tests (handler level)
- `internal/api/lidarr.go` — search proxy with stubbed `Client`: 200 happy path, 503 disabled, 503 unreachable, 401 auth-failed.
- `internal/api/requests.go` — create with valid + invalid kind/MBID combinations; list-mine returns only caller's rows; cancel pending vs cancel non-pending; cross-user 404.
- `internal/api/admin/lidarr.go` — config GET masks api_key; PUT empty-string preserves api_key; test-connection always returns 200 envelope.
- `internal/api/admin/requests.go` — approve fires Lidarr stub, transitions row, captures defaults from config; approve with override snapshots override values; reject without notes works; non-admin 403 across the board.
### Frontend tests (vitest)
- `<DiscoverResultCard>` — renders three states correctly; calls `onRequest` only in requestable state; reserved-slot CSS verified by computed-style assertion (badge-row min-height, button anchored).
- `/discover` page — debounce search input; renders results from store; transitions state on request submit; track-kind opens confirmation modal; modal Confirm calls API, modal Cancel does not.
- `/admin/requests` page — tab switch refetches; approve fires API and removes row from pending; override modal returns chosen values to approve handler; admin-only redirect verified at layout level.
- `/admin/integrations` panel — empty-state copy; test-connection updates status pill; Disconnect requires confirmation.
- `/requests` page — status pills render with correct semantic class; Cancel works on pending; "Listen" link only renders on completed rows.
### Coverage target
- `internal/lidarr/` ≥ 80%
- `internal/lidarrrequests/` ≥ 80%
- `internal/api/lidarr.go`, `internal/api/requests.go`, `internal/api/admin/*` — handler coverage measured combined ≥ 70% (matches current api package threshold)
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Decompose M5 into M5a / M5b / M5c | Matches the M4 cadence; smaller PRs, faster review, clearer scope per slice |
| 2 | Permissions: search-all, add-admin (request queue) | Operator's call — "browse-and-suggest workflow lets non-trusted household members participate without giving them library-write access" |
| 3 | Config storage: DB-only, Settings UI as the entry point | Operator's product principle — "no one wants to configure yamls; this is a finished product, not a project" (memory: `project_product_not_project.md`) |
| 4 | Settings shape: dedicated `/admin/*` route group with hard route gate | Per-app product surface for admin actions; redirect at layout-level, never load admin content for non-admin (operator's instruction) |
| 5 | Search UX: standalone `/discover` route (option B), not inline-in-search | Operator preference — explicit "I want to add music" surface, separate from local-library search |
| 6 | Granularity: artist + album + track | Operator preference — track-kind resolves to album-kind under the hood (Lidarr's monitor unit is album), explicit modal disclosure rather than silent expansion |
| 7 | Lifecycle detection: library scan as source of truth | Reuses existing scanner; reconciler is a 5-min worker; webhook is a clean follow-up if latency matters |
| 8 | Quality profile / root folder: default + per-add override | Default covers >90% of approvals; override is the escape hatch. Modal is collapsed-by-default |
| 9 | Approve fires Lidarr synchronously, reconcile asynchronously (Approach 1) | Admin gets immediate "Lidarr accepted/rejected" feedback; reconciliation has to be async because downloads take minutes-to-hours |
| 10 | New `RequireAdmin` middleware on `/api/admin/*` route group | Centralized auth check; SPA route gate is UX, server middleware is the security boundary |
| 11 | UI lands at FabledSword design system bar (memory: `project_design_system.md`) | Operator's quality bar — "no more scaffolding-feel UI" (memory: `project_ui_quality.md`); accent only for brand-moments, Moss/Bronze/Oxblood for actions |
| 12 | Track-kind disclosure modal | Lidarr can't fetch a single track without its album; explicit "this will add the album" beats silently expanding the request |
| 13 | `lidarr_requests.failed` status reserved but not transitioned in this slice | Foreshadows a "stalled approval" timeout follow-up; not v1 |
## 10. Out of scope (this slice)
Tracked in the M5 milestone for later sub-plans:
- **Quarantine workflow** — `lidarr_quarantine` table, soft-hide on tracks, admin "delete via Lidarr" path, `/admin/quarantine` page. → M5b.
- **Radio suggested-additions** — `/api/radio` response includes a separate field for out-of-library MBIDs from `track_similarity`; SPA shows "Would you add these?" affordance with inline request submission. → M5c.
- **Lidarr webhook ingestion** — push notifications on download complete; near-real-time status updates on `/requests`. → cheap follow-up after M5c.
- **Failed-request timeout** — reconciler transitions long-stuck `approved` requests to `failed` with operator-facing diagnostics. → operational tightening; not v1.
- **Admin requestor notifications** — toast/badge when a user logs in if their request was approved/completed/rejected. → polish, slot into Fable #349 (UI polish pass) or earlier if it becomes friction.
## 11. Open questions
- **Album cover art proxying** — Lidarr returns image URLs that point at MusicBrainz/Cover Art Archive. The SPA could fetch directly (extra origins, CORS), or Minstrel could proxy them through `/api/cover-art?lidarr=...`. **Decision deferred to plan time** — start with direct fetch, add proxy if CORS bites.
- **Search debounce / cache** — Lidarr's lookup endpoint is the rate-limit-sensitive one. SPA debounce of 250ms + 60s server-side LRU cache on `(query, kind)` is the conservative starting point. Tunable.
- **`failed` status promotion** — at what time threshold does `approved``failed`? Suggest 7 days, but no logic for it ships in this slice.
@@ -1,389 +0,0 @@
# M5b — Quarantine workflow + admin resolution UI
> **Status:** Draft for review · 2026-04-30
>
> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). Decomposition recap from the M5a spec:
>
> - **M5a** — Lidarr connection + search/add + admin shell. Shipped on `dev`.
> - **M5b (this spec)** — Quarantine workflow (per-user soft-hide of tracks, admin resolution UI).
> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance).
>
> Each ships as its own PR with its own brainstorm/spec/plan cycle.
## 1. Goal
Authenticated users can flag any local track as broken with a reason and optional notes. A flagged track disappears from that user's library, search, browse, and `/api/radio` responses — but appears on a dedicated `/library/hidden` page where they can review and un-hide. Admins see an aggregated queue at `/admin/quarantine` (one row per track with reason distribution + per-user details + inline playback) and resolve each row by clearing the reports, deleting the local file, or telling Lidarr to remove the parent album with import-list exclusion.
The dominant user mental model is **data quality**: "this track is a bad rip / wrong file / wrong tags / duplicate." Personal preference framing is out of scope.
## 2. Goals and non-goals
### Goals
- Authenticated user can flag any track with reason ∈ {`bad_rip`, `wrong_file`, `wrong_tags`, `duplicate`, `other`} plus optional notes.
- Trigger affordance is a `<TrackMenu>` overflow (kebab) on every track row and on the now-playing player bar — sibling to `<LikeButton>` but one-click-removed to prevent miss-clicks.
- Quarantined tracks are hidden from that user's `/api/albums/:id`, `/api/artists/:id`, library track lists, search, `/api/radio`, and contextual radio. They remain visible only on `/library/hidden`.
- `/library/hidden` page lists the user's own quarantines with un-hide affordance.
- Admin queue at `/admin/quarantine` aggregates by track, shows reason distribution + count, expandable per-user reports, inline playback, and the three resolution actions.
- Admin resolution actions: **Resolve** (clear the row), **Delete file** (rm file from disk + tracks row + clear), **Delete via Lidarr** (call Lidarr `DELETE /api/v1/album/{id}?deleteFiles=true&addImportListExclusion=true`, cascade Minstrel rows, clear).
- Admin actions write to a `lidarr_quarantine_actions` audit log with snapshot fields so the log stays readable after the underlying tracks/albums are deleted.
- Subsonic API (`/rest/*`) stays unfiltered — quarantine is `/api/*`-only.
### Non-goals (this slice)
- Per-album / per-artist quarantine. Tracks only.
- Quarantine on shared playlists, queues, or Subsonic clients.
- Auto-resolve rules ("if 3 users flag, auto-quarantine globally"). Admin always decides.
- Notifying users when their report is acted on (toast / email). → polish slot.
- Bulk admin operations (multi-select + apply). → revisit if queue grows beyond what one-by-one handles.
## 3. Architecture
### New Go packages
- **`internal/lidarrquarantine/`** — `Service` owning the quarantine lifecycle:
- `Flag(ctx, userID, trackID, reason, notes)` — upsert a `lidarr_quarantine` row.
- `Unflag(ctx, userID, trackID)` — remove the caller's row.
- `ListMine(ctx, userID)` — caller's own quarantines (drives `/library/hidden`).
- `ListAdminQueue(ctx)` — aggregated by track. Returns `[]AdminQueueRow{TrackID, TrackTitle, ArtistName, AlbumTitle, AlbumID, LidarrAlbumMBID, ReportCount, ReasonCounts, LatestAt, Reports []UserReport}`.
- `Resolve(ctx, trackID, adminID)` — delete all per-user rows for the track + write audit row.
- `DeleteFile(ctx, trackID, adminID)` — call `library.DeleteTrackFile` then Resolve.
- `DeleteViaLidarr(ctx, trackID, adminID)` — look up the parent album in Lidarr by MBID, call `Client.DeleteAlbum(id, deleteFiles=true, addImportListExclusion=true)`, remove Minstrel rows for all tracks of that album, clear all related quarantine rows, write audit row.
### Lidarr client additions (`internal/lidarr`)
- `LookupArtistByMBID(ctx, mbid) (LidarrArtist, error)``GET /api/v1/artist?mbId=…`.
- `LookupAlbumByMBID(ctx, mbid) (LidarrAlbum, error)``GET /api/v1/album?foreignAlbumId=…`.
- `DeleteAlbum(ctx, lidarrAlbumID, deleteFiles bool, addImportListExclusion bool) error``DELETE /api/v1/album/{id}?deleteFiles=...&addImportListExclusion=...`. M5b admin path always passes both `true`.
Returns the same typed errors the existing client uses: `ErrUnreachable`, `ErrAuthFailed`, `ErrLookupFailed`, plus `ErrNotFound` for the lookup methods when no row matches.
### Library deletion (`internal/library`)
- New `DeleteTrackFile(ctx, trackID) error` — removes the file from disk and the row from `tracks`. Album/artist rows stay (other tracks may reference them). The reconciler's MBID lookup will simply find no match next pass; M5a's reconcile is unaffected.
### Soft-hide enforcement
Every endpoint that returns track lists in user-context joins against `lidarr_quarantine`:
```sql
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $userID AND q.track_id = tracks.id
)
```
Affected queries (modified, not new):
- `GetAlbumDetail` (track list filter)
- `GetArtistDetail` / library artist views (transitively, via track filter)
- `Search` (track facet)
- Library track-list endpoints
- Radio generator + contextual radio endpoints + similarity-driven autoplay
`/rest/*` Subsonic queries are NOT modified — Subsonic clients see the unfiltered library.
### Wiring
- `cmd/minstrel/main.go` constructs `lidarrquarantine.Service` and injects into `internal/api/handlers`. No new background worker.
- New handler files: `internal/api/quarantine.go` (user-facing), `internal/api/admin_quarantine.go` (admin endpoints).
- Reuses M5a's `RequireAdmin` middleware — `/api/admin/quarantine/*` mounts on the existing admin route group.
### SPA additions
- `<TrackMenu>` Svelte component — kebab icon button + dropdown menu. Renders a single "Flag this track…" item for M5b; designed for future actions.
- `<FlagPopover>` Svelte component — opens from `<TrackMenu>` with the reason `<select>` + notes textarea + Cancel/Flag buttons. Pre-fills if the user has an existing flag (re-flag is upsert).
- `/library/hidden` page — caller's quarantines with un-hide affordance.
- `/admin/quarantine` page — aggregated admin queue with the three resolution actions, inline playback, expandable per-user reports.
- `Shell.svelte` — add `Hidden` to the main nav (visible to all auth'd users).
- `AdminSidebar.svelte` — promote `Quarantine` from placeholder to real link.
- `<TrackRow>` and `<PlayerBar>` — mount `<TrackMenu>` next to `<LikeButton>`.
### Data flow — happy paths
**User flag → soft-hide:**
1. User clicks the `<TrackMenu>` kebab on a track row → "Flag this track…" → popover opens.
2. User picks reason, optionally types notes, clicks Flag → SPA `POST /api/quarantine {track_id, reason, notes?}`.
3. Server upserts `lidarr_quarantine` row, returns 201.
4. SPA optimistically removes the row from the visible list and toasts "Hidden — review on the Hidden tab."
5. Subsequent reads from this user join the quarantine table and silently exclude the track.
**Admin Resolve:**
1. Admin opens `/admin/quarantine`, sees aggregated row, clicks Resolve.
2. SPA `POST /api/admin/quarantine/:track_id/resolve`.
3. Service deletes all per-user rows for the track in a single statement, writes audit row, returns 200 with `{action_id, affected_users}`.
4. SPA removes the row from the queue and toasts the count cleared.
**Admin Delete file:**
1. Admin clicks Delete file → modal-confirm.
2. SPA `POST /api/admin/quarantine/:track_id/delete-file`.
3. Service calls `library.DeleteTrackFile` (rm + DELETE FROM tracks), then deletes per-user rows, writes audit row.
4. SPA removes the row.
**Admin Delete via Lidarr:**
1. Admin clicks Delete via Lidarr → typed-confirm modal ("DELETE").
2. SPA `POST /api/admin/quarantine/:track_id/delete-via-lidarr`.
3. Service:
- Looks up the track's parent album in the local DB (gets `lidarr_album_mbid`).
- 404 with `album_mbid_missing` if the track has no MBID.
- Calls `Client.LookupAlbumByMBID` to translate MBID → Lidarr-internal album ID.
- Calls `Client.DeleteAlbum(id, true, true)`. **No partial state**: if Lidarr fails, returns the typed error; nothing in Minstrel changes; admin retries.
- On Lidarr success, deletes Minstrel rows for all tracks of the parent album, clears related quarantine rows, writes audit row with `affected_users` count + `lidarr_album_mbid`.
4. SPA removes the row, toasts "Removed — Lidarr will not redownload."
## 4. Schema — migration `0011_lidarr_quarantine`
### `lidarr_quarantine` (per-user complaints)
```sql
CREATE TYPE lidarr_quarantine_reason AS ENUM (
'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other'
);
CREATE TABLE lidarr_quarantine (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
reason lidarr_quarantine_reason NOT NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id);
CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC);
```
### `lidarr_quarantine_actions` (admin audit log)
```sql
CREATE TYPE lidarr_quarantine_action AS ENUM (
'resolved', 'deleted_file', 'deleted_via_lidarr'
);
CREATE TABLE lidarr_quarantine_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
track_id uuid NOT NULL, -- not FK: track may be gone
track_title text NOT NULL, -- snapshot
artist_name text NOT NULL, -- snapshot
album_title text, -- snapshot
action lidarr_quarantine_action NOT NULL,
admin_id uuid REFERENCES users(id) ON DELETE SET NULL,
lidarr_album_mbid text, -- non-null on deleted_via_lidarr
affected_users int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id);
CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC);
```
**Shape notes:**
- `(user_id, track_id)` PK mirrors the `general_likes` pattern. Re-flagging upserts (replaces reason/notes).
- `lidarr_quarantine_actions.track_id` deliberately is NOT a foreign key — `deleted_via_lidarr` removes the track row. Snapshot text columns keep the audit log readable post-delete.
- `affected_users` lets the admin see "delete-via-Lidarr at 2026-05-02 affected 4 users" after the fact.
- `lidarr_album_mbid` is non-null only on `deleted_via_lidarr`; gives recovery context if the operator later wants to re-add the album in Lidarr.
### Down migration
Drops in reverse order: indexes → tables → enum types.
## 5. API surface
All endpoints under `/api/*`, JSON, `{error: {code, message}}` envelope on errors. `/api/admin/*` passes through `RequireAdmin` (M5a).
### User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
| `POST` | `/api/quarantine` | Body: `{track_id, reason, notes?}`. Upserts a `lidarr_quarantine` row for the caller. Returns `201 {track_id, reason, notes, created_at}`. 400 `bad_reason` if reason is not in the enum. 404 `track_not_found` if track_id doesn't exist. |
| `DELETE` | `/api/quarantine/:track_id` | Removes the caller's row. 204 on success. 404 `quarantine_not_found` if no row exists for this user+track. |
| `GET` | `/api/quarantine/mine` | Returns the caller's quarantined tracks with full track detail (joined). Used by `/library/hidden`. Ordered by `created_at DESC`. |
### Admin-only
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/admin/quarantine` | Aggregated queue, one row per track. Each row: `{track_id, track_title, artist_name, album_title, album_id, lidarr_album_mbid?, report_count, reason_counts: {bad_rip:N,...}, latest_at, reports: [{user_id, username, reason, notes, created_at}]}`. Ordered by `latest_at DESC`. |
| `POST` | `/api/admin/quarantine/:track_id/resolve` | Clears all `lidarr_quarantine` rows for the track. Writes audit row. Returns `200 {action_id, affected_users}`. |
| `POST` | `/api/admin/quarantine/:track_id/delete-file` | Deletes file from disk + `tracks` row, then clears all quarantine rows, writes audit row. Returns `200 {action_id, affected_users}`. 404 `track_not_found` if already gone. 500 `file_delete_failed` on OS error. |
| `POST` | `/api/admin/quarantine/:track_id/delete-via-lidarr` | Calls Lidarr DELETE on the parent album with `deleteFiles=true&addImportListExclusion=true`, then removes Minstrel rows for all tracks of that album, clears related quarantine rows, writes audit row. Returns `200 {action_id, affected_users, deleted_track_count}`. 503 `lidarr_disabled`/`lidarr_unreachable`/`lidarr_auth_failed`. 404 `album_mbid_missing` if track has no resolvable album MBID. 502 `lidarr_album_lookup_failed` if Lidarr returns no album for the MBID. |
| `GET` | `/api/admin/quarantine/actions?limit=` | Recent admin actions log for audit/recovery. Default `limit=50`, max 200. Ordered by `created_at DESC`. |
### Modified read endpoints (soft-hide enforcement)
Existing endpoints get a quarantine join when called with an authenticated user context:
- `GET /api/albums/:id`
- `GET /api/artists/:id`
- `GET /api/library` and friends
- `GET /api/search` (track facet)
- `GET /api/radio` and contextual radio endpoints
The filter is the `WHERE NOT EXISTS` clause described in §3. `/rest/*` (Subsonic) stays unchanged.
### Error codes added
`bad_reason`, `quarantine_not_found`, `track_not_found`, `album_mbid_missing`, `lidarr_album_lookup_failed`, `file_delete_failed`. Reuses M5a's: `lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `not_authorized`.
## 6. UI surfaces
All against the FabledSword design tokens established in M5a. Voice rule: sentence case, "understated mythic" register on errors and empty states.
### `<TrackMenu>` — overflow menu component
Replaces the originally-considered standalone Flag button. Mounted in:
- `TrackRow.svelte` — sibling to `<LikeButton>` in the row's right-cluster.
- `PlayerBar.svelte` — next to the like button in the player's right cluster.
Layout: Lucide `MoreHorizontal` (or `MoreVertical` — finalize during implementation) at 16px / 1px stroke, `text-text-muted` default, `text-text-primary` on hover. Click toggles a small dropdown anchored to the button. Click-outside and Escape both close.
For M5b the menu has a single item: **Flag this track…** (Lucide `Flag` icon + sentence-case label). The component takes a `track: TrackRef` prop and is structured for future items (Add to queue, Add to playlist, View album, etc.).
### `<FlagPopover>` — flagging UI
Opens from `<TrackMenu>`. NOT a full modal — a small popover anchored near the trigger so the track context stays visible.
Contents:
- Header: "Flag this track as broken" (Inter 13/500).
- Reason `<select>`: "Bad rip", "Wrong file", "Wrong tags", "Duplicate", "Other".
- Notes `<textarea>` (optional, placeholder "What's wrong with it? (optional)"). 200-char soft limit.
- Actions: Cancel (Pewter ghost) · Flag (Bronze + Lucide `Flag` icon).
If the user already has a quarantine on this track, the popover pre-fills the saved reason/notes and the action button reads "Update flag." Re-submitting upserts.
On submit: optimistic hide of the track row + toast "Hidden — review on the Hidden tab." Failure rolls back the optimistic hide and shows an error toast.
### `/library/hidden` (user-facing)
New nav item under the existing Library section in `Shell.svelte`. Position: between Liked and Search (defer to plan time if a different position reads better).
Page shows the user's quarantined tracks ordered by `created_at DESC`. Row anatomy mirrors `/requests` for visual consistency:
- 56px album art square (Slate fallback).
- Pills: kind ("Track") + reason (`bad_rip` etc., accent-tint).
- Title (Parchment) + meta line "by Artist · Album · flagged 2d ago".
- Notes (Vellum, italic) — only when present.
- Action: Un-hide (Pewter ghost + Lucide `RotateCcw` icon). One click — no confirmation. Optimistic remove.
Empty state: "Nothing hidden yet." (voice rule)
### `/admin/quarantine` (admin)
`AdminSidebar.svelte` — promote Quarantine from `placeholder: true` to a real link.
Page header: H2 "Quarantine" + count pill in `accent-tint` when `report_count > 0`.
Aggregated row (one per track):
- 56px album art (left).
- Title (Parchment) · meta: "artist · album · 3 reports — latest 4h ago".
- **Reason distribution row**: pills with `<count>× <reason>` (`2× bad_rip`, `1× wrong_tags`), accent-tint background.
- **Reports details** (collapsed by default): expand caret reveals per-user list with `username · reason · notes · 2h ago`.
- **Inline play button** (Lucide `Play`, accent-colored — qualifies as a brand moment per the design system Hybrid rule): plays via the existing `enqueueTrack` action so admin can verify the issue.
- Action cluster (right-aligned):
- Resolve — Pewter ghost + Lucide `RotateCcw` icon.
- Delete file — Bronze + Lucide `Trash2` icon. Modal-confirm.
- Delete via Lidarr — Oxblood + Lucide `Trash2` + `Cloud` icon. Typed-confirm modal ("DELETE", trimmed equality, matching the M5a Disconnect pattern).
Modal-confirm copy for Delete file: "Remove `<track title>` from disk and clear `<N>` reports? Lidarr may auto-redownload depending on its monitor settings." Cancel (Pewter ghost) · Delete (Bronze).
Typed-confirm copy for Delete via Lidarr: "This will tell Lidarr to remove `<album title>` (artist `<artist name>`) and add it to the import-list exclusion. Affects all `<N>` tracks on the album. Type `DELETE` to confirm." Cancel (Pewter ghost) · Delete (Oxblood, disabled until trimmed input equals "DELETE").
When Delete via Lidarr fails, the modal stays open with an inline error: "Couldn't reach Lidarr — try again, or check Settings → Integrations." Same understated-mythic register as the M5a toast.
When `lidarr_album_mbid` is missing on a row, the Delete-via-Lidarr button renders dimmed with title-attribute tooltip "Local-only track — no Lidarr album to remove." Defers operator confusion ("why doesn't this button work?") at minimal UX cost.
Empty state: "Nothing to triage right now." (voice rule)
## 7. Error handling
### Lidarr unreachable / auth-failed during admin actions
- `Client.LookupAlbumByMBID` and `Client.DeleteAlbum` return the same typed errors as M5a: `ErrUnreachable`, `ErrAuthFailed`, `ErrLookupFailed`, plus `ErrNotFound` for lookups.
- Admin handler maps to the same HTTP codes M5a established (503 + JSON envelope).
- Failure leaves Minstrel state untouched. The quarantine rows stay; admin retries. **No partial-state writes** — Minstrel deletion fires only after Lidarr DELETE confirms.
### Track has no `lidarr_album_mbid`
- Some legacy/imported tracks may have empty MBIDs. The Delete-via-Lidarr action 404s with `album_mbid_missing`. UI dims the button on those rows (see §6).
### File-deletion errors
- `library.DeleteTrackFile` failure (file already gone, permission error) returns the OS error wrapped. Admin handler maps to 500 with `file_delete_failed`. The quarantine row stays so admin can retry. **No partial state.**
### User-facing flag flow
- `POST /api/quarantine` is idempotent on (user_id, track_id) — re-flagging upserts. Errors: 400 `bad_reason`, 404 `track_not_found`. Optimistic SPA hide rolls back on failure with an error toast.
### Soft-hide query joins
- The `WHERE NOT EXISTS` filter is gated on user context. Subsonic clients (legacy API) skip the join entirely. If the join fails (DB error), the entire request fails — quarantine is an integrity boundary, not "best effort."
## 8. Testing
### Unit tests (no DB)
- `internal/lidarr/` — additions: table-driven tests for `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum`. Covers happy + auth-fail + 5xx + 404 + bad-JSON for each, against canned fixtures in `internal/lidarr/testdata/`.
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- `internal/lidarrquarantine.Service`:
- Flag (insert + upsert behaviors).
- Unflag (cleanup).
- ListMine.
- ListAdminQueue — verify aggregation: 3 users × 1 track = 1 admin row with `report_count=3`, correct reason distribution.
- Resolve — writes audit row, clears all per-user rows.
- DeleteFile — track row gone, quarantine cleared, audit written.
- DeleteViaLidarr — with stub Lidarr server: lookup returns album, DELETE called with both flags `true`, Minstrel rows for all album tracks removed, audit row written, `affected_users` count correct. Failure stub: nothing changes locally.
- Soft-hide enforcement — seed 2 users + 1 quarantined track on user A. Verify:
- User A's `GET /api/albums/:id` excludes the track.
- User B's includes it.
- Admin's `/api/admin/quarantine` shows it aggregated.
- Same shape for search and radio endpoints.
### HTTP tests (handler level)
- `internal/api/quarantine.go` — POST (with valid + invalid reasons), DELETE happy + not-found, GET mine.
- `internal/api/admin_quarantine.go` — GET aggregated queue shape, POST resolve / delete-file / delete-via-lidarr (with stub Lidarr), non-admin 403 across the board, action-log GET.
### Frontend tests (vitest)
- `<TrackMenu>` — opens on click, closes on outside-click + Escape, single "Flag this track…" item present, takes track prop.
- `<FlagPopover>` — submits with reason, optional notes; Cancel does not submit; pre-fills when re-flagging; "Update flag" button label when re-flagging.
- `/library/hidden` page — renders user's quarantines, un-hide removes row.
- `/admin/quarantine` page — aggregated rows render with reason distribution, expandable per-user list, Play button calls the player, Resolve/Delete-file/Delete-via-Lidarr each fire the correct API and remove the row on success. Modal-confirm gates Delete file. Typed-confirm gates Delete via Lidarr. Error states surface inline. Dimmed Delete-via-Lidarr button when MBID missing.
### Coverage targets
- `internal/lidarr/` (now extended) ≥ 80%.
- `internal/lidarrquarantine/` ≥ 80%.
- `internal/api/quarantine.go` + `internal/api/admin_quarantine.go` — handler coverage measured combined ≥ 70%.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Per-user soft-hide; admin sees aggregate | Fits the data-quality framing — user files complaint, admin treats as bug queue, but each user's hide stays personal until admin acts |
| 2 | Hide everywhere except `/library/hidden` | Strongest hide; matches "I never want to see this until admin fixes it"; `/library/hidden` keeps un-hide reachable |
| 3 | Subsonic API stays unfiltered | Per `project_subsonic_legacy` memory; new behavior is `/api/*`-only |
| 4 | Track-level only (no album/artist quarantine) | Matches M5a §10 framing; album-level flag is rare enough to not need a dedicated affordance |
| 5 | Admin actions: Resolve / Delete file / Delete via Lidarr | Three buckets covering false alarm, bad file, bad release. The §10 carve-out specifically named "delete via Lidarr" |
| 6 | Always `deleteFiles=true + addImportListExclusion=true` on Lidarr DELETE | Otherwise "Delete via Lidarr" doesn't actually prevent the same broken release from re-downloading |
| 7 | Audit log for admin actions, not for per-user flags | Per-user rows are conceptually transient (flag → unflag or resolve); admin destructive actions need recovery context |
| 8 | Trigger via `<TrackMenu>` overflow (kebab), not standalone Flag button | Prevents miss-clicks against `<LikeButton>`; gives a home for future track actions |
| 9 | Aggregated admin queue, not per-user feed | Admin acts on tracks, not on users; one row per track keeps the queue actionable |
| 10 | Reason fixed enum + optional notes | Aggregation gets actionable buckets; notes give the long tail an escape hatch |
| 11 | Inline Play button in admin queue is brand-moment accent | Per the design-system Hybrid rule — Minstrel-feature interaction (audition the issue) gets the accent |
| 12 | Flag widget is a popover, not a full modal | Keeps track context visible while flagging; smaller commitment than the M5a Add modal |
## 10. Out of scope (this slice)
- Album / artist quarantine.
- Bulk admin operations.
- Auto-resolve thresholds.
- User notifications when reports are acted on.
- Subsonic API quarantine honoring.
- "Delete via Lidarr" with finer-grained options (deleteFiles=false, exclusion=false). Always full-cascade in v1.
## 11. Open questions
- **Default position of `/library/hidden` in nav:** between Liked and Search? Or under a future Library submenu? — defer to plan time.
- **Track without `lidarr_album_mbid`:** UI dims the button with tooltip (current proposal) vs. hides it entirely — confirm during plan time. Leaning toward dimmed-with-tooltip so the operator understands why the action isn't available.
- **Reason "duplicate" follow-up:** the admin might want to know which other track is the duplicate. Out of scope here; potential M5b polish task or M5c overlap with similarity. — defer.
@@ -1,360 +0,0 @@
# M5c — Suggested additions on `/discover`
> **Status:** Draft for review · 2026-04-30
>
> **Sub-plan of:** M5 (Lidarr integration). Final slice of the M5 trilogy:
>
> - **M5a** — Lidarr connection + search/add + admin shell. Shipped on `dev`.
> - **M5b** — Quarantine workflow. Shipped on `dev`, in PR #30.
> - **M5c (this spec)** — Personalized artist suggestions surfaced on `/discover`.
>
> Note: this spec deliberately diverges from the M5a §10 carve-out, which described surfacing out-of-library MBIDs in `/api/radio` responses. Operator redirected during brainstorming: the suggestion surface is a dedicated recommendation feed on `/discover`, decoupled from radio. The §10 reference is preserved here for traceability.
## 1. Goal
Authenticated users land on `/discover` and see a "Suggested for you" feed by default — top-12 out-of-library artists ranked by per-user signal (likes + recency-decayed plays) projected through ListenBrainz artist-similarity. Each suggestion shows attribution ("Because you liked X, played Y, and played Z"), and one click fires an artist-kind add request through M5a's existing Lidarr-add path.
When the user types in the search input, the suggestion feed is replaced by Lidarr search results — M5a's existing behavior. When the input clears, suggestions return.
## 2. Goals and non-goals
### Goals
- Top-12 personalized artist suggestions on `/discover` when the search input is empty.
- Per-user ranking weighted by explicit likes (×5) plus implicit plays (count, recency-decayed by half-life ~30 days).
- Top-3 contributing seed artists named per suggestion: "Because you liked X, played Y, and played Z."
- One-click add via the existing M5a `POST /api/requests` artist-kind path.
- Suggestions hide automatically when the candidate is in-library or already in a non-terminal `lidarr_requests` row.
- The M4b similarity ingest worker is extended to persist unmatched similar-artist MBIDs that it currently discards. No new background worker.
### Non-goals (this slice)
- Album-level or track-level suggestions. Artist-only for v1; albums are a potential v2 layer once we see how artist suggestions feel in practice.
- Realtime invalidation on every like/play. The feed updates passively as user signals accumulate; no push, no immediate refresh.
- Cross-user collaborative filtering. The recommendation is the user's own seed set × ListenBrainz similarity — single-user data only.
- Pagination beyond top-12. Polish slot if the operator wants it later.
- Materialized aggregation of `play_events`. Documented as a v2 lever if on-demand performance ever becomes an issue.
- Cover art for out-of-library suggestions. v1 uses the Lucide `Disc3` fallback; a MusicBrainz Cover-Art-Archive lookup is a polish-pass slot.
- Anything in the `/api/radio` response. M5c is decoupled from radio.
## 3. Architecture
### Data flow
1. **M4b similarity worker (existing)** fetches similar-artists from ListenBrainz per played artist. Today it discards MBIDs that aren't in `artists`. M5c keeps that filter for the `artist_similarity` table but ALSO writes the discarded MBIDs to a new `artist_similarity_unmatched` table — same structure but with no FK on the candidate side.
2. **Suggestion query** (on demand at `/api/discover/suggestions`) joins the user's likes + plays against `artist_similarity_unmatched` via the seeds, computes per-candidate score, ranks top-N.
3. **SPA renders** the response on `/discover` when the search input is empty. Each card reuses the existing `<DiscoverResultCard>` with an extra `attribution` prop.
4. **One-click Request** fires the same M5a `POST /api/requests` flow used by the search-side `<DiscoverResultCard>` — no new add machinery.
### New Go components
- **`internal/recommendation/suggestions.go`** — exports a single `SuggestArtists(ctx, pool, userID, halfLifeDays, limit) ([]ArtistSuggestion, error)` function. Single CTE query (see §4). Returns ranked candidates with their top-3 attribution seeds.
- **`internal/api/suggestions.go`** — `GET /api/discover/suggestions` handler. Authenticated; no admin gate. Calls the recommendation service and resolves seed artist names via a single `GetArtistsByIDs` follow-up (≤36 distinct IDs across top-12 candidates × 3 seeds each).
### Modified Go components
- **`internal/similarity/worker.go`** — `upsertArtistSimilar` keeps the existing matched path and adds a parallel unmatched-persist loop that calls a new `UpsertArtistSimilarityUnmatched` query. Top-K cap mirrors the matched path.
- **`internal/db/queries/similarity.sql`** — extension to ingest the new table. Reads in §4.
### Frontend changes
- `web/src/routes/discover/+page.svelte` — when `debouncedQ === ''`, render the new `<SuggestionFeed>` subcomponent. Hide the kind tabs in this state. Existing search behavior unchanged when input is non-empty.
- `web/src/lib/components/DiscoverResultCard.svelte` — add an optional `attribution?: string` prop that renders an italic Vellum line below the title.
- `web/src/lib/api/suggestions.ts` (new) — `listSuggestions(limit?)` and `createSuggestionsQuery()` factory with `staleTime: 5 * 60_000`.
- `web/src/lib/api/queries.ts``qk.suggestions(limit)` query key.
- `web/src/lib/api/types.ts``ArtistSuggestion`, `SeedContribution` types.
### Wiring
No `cmd/minstrel/main.go` or `internal/server/server.go` changes. The new endpoint mounts on the existing authed route group; the similarity worker is already constructed.
## 4. Schema — migration `0012_artist_similarity_unmatched`
```sql
-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would
-- otherwise discard. Mirrors artist_similarity shape: same composite PK
-- with source, same (seed_id, score DESC) index, same source enum check.
-- The candidate side is text + name (no FK) — that's the whole point.
CREATE TABLE artist_similarity_unmatched (
seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
candidate_mbid text NOT NULL,
candidate_name text NOT NULL,
score DOUBLE PRECISION NOT NULL,
source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (seed_artist_id, candidate_mbid, source)
);
CREATE INDEX artist_similarity_unmatched_seed_score_idx
ON artist_similarity_unmatched (seed_artist_id, score DESC);
```
### Schema notes
- `seed_artist_id` cascades on artist delete — consistent with `artist_similarity`. Removes orphaned suggestion rows automatically when an operator deletes a seed artist.
- `candidate_mbid` deliberately has no FK and no unique constraint by itself; the same out-of-library MBID can appear for many seed artists, and that's the whole mechanism: aggregating contributions across the user's seeds is what produces the score.
- `(seed_artist_id, candidate_mbid, source)` PK gives the worker a clean upsert target while leaving room for future similarity sources.
- `(seed_artist_id, score DESC)` index satisfies the dominant query pattern: "for seed S, give me top-K unmatched candidates by score." This is what the suggestion query exploits inside its CTE join.
- Storage estimate at v1 scale: ~2k seed artists × ~100 unmatched candidates × ~150 bytes = **~30 MB**. Negligible.
### Down migration
```sql
DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx;
DROP TABLE IF EXISTS artist_similarity_unmatched;
```
### Suggestion query shape
```sql
-- name: SuggestArtistsForUser :many
WITH seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0)
AS signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
GROUP BY a.id
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,
seeds.artist_id AS seed_id,
seeds.signal * u.score AS contribution
FROM artist_similarity_unmatched u
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
)
SELECT candidate_mbid,
candidate_name,
SUM(contribution)::float AS total_score,
(array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids,
(array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions
FROM contributions
GROUP BY candidate_mbid, candidate_name
ORDER BY total_score DESC
LIMIT $3;
```
The handler then resolves the top-3 seed UUIDs to artist names via `GetArtistsByIDs` (one extra round-trip; cheap because there are at most 36 distinct seed IDs across top-12 candidates).
### `seeds` CTE rationale
- Likes contribute a flat `5.0` per liked artist — explicit user signal worth more than a single play.
- Plays contribute `Σ exp(-age_days / half_life)` summed across all play events for the user × artist. Recent plays carry close to 1.0; week-old plays ~0.79; 30-day-old plays ~0.37; 90-day-old plays ~0.05. The exponential decay matches what radio's `recencyDecay` already does conceptually (different polarity).
- `WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL` skips artists the user has neither liked nor played — they'd contribute zero signal anyway.
### Worker upsert query
```sql
-- name: UpsertArtistSimilarityUnmatched :exec
INSERT INTO artist_similarity_unmatched (seed_artist_id, candidate_mbid, candidate_name, score, source)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
candidate_name = EXCLUDED.candidate_name,
score = EXCLUDED.score,
fetched_at = now();
```
Idempotent on re-fetch; `candidate_name` and `score` get refreshed when ListenBrainz returns updated values.
## 5. API surface
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/discover/suggestions?limit=&half_life_days=` | Returns the caller's top-N artist suggestions. Defaults `limit=12` (capped at 50), `half_life_days=30`. Both query params are operator-tunable; the SPA only sends `limit` in v1. Returns `200 [{mbid, name, score, attribution: [{artist_id, name, contribution}]}]`. |
### Response type
```ts
type ArtistSuggestion = {
mbid: string;
name: string;
score: number;
attribution: SeedContribution[]; // top-3, ordered by contribution DESC
};
type SeedContribution = {
artist_id: string;
name: string;
contribution: number;
};
```
### Empty cases
- New user with no likes and no plays → `200 []`. Empty-state copy: "Listen to something or like an artist to start getting suggestions."
- All recommendations already in library or already requested → `200 []`. Same copy works (the operator's library exhaustively covers their taste).
### Error codes added
None. The endpoint is read-only and can only fail on internal DB error → `500 server_error`.
### Performance
- Single CTE query at request time + one follow-up `GetArtistsByIDs` lookup for attribution names. Sub-50ms end-to-end at household scale (a few thousand seeds × hundreds of candidates fits well within Postgres' happy path with the planned indexes).
- Frontend wraps the call in TanStack Query with `staleTime: 5 * 60_000` (5 minutes). Repeat visits within a session don't re-query.
### v2 lever (deferred)
If the per-request `seeds` CTE aggregation over `play_events` becomes the bottleneck at large-library scale (year-3 territory), materialize a per-user `artist_signal_cache` table refreshed daily. The suggestion query plugs into the cache instead of scanning `play_events`. No API surface change required.
## 6. UI surfaces
All against the FabledSword design tokens. Voice rule: sentence case, "understated mythic" register on errors and empty states.
### `/discover` page-level state machine
The page has these states (existing M5a states preserved unless noted):
| Search input | Header copy | Tabs | Content |
|---|---|---|---|
| Empty | **"Suggested for you"** + Vellum subtitle (new) | hidden (new) | Suggestion grid, top-12 (new) |
| Empty + zero suggestions | **"Suggested for you"** | hidden | Empty-state copy (new) |
| Non-empty | "Add music to the library" (existing) | visible (existing) | Lidarr search results (existing) |
Subtitle when suggestions are showing: "Out-of-library artists drawn from what you've liked and played."
Empty-state copy when no suggestions: "Listen to something or like an artist to start getting suggestions."
When the user starts typing, the suggestion feed swaps out for the search-results UI. When they clear the input, the suggestion feed comes back. TanStack `staleTime` keeps both responses cached so swaps within a session are instant.
### `<DiscoverResultCard>` extension
Add an optional `attribution?: string` prop. When set, renders below the title in italic Vellum 12px. Empty / undefined → no attribution row (existing search-result rendering unchanged).
Card layout (top to bottom) with attribution:
- Art square (1:1 aspect, Slate fallback with Lucide `Disc3`).
- **Artist name** in Parchment, font-medium 14px.
- **Attribution** (italic Vellum 12px) — only when prop is set.
- Reserved badge slot (22px min-height; empty for suggestions).
- Action button: Moss "Request" with Plus icon.
### Attribution copy format
The handler returns up to 3 contributors per suggestion, ordered by contribution. The SPA formats:
| Contributors | Format |
|---|---|
| 1 | "Because you liked X." or "Because you played X." |
| 2 | "Because you liked X and played Y." |
| 3 | "Because you liked X, played Y, and played Z." (Oxford comma) |
The verb ("liked"/"played") matches the dominant signal for each seed: if the user liked the artist, "liked"; otherwise "played." A single signal type per seed keeps the copy clean.
In v1 the seed names are plain text, not links. Wiring them to `/artists/{id}` is a polish-pass refinement (Fable #349).
### `<SuggestionFeed>` subcomponent
Owned by `/discover/+page.svelte`. ~50 lines. Responsibilities:
- Reads `createSuggestionsQuery()` for data.
- Manages the optimistic-requested `Set<string>` (mirrors the existing search-side machinery on the same page so suggestions and search results share the same optimistic state — a request from search hides the matching card from suggestions on next render too).
- Renders the empty state when `data.length === 0`.
- Maps suggestions → `<DiscoverResultCard>` with `state="requestable"` and the formatted `attribution` string.
Grid: same `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4` as the search-results grid for visual continuity.
## 7. Error handling
### Worker-side (similarity ingest extension)
- `UpsertArtistSimilarityUnmatched` per-row failure logged at WARN, not propagated. Mirrors the existing `UpsertArtistSimilarity` row-error policy.
- Missing `Name` field on a `SimilarArtist` LB response: skip that row at DEBUG (we can't render a suggestion without the name). Verify the LB client surface during T2 — extend the client if `Name` isn't already exposed.
- Existing rate-limit/network handling carries over (same client call, just persisting more of the response).
### Handler-side (`/api/discover/suggestions`)
- DB error on the CTE query → `500 server_error`. SPA renders `<ApiErrorBanner>`. User can retry, or clear the input to fall back to search.
- Empty result is NOT an error — `200 []` with the SPA empty-state copy.
- No Lidarr-disabled branch on the read path: the suggestion feed reads pre-computed similarity data and works even when Lidarr is disconnected. The Request button on each card surfaces the M5a `lidarr_disabled` error when clicked, if Lidarr isn't configured.
### Stale-data behavior
- An admin adding the artist between worker re-ingest and the user's next refresh: the suggestion-query's `WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = ...)` filters in-library candidates at query time, so staleness window is "until next page refresh," not "until next worker tick." Acceptable.
- A user requesting an artist between page renders: same — the `WHERE NOT EXISTS (... lidarr_requests ...)` filters at query time.
## 8. Testing
### Unit tests (no DB)
- Pure-helper tests if any scoring logic lands in Go (not the SQL CTE). Most logic is in SQL; expect minimal Go unit-test surface.
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- `internal/recommendation/suggestions_integration_test.go`:
- **TestSuggestArtists_LikesAndPlaysContributeToScore** — single user, 1 liked + 1 played seed both pointing at the same out-of-library candidate; verify combined contribution.
- **TestSuggestArtists_Top12Cap** — 30 candidates with descending scores; verify only top-12 returned, in order.
- **TestSuggestArtists_AttributionTopThree** — 5 contributing seeds for one candidate; verify `Attribution` has exactly the top-3 by contribution.
- **TestSuggestArtists_RecencyDecayDownweightsOldPlays** — two seeds with 1-day vs 90-day-old plays pointing at the same candidate; verify recent contributes more (exact ratio derives from `exp(-age/half_life)`).
- **TestSuggestArtists_FiltersInLibraryCandidates** — candidate that exists in `artists` is excluded from response.
- **TestSuggestArtists_FiltersAlreadyRequested** — non-terminal `lidarr_requests` row hides the candidate.
- **TestSuggestArtists_RejectedRequestStillShown** — `status='rejected'` does NOT hide the candidate (rejected requests are terminal; user can re-request after admin's reject).
- **TestSuggestArtists_EmptyForNewUser** — user with no likes and no plays returns `[]`.
### Worker integration test
- `internal/similarity/worker_test.go``TestUpsertArtistSimilar_PersistsUnmatchedToTable`: seed an `artists` table with 1 in-library artist; mock the LB client to return 5 similar-artists where 1 is in-library and 4 are not. Verify `artist_similarity` got 1 row and `artist_similarity_unmatched` got 4 rows.
### HTTP handler tests
- `internal/api/suggestions_test.go`:
- **TestSuggestions_HappyPath** — stub the recommendation service, verify JSON shape.
- **TestSuggestions_EmptyForNewUser** — `200 []`.
- **TestSuggestions_AttributionShape** — response includes `attribution` array with up to 3 entries.
### Frontend tests (vitest)
- `web/src/routes/discover/discover.test.ts` extension:
- **suggestion feed renders when input is empty** — mock `createSuggestionsQuery` to return 12 rows; verify 12 cards rendered, kind tabs hidden.
- **typing replaces feed with search** — input `"miles"` → search results visible, suggestions hidden.
- **clearing input restores feed**.
- **attribution copy renders correctly for 1/2/3 seeds**.
- **request button optimistically removes the card**.
- **empty state copy** when query returns `[]`.
### Coverage targets
- `internal/recommendation/` (new code only) ≥ 80%.
- `internal/similarity/` (worker extension) maintained at current level (≥ 80% combined per existing target).
- Combined Lidarr-suite (lidarr, lidarrconfig, lidarrrequests, lidarrquarantine, plus new suggestion code) stays ≥ 80% per the M5a/M5b cadence.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Surface on `/discover` (default state, search-input-empty), not on `/api/radio` | Operator's redirection during brainstorming — recommendations should be a deliberate "I want to add music" surface, not a radio side-channel |
| 2 | Artist-only granularity for v1 | Cleanest mental model; matches Lidarr's monitor unit; album/track suggestions deferred to v2 |
| 3 | Recency-decayed plays + likes weighted 5× | Recency matches radio's existing decay model conceptually; 5× honors the M2 distinction between explicit (like) and implicit (play) signal |
| 4 | Top-3 contributing seeds named per suggestion | Operator framing was "you might like X because you liked Y, Z, **and W**" — multi-seed attribution is the whole point of the surface |
| 5 | Search-empty replaces initial-copy state with the feed; tabs hidden | Operator wanted suggestions front-and-center for users who don't know the feature exists; search is the explicit action |
| 6 | Top-12 fixed, no pagination | Score-vs-noise drops sharply past top-12; operator workflow ends with external search engine for actual decision |
| 7 | On-demand SQL + 5-minute SPA cache; no backend caching layer | Single CTE at household scale is sub-50ms; TanStack `staleTime` handles repeat visits; v2 materialization noted as a future lever if needed |
| 8 | New `artist_similarity_unmatched` table; extend M4b worker | Mirrors `artist_similarity` shape; worker already runs and discards these MBIDs today; persisting is a small addition |
| 9 | Reuse `<DiscoverResultCard>` with optional `attribution` prop | Cheaper than maintaining a parallel `<SuggestionCard>`; the attribution slot fits cleanly above the reserved badge row |
## 10. Out of scope (this slice)
- Album / track suggestions.
- Cross-user collaborative filtering ("users like you also liked X").
- Pagination / infinite scroll on the suggestion feed.
- Cover art for out-of-library candidates (would require MusicBrainz Cover-Art-Archive lookup).
- Linking attribution-seed names to `/artists/{id}` (polish-pass refinement).
- Materialized per-user signal aggregation. v2 lever if performance ever requires it.
- Tunable `half_life_days` exposed in the SPA. Backend accepts it, frontend always uses default.
## 11. Open questions
- **`SimilarArtist.Name` on the LB client.** Verify during T2 that the existing client surfaces the artist name alongside MBID. If not, extend the client (small, additive).
- **Cold-start duration after install.** Until the M4b worker has had a chance to run a few ticks against the user's listened-to artists, the unmatched table is small and suggestions will be sparse. Documented; not a v1 fix.
- **Cross-source diversity.** When `musicbrainz_tag` and `user_cooccurrence` source providers come online (post-M5), the same candidate MBID may appear from multiple sources with different scores. The `seeds → contributions` join already aggregates per `(seed, candidate)` regardless of source via the GROUP BY in the outer CTE — but ranking diversity (don't surface 12 candidates all sourced from one seed) is a polish-pass concern.
@@ -1,410 +0,0 @@
# M6a — Home page redesign + library list relocation
> **Status:** Draft for review · 2026-04-30
>
> **Phase:** UI polish (Fable #349 per `project_ui_quality.md` memory). First slice. Brings the existing scaffolding-quality SPA surfaces (root, library list) up to the FabledSword design-system bar that M5a/M5b/M5c established. Subsequent polish slices (search results, artist detail, album detail) get their own brainstorm/spec/plan cycles.
## 1. Goal
`/` becomes a personalized home page with sections in independent horizontal-scroll rows: **Recently added albums** (2 rows) · **Rediscover** (2 rows: albums + artists) · **Most played tracks** (3 rows) · **Last played artists** (1 row). Each item card has a play affordance (album → from track 1; artist → shuffled queue; track → queue starting from that track). The existing library-as-list moves to dedicated `/library/artists` and `/library/albums` wrapping-grid pages with alphabetical dividers. Pre-FabledSword styling on existing components (`<AlbumCard>`, `<ArtistRow>`) is rebuilt at the design-system bar; `<ArtistRow>`'s click-target bug is retired with the component itself.
## 2. Goals and non-goals
### Goals
- `/` is the new home page. Old library-list content moves to `/library/artists` and `/library/albums`.
- Home page sections (top to bottom):
1. **Recently added albums** — 2 horizontal-scroll rows × 25 cards each (50 most-recent albums).
2. **Rediscover** — 2 horizontal-scroll rows: row 1 = albums (squares), row 2 = artists (circles). 25 each.
3. **Most played tracks** — 3 horizontal-scroll rows × 25 compact track cards each (75 top-ranked tracks).
4. **Last played artists** — 1 horizontal-scroll row × 25 circular artist cards.
- All rows scroll **independently** (each row has its own scroll position).
- All rows in all sections standardize to **25 items** for consistent visual length.
- Each scroll row has **left/right arrow buttons** (Lucide `ChevronLeft`/`ChevronRight`) that page the scroll. Arrows disable at scroll boundaries.
- Item interactions:
- **Album card** click → `/albums/{id}`. Play-button overlay → fetches detail, plays from track 1.
- **Artist card** click → `/artists/{id}`. Play-button overlay → fetches all artist tracks, shuffles, plays.
- **Compact track card** click → plays the track in queue mode within the section's track list.
- Artist cards use a circular thumbnail derived from one of the artist's albums via SQL join (most-recent album with non-null `cover_art_path`). Falls back to Lucide `Disc3` glyph.
- `/library/artists` is a wrapping grid of `<ArtistCard>` (~140px wide) with alphabetical letter dividers between sort-name groups.
- `/library/albums` is a parallel wrapping grid of `<AlbumCard>` with alphabetical dividers by album title.
- `<AlbumCard>` polish-pass: replace pre-FabledSword styling, add play-button overlay alongside the existing add-to-queue button.
- `<ArtistRow>` retired (replaced by `<ArtistCard>` in `/library/artists`); the absolute-link click-target bug goes with it.
- Main nav updated: `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`.
- Per the operator's "no in-between steps" rule: the slice ships finished. No "v2 polish" placeholders left in the home-page surface.
### Non-goals (this slice)
- Section-header "See all →" links / per-section detail routes. The library/artists and library/albums pages serve that need for those entity types. Most-played-tracks-detail and rediscover-detail are not built; horizontal-scroll-with-arrows is the navigation.
- Music-service-style mood/genre/playlist sections.
- Time-window slicing on Most played (e.g., "this week / this month / all time" per row). All 3 rows show one ranked dataset.
- Polish for search results, artist detail, album detail pages — those are M6b/c (separate slices).
- Drag-to-reorder, user-customizable sections.
- Cover-art derivation refresh strategy. The SQL picks one representative album per artist at query time; if the chosen album is later removed, the next query naturally re-derives.
## 3. Architecture
### Backend
**No migration required.** All sections derive from existing tables:
- `albums.created_at` and `tracks.added_at` from migration 0002.
- `play_events.{user_id, track_id, started_at, was_skipped}` from migration 0005.
- `general_likes_albums` and `general_likes_artists` from migration 0006.
- `albums.cover_art_path` already exists; the artist-cover derivation is a query-time `LEFT JOIN LATERAL`.
**New queries** (in `internal/db/queries/recommendation.sql` for the home-page composites; `artists.sql` and `albums.sql` for the listing variants):
- `ListRecentlyAddedAlbumsForUser(user_id, limit)` — albums sorted by `created_at DESC` with `artist_name` joined. Largely shadows the existing `ListAlbumsNewest`; the user_id parameter exists for future personalization (none in v1).
- `ListMostPlayedTracksForUser(user_id, limit)` — tracks ranked by `count(*)` over `play_events WHERE user_id=$1 AND was_skipped=false`, grouped by `track_id`, ordered DESC. Joined to `lidarr_quarantine NOT EXISTS` so quarantined tracks are filtered.
- `ListLastPlayedArtistsForUser(user_id, limit)` — artists ranked by `max(play_events.started_at) WHERE user_id=$1`, with derived `cover_art_path` via the representative-album lateral join.
- `ListRediscoverAlbumsForUser(user_id, limit)`:
- Eligibility: `general_likes_albums.liked_at < now() - interval '30 days'` AND no track from the album has `play_events.started_at > now() - interval '14 days'` for the user.
- Order: `(now() - max(play_events.started_at))` DESC — longest-since-last-play first.
- Fallback: when fewer than `limit` rows match, the query also returns randomly-sampled albums-from-likes to fill out (per (d) decision).
- `ListRediscoverArtistsForUser(user_id, limit)` — same shape via `general_likes_artists`.
- `ListArtistsAlphaWithCovers(limit, offset)` — replaces the existing `ListArtistsAlpha`. Sorts by `sort_name ASC`, joins to a representative album to expose `cover_art_path`.
- `ListAlbumsAlphaWithArtist(limit, offset)` — sorts by album title (or `sort_title`), joins `artists.name`. May already exist as `ListAlbumsAlphaByName` — verify before duplicating.
**New Go service**: `internal/recommendation/home.go` — exports `HomeData(ctx, pool, userID) (*HomePayload, error)` that runs all section queries in parallel via goroutines and assembles a single composite. Mirrors the M5c `SuggestArtists` pattern.
```go
type HomePayload struct {
RecentlyAddedAlbums []AlbumRefView
RediscoverAlbums []AlbumRefView
RediscoverArtists []ArtistRefView
MostPlayedTracks []TrackRefView
LastPlayedArtists []ArtistRefView
}
```
**New API handlers** (in `internal/api/home.go` and `internal/api/library_albums.go`):
- `GET /api/home` — returns the composite payload. Authenticated; sub-100ms target at household scale.
- `GET /api/library/albums?sort=alpha&limit=&offset=` — paged album list mirroring the existing `/api/artists` pattern.
- `GET /api/artists/{id}/tracks` — all tracks for an artist across their albums. Honors quarantine. Used by the artist-card play affordance for client-side shuffle.
**Quarantine compatibility**: most-played-tracks and the artist-tracks endpoint join `lidarr_quarantine NOT EXISTS WHERE user_id = $1`. Other home-page sections operate on albums/artists which aren't quarantined, so no special handling.
### Frontend
**New components** (in `web/src/lib/components/`):
- `HorizontalScrollRow.svelte` — reusable scroll wrapper. Owns horizontal-scroll container, snap behavior, arrow buttons, edge-state disabling. Takes a snippet for items.
- `ArtistCard.svelte` — circular variant of the entity card. Includes play-button overlay.
- `CompactTrackCard.svelte` — small card for "Most played" rows. Card-click plays the track in section-queue mode.
- `AlphabeticalGrid.svelte` — wrapping grid wrapper for `/library/artists` and `/library/albums`, with letter dividers between sort-name groups.
**Modified components**:
- `AlbumCard.svelte` — full polish pass: FabledSword tokens replace pre-M5a styling, play-button overlay added (Lucide `Play` accent on hover, top-center over art square), card-click navigates to `/albums/{id}` cleanly (single-`<a>` wrapper, no relative-positioning click-trap).
- `Shell.svelte``navItems` updated to `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`.
**Retired components**:
- `ArtistRow.svelte` — replaced by `<ArtistCard>` everywhere. The `absolute inset-0 <a>` click-target bug retires with it.
- `LibrarySkeleton.svelte` — was list-skeleton; replaced by a grid-skeleton variant for `/library/artists` and `/library/albums`. (May still be useful for other surfaces; check usage and decide whether to retire or extend.)
**Routes**:
- `web/src/routes/+page.svelte` — full rewrite. Composes `<RecentlyAddedSection>`, `<RediscoverSection>`, `<MostPlayedSection>`, `<LastPlayedSection>` (or inlines the four sections if subdivision adds noise without value).
- `web/src/routes/library/artists/+page.svelte` — wrapping grid of `<ArtistCard>` via `<AlphabeticalGrid>`. Infinite-scroll pagination via TanStack `createInfiniteQuery`.
- `web/src/routes/library/albums/+page.svelte` — parallel page for albums.
**Frontend API client** (in `web/src/lib/api/`):
- `home.ts``listHome()` + `createHomeQuery()` factory (`staleTime: 60_000`).
- `albums.ts` (new or extend existing) — `listAlbumsAlpha(limit, offset)` + infinite-query factory.
- `artists.ts` — extend with `listArtistTracks(artistID)`.
- `types.ts` — add `HomePayload`, ensure `ArtistRef.cover_art_path?: string | null` is exposed.
- `queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks(id)`.
### Routing summary
```
/ → Home (new)
/library/artists → Artist grid (new — replaces old "/" content)
/library/albums → Album grid (new)
/library/liked → existing
/library/hidden → existing (M5b)
/artists/{id} → existing
/albums/{id} → existing
```
## 4. Schema
No migration. Schema is mature enough that all sections are expressible against existing tables.
The artist-cover derivation is a `LEFT JOIN LATERAL` against `albums`:
```sql
LEFT JOIN LATERAL (
SELECT cover_art_path
FROM albums
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
) cov ON true
```
The non-null filter ensures we pick a representative album that actually *has* art when one exists; falls through to NULL when no album in the discography has cover art set.
## 5. API surface
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/home` | Returns the composite home payload. Section sizes capped server-side: 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) / 75 (most_played) / 25 (last_played). SPA receives the full set per section and splits across visual rows. Response time target: sub-100ms. |
| `GET` | `/api/library/albums?sort=alpha&limit=&offset=` | Paged album list. Defaults: `sort=alpha`, `limit=50`, `offset=0`. Returns `{items: AlbumRef[], total: int, limit: int, offset: int}`. |
| `GET` | `/api/artists/{id}/tracks` | Returns all tracks for the artist across their albums, as `TrackRef[]`. Honors per-user quarantine. 404 if the artist doesn't exist. |
### Response shapes
**`GET /api/home`** body:
```ts
type HomePayload = {
recently_added_albums: AlbumRef[]; // up to 50
rediscover_albums: AlbumRef[]; // up to 25
rediscover_artists: ArtistRef[]; // up to 25
most_played_tracks: TrackRef[]; // up to 75
last_played_artists: ArtistRef[]; // up to 25
};
```
**`AlbumRef`** (existing type, extended):
```ts
type AlbumRef = {
id: string;
title: string;
artist_id: string;
artist_name: string;
cover_art_path?: string | null;
// existing fields preserved
play_track_count: number; // new — number of tracks for the play affordance
};
```
**`ArtistRef`** (existing type, extended):
```ts
type ArtistRef = {
id: string;
name: string;
// existing fields preserved
cover_art_path?: string | null; // new — derived from representative album
};
```
`TrackRef` is the existing shape; no changes needed.
### Errors
- `500 server_error` on DB failure for any home-page section. `/api/home` returns the error rather than a partial payload (predictable empty-or-full UX for the SPA).
- `404 not_found` for `/api/artists/{id}/tracks` when the artist doesn't exist.
- Empty arrays are NOT errors — `200 {recently_added_albums: [], …}` is the new-user response. SPA renders empty-state copy per section.
### Caching
Frontend wraps `/api/home` in TanStack Query with `staleTime: 60_000` (1 minute). Repeat home-page visits within a session don't re-query. Like-state changes, new plays, freshly-added albums surface on next refetch (1 min after the last fetch, or on manual refetch / page navigation).
## 6. UI surfaces
All against FabledSword tokens. Sentence case throughout.
### Home page (`/`)
Vertical-scroll page with 4 sections stacked top-to-bottom:
```
┌───────────────────────────────────────────────────────┐
│ Recently added │
│ ◀ [album card] [album card] [album card] … ▶ │ row 1 (newest 25)
│ ◀ [album card] [album card] [album card] … ▶ │ row 2 (next 25)
│ │
│ Rediscover │
│ ◀ [album card] [album card] … ▶ │ albums (25)
│ ◀ [artist circle] [artist circle] … ▶ │ artists (25)
│ │
│ Most played │
│ ◀ [track card] [track card] … ▶ │ row 1 (top 25)
│ ◀ [track card] [track card] … ▶ │ row 2 (next 25)
│ ◀ [track card] [track card] … ▶ │ row 3 (next 25)
│ │
│ Last played │
│ ◀ [artist circle] [artist circle] … ▶ │ row 1 (last 25)
└───────────────────────────────────────────────────────┘
```
**Section header**: H2 in Fraunces 24/500 (Parchment) + optional one-line Vellum subtitle (sentence-case voice rule).
**Empty section state**: section header always renders; rows replaced by Vellum copy. Voice-rule examples:
- Recently added: "Nothing added yet. Scan a folder via the server's config."
- Rediscover: "No forgotten favourites yet. Like some albums or artists to fill this in."
- Most played: "No plays to draw from. Listen to something."
- Last played: "No recent plays."
### `<HorizontalScrollRow>` component
Reusable wrapper for any horizontal-scroll row. Props: `{ items, renderItem }` (snippet).
- Container: `overflow-x: auto`, `scroll-snap-type: x mandatory`, `gap: 16px`.
- Items inside: `flex-shrink: 0`, `scroll-snap-align: start`, consistent width per card type.
- **Left arrow button**: 40px circular Iron-bg button, absolutely positioned at the row's left edge, vertically centered. Lucide `ChevronLeft` 16px / 1px stroke, Vellum default / Parchment hover. Disabled when `scrollLeft === 0`.
- **Right arrow button**: mirrored at the row's right edge with `ChevronRight`. Disabled when `scrollLeft + clientWidth >= scrollWidth`.
- Click on either arrow: `scrollBy({ left: ±containerWidth*0.85, behavior: 'smooth' })` — pages by ~one screenful.
- Native pointer-drag, trackpad swipe, and touch swipe all work via browser default on overflow.
- Arrows fade in on row hover; on touch devices (no hover), always visible. Detect via `@media (hover: hover)`.
### `<AlbumCard>` (polish + play button)
- Square aspect ratio, ~180-200px wide on desktop home page; ~140px in `/library/albums`.
- Title (Parchment 14/500) below the art; artist name (Vellum 12/400) below the title.
- **Play-button overlay** at the art's center on hover (or always-visible on touch). Lucide `Play` filled, 32px, accent forest-teal — qualifies as a brand moment per the design system. Click stops propagation, fetches album detail, calls `playQueue(detail.tracks, 0)`. Existing `+` add-to-queue affordance stays at top-right.
- Card click (anywhere not on the play / like / + buttons) → navigates to `/albums/{id}` via single `<a>` wrapper. The inner action buttons sit at `z-10` above the link to claim their click events.
- Like button stays at top-right alongside the `+` button.
### `<ArtistCard>` (new, circular)
- Square aspect ratio art container with `border-radius: 9999px` (circle). Same width as AlbumCard variants.
- Image source: `cover_art_path` from the representative-album SQL join. When null, render a Slate-bg circle with Lucide `Disc3` 32/1.5px-stroke centered.
- Artist name centered below the circle (Parchment 14/500, single-line truncate).
- **Play-button overlay** at the circle's center on hover. Click → fetch `/api/artists/{id}/tracks`, shuffle client-side, call `playQueue(shuffled, 0)`.
- Card click → navigates to `/artists/{id}`.
### `<CompactTrackCard>` (new)
- Square cover art at top, ~140-160px wide.
- Title (Parchment 13/500, truncate) + artist name (Vellum 11/400, truncate) below.
- Card click → calls `playQueue(sectionTracks, indexInRow)` so playback starts at this track and continues through the section's track list.
- No separate play-overlay; the entire card is the play affordance. Smaller chrome footprint matches the section's "small rows" intent.
### `<AlphabeticalGrid>` (new wrapping-grid wrapper)
- Items wrap normally in a CSS grid (e.g., `grid-template-columns: repeat(auto-fill, minmax(140px, 1fr))`).
- Iterates the items in sort-name order. Whenever consecutive items differ in `sort_name[0]` (uppercase), inserts a row-spanning divider with the letter at left and a thin Pewter rule extending across the grid width.
- No empty-letter padding — if the library jumps from `A` to `D`, the grid jumps from `A` divider to `D` divider directly.
- Used by both `/library/artists` (with `<ArtistCard>`) and `/library/albums` (with `<AlbumCard>`).
### `/library/artists`
- H2 "Artists" + Vellum subtitle showing total count ("327 artists").
- `<AlphabeticalGrid>` wrapping `<ArtistCard>` items at smaller width (~140px).
- Pagination via `createInfiniteQuery` against `/api/artists?sort=alpha&limit=&offset=`. The existing artist-list endpoint stays; the alphabetical-cover variant just supersedes the row-style rendering.
- Sort is fixed at A→Z by `sort_name`. The previous sort dropdown (alpha vs newest) is removed.
### `/library/albums`
- H2 "Albums" + Vellum subtitle ("1,204 albums").
- Identical structure to `/library/artists` but with `<AlbumCard>` (squares) and dividers driven by `albums.sort_title[0]`.
### `Shell.svelte` nav update
```ts
const navItems = [
{ href: '/', label: 'Home' },
{ href: '/library/artists', label: 'Artists' },
{ href: '/library/albums', label: 'Albums' },
{ href: '/library/liked', label: 'Liked' },
{ href: '/library/hidden', label: 'Hidden' },
{ href: '/search', label: 'Search' },
{ href: '/discover', label: 'Discover' },
{ href: '/requests', label: 'Requests' },
{ href: '/playlists', label: 'Playlists' },
{ href: '/settings', label: 'Settings' }
];
```
Admin link injection (M5a) stays.
### Voice rule + design tokens recap
Section copy uses sentence case ("Recently added," "Rediscover," "Most played," "Last played"). Empty states use the understated-mythic register. All chrome uses FS tokens — no raw hex. Lucide icons at 16px/1px or 32px/1.5px stroke per design rule. Brand-moment accent (forest teal) reserved for the play affordance and active-nav state — not the card chrome itself.
## 7. Error handling
This slice is read-only; the error surface is small.
- **`/api/home`**: 500 on any underlying DB failure. Each section query runs in a goroutine; the handler aggregates errors and 500s rather than returning a partial payload.
- **`/api/library/albums`** and **`/api/artists/{id}/tracks`**: 500 on DB error; 404 on artist-not-found for the tracks endpoint.
- Empty section data is NOT an error. SPA renders the section header with empty-state copy.
- **Quarantine effects**: if a user's most-played tracks are entirely quarantined, the section empties to its empty-state. Same UX as a user with no plays.
- **Stale data**: TanStack Query `staleTime: 60_000` keeps repeat home-page mounts cached. Fresh data on next refetch.
- **Play action errors**: card stays in normal state; toast surfaces "Couldn't load tracks. Try again." No optimistic playback.
## 8. Testing
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
`internal/recommendation/home_test.go`:
- `TestHomeData_NewUser_ReturnsAllSectionsEmpty`
- `TestHomeData_RecentlyAdded_OrderedByCreatedAt` — seed 30 albums; verify top 50 in order.
- `TestHomeData_MostPlayed_RanksByPlayCount` — seed plays for 5 tracks with varying counts; verify ranking.
- `TestHomeData_MostPlayed_HonorsQuarantine` — quarantine a top-played track, verify it's filtered.
- `TestHomeData_LastPlayedArtists_OrderedByMaxStartedAt`
- `TestHomeData_LastPlayedArtists_DerivesCoverArtFromAlbum`
- `TestHomeData_RediscoverAlbums_AppliesEligibility` — recently-liked or recently-played albums excluded.
- `TestHomeData_RediscoverAlbums_FallbackWhenSparse` — fewer than `limit` eligibility matches → mixes in randomly-sampled likes.
- `TestHomeData_RediscoverArtists_*` — symmetric coverage.
- `TestListArtistsAlphaWithCovers` — alphabetical sort + cover-art derivation.
### HTTP tests
`internal/api/home_test.go`:
- Happy path returns the composite shape.
- Empty-user returns all-empty arrays.
- Quarantined tracks excluded from most-played.
- 500 on DB error.
`internal/api/library_albums_test.go`:
- Paged response shape; sort=alpha returns alphabetical.
`internal/api/artists_test.go` extension:
- `GetArtistTracks` happy path + quarantine filter + 404.
### Frontend tests (vitest)
- `<HorizontalScrollRow>`: arrow clicks fire `scrollBy`; left disabled at start; right disabled at end; renders items snippet.
- `<AlbumCard>`: play overlay click fetches album detail and calls `playQueue` with index 0; card click navigates to `/albums/{id}`.
- `<ArtistCard>`: play overlay click fetches `/api/artists/{id}/tracks`, shuffles, calls `playQueue` at 0; falls back to `Disc3` when `cover_art_path` is null.
- `<CompactTrackCard>`: card click calls `playQueue` with section tracks at the right index.
- `<AlphabeticalGrid>`: dividers render between items differing in `sort_name[0]`; skipped letters absent.
- `/+page.svelte` (home): all 4 section headers render; empty sections show copy; populated sections render `<HorizontalScrollRow>` with right components.
- `/library/artists/+page.svelte`: wrapping grid renders, alphabetical dividers correct, infinite-scroll pagination.
- `/library/albums/+page.svelte`: same.
- `Shell.test.ts`: nav order updated.
### Coverage targets
Combined `internal/recommendation/` + new `internal/api/home.go` + new `internal/api/library_albums.go` ≥ 80%. Frontend test coverage maintained at current level.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Home page replaces `/`; library list moves to `/library/artists` and `/library/albums` | Operator's framing — "the root of the app should be a page that shows sections" |
| 2 | Independent horizontal-scroll rows with arrow buttons | Operator's explicit pick — independent scroll over lockstep; arrows over swipe-only |
| 3 | All rows standardized to 25 items | Operator's "consistent length" ask; clean visual alignment |
| 4 | Most-played tracks render as compact cards (not text rows) | Operator's clarification — "tracks as little cards" in horizontal scroll |
| 5 | Artist cards use circular thumbnails derived from album covers | Operator's ask; SQL `LEFT JOIN LATERAL` handles derivation, no schema change needed |
| 6 | Album cards get a play-button overlay; artist cards too | Operator's ask — albums play from track 1, artists play shuffled |
| 7 | Rediscover semantics = (d) eligibility filter + sort by longest-since-last-play + fallback | Operator's pick during brainstorming |
| 8 | Albums + artists in Rediscover (separate rows in same section) | Operator's pick |
| 9 | `<ArtistRow>` retired; `<ArtistCard>` (circular) is the only artist visualization | Resolves the click-target bug as a side effect; one component to maintain |
| 10 | `/library/artists` and `/library/albums` use wrapping grid + alphabetical dividers | Operator's ask; matches the FS-design-system bar |
| 11 | `<AlbumCard>` polish-pass within this slice | Doing it now beats leaving it scaffolding-quality across surfaces it appears on |
| 12 | "No in-between steps" — anything that ships, ships finished | Operator's principle — half-built UI loses functionality across sessions |
| 13 | TanStack `staleTime: 60_000` on `/api/home` | Repeat home visits cached; fresh data on next refetch |
## 10. Out of scope (this slice)
- Section "See all →" detail routes (most-played-detail, rediscover-detail). The library/artists and library/albums pages serve that role for those entity types; track and rediscover details defer.
- Mood/genre/playlist sections.
- Per-row time-window splits on Most played.
- Polish for search results, artist detail, album detail pages — separate slices (M6b, M6c).
- Drag-to-reorder home sections, user-customizable sections.
- Cover-art refresh strategy when the chosen representative album is removed (next query naturally re-derives; sufficient for v1).
## 11. Open questions
- **Section subdivision into subcomponents?** The home page composes 4 sections; whether each becomes its own subcomponent (e.g., `<RecentlyAddedSection>`) or all four inline in `+page.svelte` is an implementation choice. Defer to plan-time. Likely subdivide if any section gets >50 lines.
- **Existing `LibrarySkeleton.svelte`** — used elsewhere or only on the old root? Verify during implementation. Either retire or extend with a grid variant for the new pages.
- **Cover-art lookup for fallback artists** — if no album in the discography has art set, do we ever fall through to MusicBrainz Cover Art Archive? Out of scope here; flagged as a Fable polish task.
@@ -1,313 +0,0 @@
# M7 — Flutter mobile foundation + first vertical slice
> **Status:** Draft for review · 2026-05-02
>
> **Phase:** M7 task #356 — first slice. Lays the Flutter client foundation (project scaffold, theme system, auth, networking, audio backend) and ships a working app that authenticates, browses, and plays music. Subsequent slices (search, discover, requests, settings, admin, offline cache) get their own brainstorm/spec/plan cycles.
## 1. Goal
Land a Flutter mobile client (iOS + Android) that authenticates against an existing Minstrel server, browses the library, and plays music — including background playback with lock-screen controls. Visual identity matches the SvelteKit SPA via shared design tokens. Likes are surfaced wherever they live in browse so layout decisions settle early.
## 2. Goals and non-goals
### Goals
- Flutter project scaffold with iOS + Android targets enabled. Folder layout is desktop-ready (Linux/macOS/Windows targets stay disabled; structure does not preclude enabling them later).
- Server URL configuration on first launch — operator points the app at a Minstrel instance.
- Token-based login against `/api/auth/*`. Token stored in `flutter_secure_storage`. OIDC (#298) deferred.
- Theme system: FabledSword tokens exported from web as a single JSON source of truth; Flutter consumes via a generator that emits `tokens.dart` and a `FabledSwordTheme` `ThemeExtension`. Web side switches to consuming the same JSON via a CSS-vars generator. Fonts (Fraunces, Inter, JetBrains Mono) and SVG glyphs (album-fallback, etc.) sync via the same build pipeline.
- Library browse:
- **Home** mirrors the web home — Recently added albums, Rediscover, Most played tracks, Last played artists. Sections render as horizontal-scroll rows.
- **Artist detail** with header (artwork, name, Play button, Like button) and an albums grid.
- **Album detail** with header (artwork, title, artist, Play button, Like button) and a track list (each row has a Like button).
- Player:
- Mini PlayerBar in the shell route, persistent across navigation.
- NowPlaying full-screen view with cover, scrubber, transport controls, queue list.
- `just_audio` wrapped by `audio_service` for background audio + lock-screen / notification controls.
- `MediaItem` populated with track id, title, artist, album, artwork URL.
- Stream URL `/api/tracks/{id}/stream` with session token in request header.
- Likes ship in this slice. Heart-icon button, optimistic toggle with rollback on error. Surfaces: artist header, album header, track rows.
- Visual side-by-side audit: a screenshot pass per screen comparing web ↔ Flutter, gating slice-1 ship until they read as siblings.
- Server compatibility: client checks `/api/health` for a `min_client_version` field on launch and refuses to operate if too old. Server-side addition is part of this slice.
- Distribution:
- **Android:** debug APK from Forgejo CI on every push to `dev`; release APK on tag, attached to the Forgejo release.
- **iOS:** TestFlight build on tag (manual step v1).
- CI: Forgejo Actions workflow runs `flutter analyze`, `flutter test`, and `flutter build apk --debug` on every push.
### Non-goals (this slice)
- Search, discover, requests, admin, settings beyond server URL.
- Listening history, playlists.
- Offline cache (#357 — sibling task; separate brainstorm).
- iOS App Store / Google Play Store listings. APK + TestFlight only.
- OIDC login (#298 — separate task).
- Desktop targets (Linux/macOS/Windows). Tauri-wrapped SvelteKit ships post-v1 as a separate slice.
- Translations / i18n. English copy hard-coded in this slice.
- Per-track / per-album / per-playlist download UI (lives with #357).
## 3. Architecture
### 3.1 Project layout
```
flutter_client/
├── lib/
│ ├── main.dart # ProviderScope + audio_service init
│ ├── app.dart # MaterialApp.router + theme
│ ├── theme/
│ │ ├── tokens.dart # generated from shared/fabledsword.tokens.json
│ │ ├── theme_extension.dart # FabledSwordTheme (colors, type, spacing)
│ │ └── theme_data.dart # Material 3 ThemeData factory
│ ├── api/
│ │ ├── client.dart # dio instance + auth interceptor
│ │ ├── errors.dart # ApiError(code, message, status)
│ │ └── endpoints/ # one file per /api/* surface
│ │ ├── auth.dart
│ │ ├── library.dart
│ │ ├── likes.dart
│ │ └── player.dart # /api/queue, /api/now-playing
│ ├── models/ # DTOs mirroring web/src/lib/api/types.ts
│ ├── auth/
│ │ ├── server_url_screen.dart
│ │ ├── login_screen.dart
│ │ └── auth_provider.dart # Riverpod: session token + user
│ ├── library/
│ │ ├── home_screen.dart
│ │ ├── artist_detail_screen.dart
│ │ ├── album_detail_screen.dart
│ │ ├── library_providers.dart # AsyncValue providers
│ │ └── widgets/ # ArtistCard, AlbumCard, TrackRow, etc.
│ ├── player/
│ │ ├── audio_handler.dart # audio_service AudioHandler
│ │ ├── player_provider.dart # exposes state to UI
│ │ ├── player_bar.dart # mini player
│ │ └── now_playing_screen.dart
│ ├── likes/
│ │ ├── like_button.dart
│ │ └── likes_provider.dart
│ └── shared/
│ ├── widgets/ # buttons, pills, banners, error states
│ └── routing.dart # GoRouter config
├── shared/
│ └── fabledsword.tokens.json # source of truth; copied/symlinked from web
├── android/ ios/ # platform shells
├── tool/
│ └── gen_tokens.dart # tokens.json → tokens.dart
├── assets/
│ ├── fonts/ # Fraunces, Inter, JetBrains Mono (synced from web)
│ └── svg/ # album-fallback.svg etc. (synced from web)
└── test/ # widget + provider tests
```
### 3.2 Layering rules
- **api/** never depends on Flutter widgets — pure Dart, unit-testable without a tester.
- Providers sit between api/ and widgets. Widgets `ref.watch()` providers; providers call api/ and hold state. Widgets never call the api/ layer directly.
- **player/audio_handler.dart** is the only place that talks to `just_audio`. The rest of the app interacts via `player_provider`.
- `models/` are immutable DTOs with `fromJson` / `toJson`. Hand-written for slice 1; codegen (`json_serializable`) added later if the type surface grows past ~30 models.
### 3.3 Auth flow
1. App launches → reads `server_url` from `flutter_secure_storage`.
2. If missing → ServerUrl screen. Field validates by hitting `/api/health`.
3. If present → reads `session_token`. Missing or invalid → Login screen.
4. Login posts to `/api/auth/login`. On success, stores `session_token` and `current_user`.
5. dio interceptor reads the token from secure storage on every request, attaches `Authorization: Bearer <token>`. Server already supports both cookie (web SPA) and Bearer (non-browser clients) via `internal/auth/session.go`; Flutter uses Bearer to avoid cookie-jar handling.
6. Any 401 response → interceptor clears the token, navigates to Login.
### 3.4 Background audio
- `audio_service` runs the `AudioHandler` in a background isolate. UI talks to it via `playbackState` and `mediaItem` streams.
- `MediaItem` includes track id, title, artist, album, artwork URL.
- Lock-screen and notification controls read directly from `MediaItem`.
- Stream URL is the existing `/api/tracks/{id}/stream` with the session token in request header. `just_audio` supports custom HTTP headers via `LockCachingAudioSource` / `ProgressiveAudioSource`.
- Audio errors (network drop mid-stream, server 5xx on stream endpoint) → player pauses, surfaces a banner, queue position retained for resume.
## 4. Theme token sharing
### 4.1 Source of truth
`web/src/lib/styles/tokens.json` — a new file that defines every FabledSword token (colors, font sizes, line heights, spacing, radii) as plain JSON. The Flutter project consumes the same file (copied or symlinked into `flutter_client/shared/`).
### 4.2 Web consumption
Build step `scripts/tokens-to-css.js` reads `tokens.json` and emits `web/src/lib/styles/tokens.generated.css`:
```css
:root {
--fs-accent: #4A6B5C;
--fs-moss: ...;
...
}
```
Tailwind's `theme.extend` reads the same JSON via `tailwind.config.cjs`. Mechanical change to the web side: switch from hand-maintained CSS vars to generated ones. Existing class usage unchanged.
### 4.3 Flutter consumption
`flutter_client/tool/gen_tokens.dart` reads `tokens.json` and emits `lib/theme/tokens.dart`:
```dart
class FabledSwordTokens {
static const accent = Color(0xFF4A6B5C);
static const moss = Color(0xFF...);
static const fontDisplay = 'Fraunces';
static const fontBody = 'Inter';
// sizes, spacings, radii, etc.
}
```
`theme/theme_extension.dart`:
```dart
class FabledSwordTheme extends ThemeExtension<FabledSwordTheme> {
final Color accent, moss, bronze, oxblood;
final TextStyle display, body, mono;
// ...
}
```
Widgets read tokens via `Theme.of(context).extension<FabledSwordTheme>()!.accent`. Hard-coded colors are a lint failure — enforced via a custom `flutter_lints` rule or grep-based pre-commit check.
### 4.4 Fonts and assets
- Fonts (Fraunces, Inter, JetBrains Mono) live in `web/static/fonts/`. Copied to `flutter_client/assets/fonts/` by a sync script and registered in `pubspec.yaml`.
- SVG glyphs (`album-fallback.svg` and any others used in browse) copied to `flutter_client/assets/svg/`. Rendered with `flutter_svg`.
### 4.5 What this guarantees
Palette, typography, spacing parity. It does **not** guarantee identical widget behavior — a Material button has a Material ripple; a Tailwind button has whatever transitions you wrote. We accept that gap; both feel idiomatic to their platform while looking like the same product. Side-by-side mockup audit (web screenshot vs Flutter screenshot) per screen before slice ship.
## 5. First-slice screens
### 5.1 Server URL screen
- Single text field (URL, validated as reachable).
- "Connect" button hits `/api/health` and stores on 200.
- Error states: invalid URL, connection refused, certificate error.
- Re-shown if any later request fails with `connection_refused` or DNS error.
### 5.2 Login screen
- Username + password fields → `POST /api/auth/login`.
- Stores session token + user under `session_token` / `current_user`.
- Surfaces server errors via the same error-code mapping the web uses (`invalid_credentials`, `lidarr_unreachable`, etc.) — copy table lifted from `web/src/lib/api/error-copy.ts` exported as JSON, consumed by both clients.
- "Change server URL" link routes back to ServerUrl screen.
### 5.3 Home screen
- Mirrors the web home: 4 sections — Recently added albums, Rediscover (albums + artists), Most played tracks, Last played artists.
- One `homeProvider` returning `AsyncValue<HomeData>` from `GET /api/home`.
- Sections render as horizontal-scroll rows; section coupling matches the web (`HorizontalScrollRow.svelte` pattern — multi-row sections share a single horizontal scroller).
- Tap an artist/album card → routes to detail. Tap a track → starts playback at that track, queue = remainder of the section's track list.
- Pull-to-refresh re-fetches the home payload.
### 5.4 Artist detail
- Header: artwork (square or circular per FabledSword), name, large accent-fill Play button (48dp diameter), LikeButton.
- Albums grid below (cards link to album detail).
- Backed by `GET /api/artists/{id}` + `GET /api/artists/{id}/albums`.
- Play button → fetches all artist tracks, shuffles, plays.
- Like button → toggles `POST/DELETE /api/likes/artists/{id}` with optimistic update.
### 5.5 Album detail
- Header: artwork, title, artist, Play button, LikeButton.
- Track list with index, title, duration, per-row LikeButton (track-level).
- Backed by `GET /api/albums/{id}` + `GET /api/albums/{id}/tracks`.
- Tap a track → queue = whole album from that track forward, starts playback.
- Play button → starts at track 1, queue = whole album.
### 5.6 PlayerBar (shell-route persistent)
- Visible on every screen except ServerUrl, Login.
- Cover (small), title/artist (truncated), play/pause, skip-next.
- Tap → expands NowPlaying.
- Hidden when `audio_service` reports no active queue.
### 5.7 NowPlaying screen
- Full-screen. Large cover, title/artist, scrubber, play/pause/skip-prev/skip-next.
- Queue list below; each row tappable to jump.
- Swipe down to dismiss.
### 5.8 Routing
`GoRouter` with shell route. PlayerBar lives in the shell so it persists across navigation. Tab placeholders (home/search/requests/settings) are present in the bottom nav; only home is wired in this slice. Search/requests/settings tabs render a "Coming in slice N" placeholder so the nav layout settles.
### 5.9 State boundaries
Riverpod providers, all `AsyncValue`-typed unless noted:
- `serverUrlProvider``String?` from secure storage.
- `authProvider``AsyncValue<User?>`. Notifies when token changes.
- `homeProvider``AsyncValue<HomeData>`.
- `artistProvider(id)` — family.
- `albumProvider(id)` — family.
- `likedProvider(EntityRef)` — family. Tracks per-entity like state across the app.
- `playerProvider``Stream<PlayerState>` exposed as a Riverpod stream provider.
## 6. Backend touch-points
Slice-1 server-side work is small:
- **`min_client_version` in `/api/health`** — 5-line addition. Returns `{"status": "ok", "min_client_version": "0.1.0"}`. Client compares against its build version and refuses if too old.
- **Bearer-auth login response** — confirm `/api/auth/login` returns the session token in the response body (not just as a `Set-Cookie` header) so the Flutter client can store it in secure storage. If the current handler only sets a cookie, add the token to the JSON response. Trivial change, surfaced in the scaffold task.
- Confirm `/api/tracks/{id}/stream` honors `Range` headers fully. Required for `just_audio` seek; expected to already work, but verified in the scaffold task.
No new endpoints are required for slice 1. Delta-sync, sync tokens, stream-quality negotiation, and any endpoint additions for offline (#357) are deferred to those slices' brainstorms.
## 7. Error handling
dio interceptor maps server responses into typed `ApiError(code, message, status)` regardless of envelope shape (`{error: "code"}` vs `{error: {code, message}}`). UI consumes `AsyncValue.error` and surfaces:
- `connection_refused` / DNS errors → reusable `ConnectionErrorBanner` with Retry, plus a "Change server URL" link back to the ServerUrl screen.
- `unauthenticated` → token cleared, push Login.
- Any other code → toast with the user-facing message from the shared error-copy JSON consumed by both clients.
- Audio errors (network drop mid-stream, 5xx on stream endpoint) → player pauses, banner shown, queue position retained for resume.
- `version_too_old` (from `min_client_version` check on launch) → blocking modal: "Update required to use this server. <Update button>" linking to the latest APK / TestFlight.
## 8. Testing
Three layers:
- **Unit tests** for `api/` and providers: fake dio with mocked responses, assert provider state transitions. No widgets involved. Fast, runs in `flutter test` without a tester.
- **Widget tests** for screens: pump screen with `ProviderScope` overrides for the providers it depends on, assert text/buttons render correctly. Same idea as the Vitest + Testing-Library pattern on the web side.
- **Integration test** for the auth + browse + play happy path: real server stub on localhost, real `audio_service`. One golden-path test for slice 1.
Forgejo Actions `flutter.yml` runs `flutter analyze` + `flutter test` + `flutter build apk --debug` on every push to `dev`. iOS build gated to tagged releases.
## 9. Distribution and versioning
- **Android:** debug APK on every push to `dev` (CI artifact). Release APK on tag, attached to the Forgejo release page. Self-host install via the release page; no Play Store for v1.
- **iOS:** TestFlight build on tag (manual step v1, automated later). No App Store for v1.
- **Versioning:** Flutter app version mirrors the server tag (`v0.1.0` server → `0.1.0` Flutter). Within a tagged release, client and server are paired; no feature drift.
- Server-side `min_client_version` enforces the pairing — old clients can't talk to new servers (or vice versa) and are told to update.
## 10. Open decisions tracked for later slices
These are explicitly *not* decided here; they belong to subsequent brainstorms but are listed so we don't lose them:
- **Codegen for models** — hand-written DTOs in slice 1; revisit when type surface exceeds ~30 models.
- **Push notifications** — out of scope for v1 mobile; revisit alongside #366 (notifications).
- **Crash reporting** — out of scope for slice 1; pick a self-hosted target (Sentry, Glitchtip) before v1 ship.
- **OIDC client integration** (#298) — `flutter_appauth` is the standard Flutter library. Slot in once server-side OIDC lands.
- **Offline cache** (#357) — sibling task. The Riverpod provider boundaries are designed so a `dataSource` provider can swap remote ↔ local without rewiring consumers.
- **Desktop Tauri shell** — post-v1 wrap of the SvelteKit SPA. Not part of this spec.
## 11. Origin
Operator request 2026-05-02: "create an m7 and push all of these ideas into it. I want v1 to ship as a full product. I want a full flutter app with all the functionality that would require." Scope decisions made in brainstorm 2026-05-02 (this doc):
- Mobile-first for v1; desktop deferred to v1.1 as a Tauri-wrapped SPA (not Flutter desktop).
- This brainstorm covers foundation + first vertical slice (auth + browse + player + likes); subsequent features are their own brainstorm/spec/plan cycles.
- Stack: Riverpod + dio + just_audio + audio_service + flutter_secure_storage + Material 3 + ThemeExtension from FabledSword tokens.
- Likes included in slice 1 (operator: "include in slice 1 as they will affect layout and I don't want to have a lot of refactoring later").
## 12. Related
- M7 #356 — this spec implements its first slice.
- M7 #357 — offline cache; sibling, separate brainstorm. Architecture here designed to slot it in cleanly.
- M7 #298 — OIDC; deferred. Slice 1 is token-only.
- M7 #351 — Profile/Account; surfaces in a later Settings slice.
- M7 #352 — Playlists; out of scope for slice 1.
@@ -1,256 +0,0 @@
# M7 #362 — Web SPA Theme Toggle (Dark / Light / System)
**Status:** Design
**Milestone:** M7 (v1.0 web-first sweep)
**Issue:** Fable #362
**Scope:** Web SPA only. Flutter mobile mirror is out of scope (separate task in Tier 1 catch-up).
## Goal
Let a user choose Dark, Light, or follow-system theme for the Minstrel web SPA. Persist the choice across sessions. Apply it before first paint to avoid FOUC.
## Out of scope
- Per-page or per-section theme overrides.
- Multi-color brand palettes beyond the dark + light pair.
- Time-of-day automatic switching independent of the OS preference.
- Cross-tab live sync (settings change in tab A doesn't propagate to tab B). Documented as a known limitation.
- Flutter client mirror.
- Animated transitions between modes (instant flip).
## Architecture
### Token strategy
Existing system: design tokens defined in `web/src/lib/styles/tokens.json`, generated to `web/src/lib/styles/tokens.generated.css` by `web/scripts/tokens-to-css.js`, and consumed by Tailwind via `var(--fs-*)` references in `web/tailwind.config.js`.
Theme is implemented by emitting **two blocks** of custom-property values in the generated CSS:
```css
:root {
/* Dark palette (default) + flat tokens used in both modes */
--fs-obsidian: #0E1013;
...
--fs-moss: #5A7563;
--fs-on-action: #E8E2D2;
}
[data-theme="light"] {
/* Light overrides only — flat tokens are NOT repeated */
--fs-obsidian: #F8F5EE;
--fs-iron: #ECE6D5;
...
}
```
Tailwind config is **not** changed. The `var(--fs-*)` indirection means every utility class (`bg-fs-iron`, `text-fs-parchment`, etc.) automatically swaps when the `[data-theme="light"]` selector matches.
The active theme is selected by setting `data-theme="light"` (or absent / `"dark"`) on `<html>`.
### tokens.json schema change
Current shape (flat):
```json
{
"colors": {
"obsidian": "#0E1013",
"iron": "#14171A",
...
}
}
```
New shape (dark values are the current `tokens.json` values, preserved):
```json
{
"colors": {
"dark": {
"obsidian": "#14171A",
"iron": "#1E2228",
"slate": "#2C313A",
"pewter": "#3F4651",
"parchment": "#E8E4D8",
"vellum": "#C2BFB4",
"ash": "#9C9A92"
},
"light": {
"obsidian": "#F8F5EE",
"iron": "#ECE6D5",
"slate": "#DCD3BD",
"pewter": "#B8AE94",
"parchment": "#14171A",
"vellum": "#2C313A",
"ash": "#5C6068"
},
"flat": {
"moss": "#4A5D3F",
"bronze": "#8B7355",
"oxblood": "#6B2118",
"warning": "#8B6F1E",
"error": "#C04A1F",
"info": "#3D5A6E",
"accent": "#4A6B5C",
"on-action": "#E8E4D8"
}
}
}
```
`flat` tokens are emitted once in `:root` only. They do not flip.
### Generator change
`web/scripts/tokens-to-css.js` is updated to:
1. Walk `colors.dark` + `colors.flat`, emit a `:root { ... }` block with `--fs-<name>: <value>;` lines.
2. Walk `colors.light`, emit a `[data-theme="light"] { ... }` block.
3. Preserve existing non-color sections of `tokens.json` (typography, spacing, etc.) — those continue to emit into `:root` only.
### `--fs-on-action` token
A new flat token, value `#E8E4D8` (frozen at the dark-mode parchment value). Used as the label color on action buttons whose backgrounds (`--fs-moss`, `--fs-bronze`, `--fs-oxblood`) do not flip. Without this, action button text would invert in light mode and lose contrast against the mid-tone earth backgrounds.
Tailwind exposes it as `text-action-fg` via a new entry in `tailwind.config.js`:
```js
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)' // new
}
```
The audit step replaces `text-text-primary` with `text-action-fg` on every site where it pairs with `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive`.
### Theme store
New file: `web/src/lib/stores/theme.svelte.ts`.
Exports a small rune-state module:
```ts
type ThemePreference = 'dark' | 'light' | 'system';
type ResolvedTheme = 'dark' | 'light';
export const theme: { value: ThemePreference }; // user preference
export const resolvedTheme: { value: ResolvedTheme }; // what's actually applied
export function setTheme(pref: ThemePreference): void;
```
Implementation:
- On module load, read `localStorage['minstrel:theme']` (default: `'system'`).
- If `'system'`, resolve via `matchMedia('(prefers-color-scheme: light)')` and subscribe to `change` events to keep `resolvedTheme` in sync.
- `setTheme(pref)` writes localStorage, updates `theme.value`, recomputes `resolvedTheme.value`, and sets `document.documentElement.dataset.theme = resolvedTheme.value`.
### FOUC prevention
`web/src/app.html` gains an inline script in `<head>`, **before** any CSS link:
```html
<script>
(function () {
try {
var t = localStorage.getItem('minstrel:theme') || 'system';
var resolved = t === 'system'
? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: t;
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) { /* localStorage unavailable — accept default dark */ }
})();
</script>
```
This runs synchronously before paint, so the correct theme is applied on first render. SSR/prerender paths see no theme attribute server-side; the script applies it client-side before paint.
### Settings UI
`web/src/routes/settings/+page.svelte` gains an "Appearance" card at the top:
```
┌─ Appearance ─────────────────────┐
│ Theme: │
│ ( ) Dark (•) Light ( ) System │
│ │
│ System follows your device's │
│ light/dark preference. │
└──────────────────────────────────┘
```
Implemented as three radio inputs (or a segmented control component if one already exists in the project — implementer to check). Selection writes through `setTheme()`.
## Light palette spec
Full token map with values:
| Token | Dark (current) | Light (new) | Role |
|---|---|---|---|
| `--fs-obsidian` | `#14171A` | `#F8F5EE` | page bg |
| `--fs-iron` | `#1E2228` | `#ECE6D5` | card/surface bg |
| `--fs-slate` | `#2C313A` | `#DCD3BD` | hover surface |
| `--fs-pewter` | `#3F4651` | `#B8AE94` | borders |
| `--fs-parchment` | `#E8E4D8` | `#14171A` | primary text |
| `--fs-vellum` | `#C2BFB4` | `#2C313A` | secondary text |
| `--fs-ash` | `#9C9A92` | `#5C6068` | muted text |
| `--fs-moss` | `#4A5D3F` | *(unchanged)* | secondary action bg |
| `--fs-bronze` | `#8B7355` | *(unchanged)* | warning action bg |
| `--fs-oxblood` | `#6B2118` | *(unchanged)* | destructive action bg |
| `--fs-warning` | `#8B6F1E` | *(unchanged)* | semantic marker |
| `--fs-error` | `#C04A1F` | *(unchanged)* | semantic marker |
| `--fs-info` | `#3D5A6E` | *(unchanged)* | semantic marker |
| `--fs-accent` | `#4A6B5C` | *(unchanged)* | brand accent |
| `--fs-on-action` | `#E8E4D8` *(new)* | *(unchanged)* | action button label |
Design rationale: light palette is "parchment / aged paper" — warm cream surfaces, dark text. Reads as an old book opened on a daylit desk; aligns with the FabledSword mythic register and preserves the bookish tone in light mode. Action and semantic colors are mid-tone earth/jewel hues that read on both light and dark backgrounds.
## Files touched
**Modify:**
1. `web/src/lib/styles/tokens.json` — restructure `colors` into `dark`/`light`/`flat` sub-maps; add `on-action` to `flat`.
2. `web/scripts/tokens-to-css.js` — emit dual `:root` + `[data-theme="light"]` blocks for colors; preserve other token sections in `:root`.
3. `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'` entry.
4. `web/src/app.html` — add inline FOUC script in `<head>`; remove the static `class="dark"` on `<html>` (theme is now driven by `data-theme`).
5. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector.
6. Action button label class audit — find every site using `text-text-primary` paired with `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` and replace `text-text-primary` with `text-action-fg`. Verified count via grep: ~25 sites.
**Create:**
7. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence.
**Generated (do not hand-edit):**
8. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens`.
## Edge cases
1. **Action button text contrast** — addressed by `--fs-on-action` non-flipping token. Audit during implementation.
2. **Cover-art images** — external content, doesn't theme. Eyeball the missing-cover placeholder/glyph fallback in light mode.
3. **Focus rings** — grep for `ring-offset-fs-*`; if any uses a flipping token, switch to a non-flipping equivalent or `ring-offset-background`.
4. **Cross-tab sync** — out of scope. Theme change in tab A doesn't update tab B until reload. Documented limitation.
5. **SSR** — server doesn't know preference; inline head script applies it client-side before paint.
6. **Error pages** — SvelteKit `+error.svelte` and framework error fallback inherit `data-theme` attribute. No special handling.
7. **localStorage unavailable** — try/catch around localStorage read; falls back to default `system`.
## Testing
Per project memory rule (no in-task tests; CI verifies), implementation tasks do not run tests. Plan does include the following test files for CI to execute:
- **Token generator unit test** (`web/scripts/tokens-to-css.test.js`) — feed sample tokens.json with `dark`/`light`/`flat`, assert emitted CSS contains `:root` block with dark + flat values, and `[data-theme="light"]` block with light values only.
- **Theme store test** (`web/src/lib/stores/theme.svelte.test.ts`) — mock `matchMedia` and `localStorage`. Verify (a) default is `system` resolving via matchMedia, (b) `setTheme('light')` persists + updates resolved, (c) matchMedia `change` event updates resolvedTheme when in `system` mode.
- **Settings Appearance card test** (`web/src/routes/settings/Appearance.test.ts`) — render, click each segment, assert localStorage write + `data-theme` attribute on `<html>`.
**Manual verification checklist** (pre-merge eyeball pass): home, library, search, playlists list, playlist detail, queue, history, settings, admin (users / library / quarantine / lidarr) — all in both modes.
## Implementation order (informs plan)
1. Restructure `tokens.json` (no behavior change yet — generator still emits one block).
2. Update generator to emit dual blocks.
3. Regenerate `tokens.generated.css`. Verify no visual regression in dark mode.
4. Add `--fs-on-action` flat token. Audit + swap action button label classes.
5. Create `theme.svelte.ts` store.
6. Add FOUC script to `app.html`.
7. Add Appearance card to Settings page, wire to store.
8. Manual eyeball pass in both modes; fix any contrast/border surprises.
@@ -1,253 +0,0 @@
# M7 #363 — Branding polish (app title, OG meta, install instructions)
**Status:** Design
**Milestone:** M7 (v1.0 web-first sweep)
**Issue:** Fable #363
**Scope:** Web SPA + Go static-serve handler + repo README. No Flutter changes; no PWA / service worker / installable-app work (deliberately deferred — Flutter #356 is the canonical mobile path, Tauri is the canonical desktop path).
## Goal
Final-mile branding polish before the v1.0 tag:
- Per-route page titles in the browser tab.
- Per-instance app-name override (`SMARTMUSIC_BRANDING_APP_NAME`) that surfaces in the SPA shell header, page titles, OG meta, and theme color hints.
- OG / Twitter share-preview metadata so a URL pasted into Slack / Discord / iMessage renders correctly.
- `<meta name="theme-color">` that follows the dark/light theme chosen by the user (and the prefers-color-scheme media query for first paint).
- Operator-first README rewrite — discovery surface for someone landing on the Forgejo repo cold.
## Out of scope
- PWA manifest, "Install Minstrel" app prompt, Apple touch icon, Android maskable icon — Flutter (#356) covers mobile, Tauri (post-v1) covers desktop. Adding a PWA layer in between is unneeded work.
- Service worker / offline shell.
- Hand-designed OG / share image — a programmatically-rendered placeholder ships with this slice; the operator-quality artwork is a separate one-off task.
- Per-page OG images (each route shares the same single image).
- Localised page titles (English-only in v1).
- A general-purpose `/api/instance` runtime config endpoint — `window.__MINSTREL__` exposes the values the SPA needs synchronously without an extra request.
## Architecture
### Branding flows through Go template injection, not a runtime fetch
```
config.yaml / env: branding.{app_name, description}
▼ config.Load
Server.BrandingCfg{ AppName, Description }
▼ on every GET / (SPA index)
internal/server/spa.ServeIndex
│ parses embedded index.html as html/template once at startup
│ renders into a bytes.Buffer, writes response
HTTP body: rendered index.html with
- <title>{{ .AppName }}</title> ← default; per-route overrides
- <meta property="og:title" content="{{ .AppName }}">
- <meta property="og:description" content="{{ .Description }}">
- <meta property="og:image" content="/brand/og-image.png">
- <meta name="twitter:card" content="summary_large_image">
- <meta name="theme-color" media="(prefers-color-scheme: dark)"
content="#14171A">
- <meta name="theme-color" media="(prefers-color-scheme: light)"
content="#F8F5EE">
- <script>window.__MINSTREL__ = { appName: "...", description: "..." };</script>
```
Other static SPA assets (`/_app/*`, `/favicon.png`, `/brand/og-image.png`) continue to be served as raw bytes through the existing `http.FileServer` — only the index.html path is templated.
Why template injection instead of a public `/api/instance` endpoint:
1. Share-preview crawlers (Slack, Discord, iMessage, Twitter, OpenGraph aggregators) do not run JavaScript. OG meta has to be present in the served HTML, not injected after hydration.
2. Once the server is templating HTML for the crawlers, exposing the same values via an inline `<script>window.__MINSTREL__ = ...</script>` block is free for the SPA — no fetch round-trip, no loading state, no flash of "Minstrel" before the operator's chosen name renders.
3. The current set of "instance config" is two strings. A typed config endpoint can be added later without redoing this layer; the inline global naturally extends to hold more keys.
### Config surface
`internal/config/config.go` gains a `Branding` struct:
```go
type BrandingConfig struct {
AppName string `yaml:"app_name" env:"SMARTMUSIC_BRANDING_APP_NAME"`
Description string `yaml:"description" env:"SMARTMUSIC_BRANDING_DESCRIPTION"`
}
```
`Config` adds `Branding BrandingConfig`. Defaults are applied in `Load`:
- `AppName` empty → `"Minstrel"`
- `Description` empty → `"Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration."`
`config.example.yaml` documents the new section under a `branding:` block.
### Server wiring
`server.New` gains a `BrandingConfig` parameter — same pattern just shipped for `DataDir` in `6d1709c`. `cmd/minstrel/main.go` threads the config through.
`internal/server/spa.go` (new file, or absorbed into the existing static-serve handler):
- At construction time, reads `index.html` from the embedded SPA filesystem and parses it as `html/template.Template`.
- `ServeHTTP` renders the template into a `bytes.Buffer` against a small data struct (`{ AppName, Description, OGImageURL }`) and writes the body. Cache-control is `no-cache` so a config change on container restart is reflected immediately.
- Other paths (`/_app/*`, `/favicon.png`, `/brand/*`) continue to be served by the existing static handler, untouched.
`html/template` context-aware escaping handles XSS automatically: the values land inside an HTML element body for the meta-tag content attributes, and inside a JS string literal in the inline `<script>` block. An operator who sets `AppName="</script><img onerror=...>"` gets their string safely escaped in both contexts.
### SPA wiring
**`$lib/branding.ts`** (new, ~10 lines):
```ts
declare global {
interface Window {
__MINSTREL__?: { appName: string; description: string };
}
}
export const appName = (): string =>
(typeof window !== 'undefined' && window.__MINSTREL__?.appName) || 'Minstrel';
export const description = (): string =>
(typeof window !== 'undefined' && window.__MINSTREL__?.description) ||
'Self-hosted music server.';
export const pageTitle = (section?: string): string =>
section ? `${appName()} · ${section}` : appName();
```
The `typeof window !== 'undefined'` guard is defensive against any future SSR / pre-render contexts; current SPA mode (`ssr=false`) doesn't strictly need it.
**Per-route titles.** Each `+page.svelte` declares a `<svelte:head>` block:
```svelte
<svelte:head><title>{pageTitle('Search')}</title></svelte:head>
```
Format follows Q5 decision (larger-to-smaller): `Minstrel · Search`, `Minstrel · Library · Artists`, `Minstrel · Artist · Radiohead`. Dynamic routes resolve from existing query data, with a fallback while loading:
```svelte
<svelte:head><title>{pageTitle(artist.data ? `Artist · ${artist.data.name}` : 'Artist')}</title></svelte:head>
```
Routes to wire (audited during implementation; not exhaustively enumerated here):
- Static: `/`, `/search`, `/library`, `/library/artists`, `/library/albums`, `/library/songs`, `/library/hidden`, `/discover`, `/queue`, `/history`, `/playlists`, `/settings`, `/login`, `/admin/*`.
- Dynamic: `/artist/[id]`, `/album/[id]`, `/playlists/[id]`.
The implementer audits every `+page.svelte` and adds a `<svelte:head>`; missing pages fall back to the document-level `<title>{{ .AppName }}</title>` from the templated shell.
**Shell header.** `Shell.svelte` currently renders the literal text `Minstrel` in the header. Replace with `{appName()}`. One-line change.
**Theme-color follow-through.** The two server-templated `<meta name="theme-color">` tags with prefers-color-scheme media queries cover first paint and pre-hydration. After hydration, `+layout.svelte` adds an `$effect` that updates a single live `<meta name="theme-color">` element's `content` attribute reactively against `theme.resolved` (the existing rune from #362):
```ts
$effect(() => {
const meta = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"]:not([media])'
) ?? (() => {
const m = document.createElement('meta');
m.name = 'theme-color';
document.head.appendChild(m);
return m;
})();
meta.content = theme.resolved === 'light' ? '#F8F5EE' : '#14171A';
});
```
The result: on a phone, when the user toggles Dark / Light / System in Settings, the address-bar / status-bar tint follows in real time.
## OG / share image
`web/static/brand/og-image.png` — committed PNG, 1200×630, fresh asset. The slice ships a **placeholder** generated once via `tools/gen-og-placeholder.mjs`:
- A small Node script (one-off; runs locally, not in CI) using `@resvg/resvg-js` (or `sharp` if simpler) to rasterise a tiny inline SVG.
- SVG: Fraunces wordmark "Minstrel" in `parchment` (#E8E4D8), centred on `obsidian` (#14171A) background, accent forest-teal `#4A6B5C` underline rule.
- Output committed to `web/static/brand/og-image.png`.
Operators / future hand-designed artwork drop a replacement PNG at the same path — no code change required.
If you'd rather treat the hand-designed artwork as a hard prerequisite and skip the placeholder generator, drop `tools/gen-og-placeholder.mjs` from this slice and add a single hand-designed PNG instead.
## README rewrite
New `README.md` structure (operator-first, top-down):
1. **What is Minstrel** — one paragraph, tagline; `<!-- TODO: screenshot -->` placeholder for a real screenshot to be added later.
2. **Highlights** — six bullets distilled from `docs/smart-music-server-spec.md`:
- OpenSubsonic-compatible (works with existing Subsonic clients).
- Server-side smart shuffle (state lives where it belongs).
- Dual-like model (general + contextual likes).
- Session-aware radio + ListenBrainz similarity.
- Lidarr integration for library import + automation.
- Web SPA today; Flutter companion in flight (#356).
3. **Quickstart** — copy-pasteable `docker compose up` block; minimal `compose.yaml` and `config.yaml` snippets; pointer to `config.example.yaml` for the full surface.
4. **Configuration** — most-asked env vars at a glance: `SMARTMUSIC_STORAGE_DATA_DIR`, `SMARTMUSIC_BRANDING_APP_NAME`, scanner settings, Lidarr / ListenBrainz pointers.
5. **Updating**`:main` rolling vs `:v1.0.x` pinned; brief note on database migrations being automatic.
6. **Specs** — links to the existing `docs/smart-music-{server,client}-spec.md`.
7. **Development** — current dev workflow / dual-process content, condensed and moved to the bottom (contributor audience).
8. **License**.
Stale content from the current README is replaced rather than appended. The CI / container-image section becomes a sub-section of "Updating" with current Forgejo Actions truth.
## Error handling
| Failure mode | Behaviour |
|---|---|
| `BrandingConfig.AppName == ""` | Falls back to `"Minstrel"` in `config.Load`; SPA `appName()` helper has the same fallback so a missing inline-script bootstrap can't crash titles. |
| `BrandingConfig.Description == ""` | Falls back to default tagline as above. |
| `branding:` section absent from yaml | Same as above — zero values become defaults. |
| Operator sets `AppName` to a string containing HTML / JS | `html/template` escapes safely in both meta-attribute and JS-string contexts. Verified by test (see below). |
| `og-image.png` missing on disk | Static file 404; share-preview crawlers tolerate broken images. Placeholder generator above prevents this in practice. |
| `+page.svelte` author forgets to add `<svelte:head>` | Falls back to document-level `<title>{{ .AppName }}</title>` — visible but not catastrophic. |
## Testing
Tests are written but **not run during implementation** — CI verifies on push (per project rule).
**Go (`internal/server/spa_test.go`, new):**
- Render index.html with `BrandingCfg{AppName: "Custom", Description: "Custom desc."}`; assert `<title>Custom</title>` present, `og:title` contains `Custom`, inline `window.__MINSTREL__` block present and parseable.
- Render with zero-value config; assert defaults `"Minstrel"` and the default tagline applied.
- Render with `AppName = "</script><img onerror=alert(1)>"`; assert the rendered body contains escaped output (no live `</script>` close inside the inline `<script>` block; no unescaped HTML in meta attributes).
**SPA (Vitest):**
- `pageTitle('Search')` returns `"Minstrel · Search"` with stub `window.__MINSTREL__`.
- `pageTitle()` (no section) returns `"Minstrel"` only.
- `appName()` returns the global value when set, falls back to `"Minstrel"` when `window.__MINSTREL__` undefined.
- `Shell.svelte` renders `appName()` value in its header (RTL query, no DOM scraping).
- Theme-color `$effect`: jsdom test that toggling `theme.resolved` between `'dark'` and `'light'` updates the `meta[name="theme-color"]:not([media])` element's `content` attribute.
**README:** not testable; manual review.
## Migration / rollout
- No DB migration required.
- Config change is backwards-compatible: omitting the `branding:` block applies defaults equivalent to current behaviour.
- Existing operators upgrading from a pre-#363 build see no behaviour change unless they explicitly set `branding.app_name`. Default tab title becomes `Minstrel · {Page}` instead of bare `Minstrel`, which is a visible improvement, not a regression.
## File list
**New:**
- `internal/server/spa.go` (templated index handler).
- `internal/server/spa_test.go` (template + escaping tests).
- `web/src/lib/branding.ts` (helper).
- `web/src/lib/branding.test.ts` (helper tests).
- `web/static/brand/og-image.png` (placeholder, committed).
- `tools/gen-og-placeholder.mjs` (one-off rasteriser; not run in CI).
**Edited:**
- `internal/config/config.go``BrandingConfig` struct + load defaults.
- `cmd/minstrel/main.go` — thread `BrandingCfg` through `server.New`.
- `internal/server/server.go``New` signature gains `BrandingConfig`; field stored on `Server`.
- `config.example.yaml` — document `branding:` section.
- `web/src/app.html` — replace literal `<title>` with `{{ .AppName }}` template token; add OG / theme-color meta with template tokens; add inline `<script>window.__MINSTREL__ = ...` block.
- `web/src/routes/+layout.svelte` — theme-color `$effect`.
- Every `web/src/routes/**/+page.svelte` listed under "Routes to wire" — add `<svelte:head>{pageTitle(...)}</svelte:head>`.
- `web/src/lib/components/Shell.svelte` — header text → `{appName()}`.
- `README.md` — full operator-first rewrite.
## Open items deferred to follow-up
- Hand-designed OG / share-image artwork (drop into `web/static/brand/og-image.png` whenever ready).
- Real screenshot for the README intro (`<!-- TODO: screenshot -->` placeholder).
- Localised page titles (English-only in v1).
- Per-page OG images.
These are tracked as comments in the relevant files, not separate Fable tasks.
@@ -1,371 +0,0 @@
# M7 — Playlists CRUD (slice 1 of #352)
> **Status:** Draft for review · 2026-05-03
>
> **Phase:** M7 task #352 slice 1 of 3. Lays the schema, backend, and `/playlists` UI for **manually-curated** playlists. Slice 2 (system-generated daily mixes) and slice 3 (home-page playlists row) are sibling spec/plan cycles. Per the v1=full-product framing all three ship before tag — none is "deferred."
>
> **Sequencing:** Lands after M7 #372 (track-actions menu, commit `1cf58b1`) which reserved an "Add to playlist…" slot in `<TrackMenu>` for this slice to wire.
## 1. Goal
Operators can create named playlists, add tracks from anywhere in the library, reorder them by drag, edit name/description, mark them public to share with other users on the same Minstrel instance, and delete them. Tracks that vanish from the underlying library (Lidarr re-import, manual removal, file deletion) leave behind an explicit "track unavailable" row in the playlist with the original metadata snapshot — the playlist never silently shrinks.
## 2. Goals and non-goals
### Goals
- Manual playlists only. The owner adds and orders tracks explicitly. No rule-based smart playlists in this slice (or anywhere in v1 — system-generated daily mixes from #352 slice 2 are a different concept that produces same-shape `playlist_tracks` rows).
- Sharing model: **private by default, owner publishes** via `is_public`. Edit / delete / reorder / add / remove tracks are owner-only. Read is owner OR `is_public=true`. Other users see public playlists in browse but cannot mutate them.
- Track-removal cascade: **soft mark with denormalized snapshot.** `playlist_tracks` carries `title`, `artist_name`, `album_title`, `duration_sec` denormalized at time-of-add. When the upstream `tracks` row is deleted, `track_id` becomes `NULL` (FK `ON DELETE SET NULL`); the playlist row stays, with the snapshot visible. UI renders such rows greyed-out + strikethrough; the operator can manually remove or replace.
- Reorder API: full ordered-list rewrite. Drag-and-drop client → `PUT /api/playlists/{id}/tracks` with `{ordered_positions: [...]}` → server rewrites all positions in a single transaction. Idempotent.
- Cover art: server-generated 2×2 collage of the first 4 contributing tracks' album covers. Generated **synchronously inline** on every track add/remove/reorder via Go's `image/jpeg` stdlib. Cached on disk at `data/playlist_covers/{playlist_id}.jpg`; path stored in `playlists.cover_path`. Empty playlists → `cover_path=NULL`, UI renders a FabledSword glyph.
- API surface lives at `/api/playlists*`. **No `/rest/*` Subsonic parity** — aligns with `project_subsonic_legacy.md`. Subsonic clients won't see playlists; that's a separate post-v1 task if/when needed.
- "Add to playlist…" entry in `<TrackMenu>` (the slot M7 #372 reserved) — clicking opens a submenu listing the user's own playlists alphabetically + a "+ New playlist…" option.
- `/playlists` index page (rewrite of the existing placeholder) shows two sections: "Your playlists" (owned, public + private) and "From other users" (others' public playlists). "+ New playlist" CTA in header.
- `/playlists/{id}` detail page: collage + name + description + public/private chip + edit + delete buttons (owner only) + drag-to-reorder track list + per-row remove + LikeButton + the existing `<TrackMenu>` kebab on each row.
- Denormalized rollups (`track_count`, `duration_sec`) on `playlists` so list pages don't aggregate at read time — updated by the service layer on every track-list mutation.
- Empty-state copy on the `/playlists` index when the operator has no playlists yet.
### Non-goals (this slice)
- System-generated playlists (daily "For You", "Songs like X") — slice 2.
- Home-page playlists row — slice 3.
- Smart / rule-based playlists — post-v1; different feature concept.
- Subsonic playlist endpoints — post-v1 if at all.
- Collaborative editing (multiple users editing one playlist) — post-v1.
- Public-playlist forks/copies into the operator's own collection — post-v1.
- Cover-collage refresh when an underlying track's cover changes silently — slice trade-off; the collage stays stale until the next playlist mutation. Rare in practice.
- Per-playlist Subsonic permissions / role gating — out of scope.
- Flutter mirror — separate slice once #356 resumes (per the M7 ordering decision: web first, Flutter mirrors after).
## 3. Architecture
### 3.1 Schema (migration 0014)
```sql
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
cover_path text, -- relative to data/, NULL until first cover
track_count integer NOT NULL DEFAULT 0,
duration_sec integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX playlists_user_idx ON playlists (user_id, updated_at DESC);
CREATE INDEX playlists_public_idx ON playlists (is_public, updated_at DESC) WHERE is_public;
CREATE TABLE playlist_tracks (
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position integer NOT NULL,
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
-- denormalized snapshot at time-of-add; survives upstream track removal
title text NOT NULL,
artist_name text NOT NULL,
album_title text NOT NULL,
duration_sec integer NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, position)
);
CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL;
```
Notes:
- Composite PK on `(playlist_id, position)` is intentional. Reorder rewrites the PK by deleting + reinserting in the same transaction. Acceptable: typical playlists are ≤ a few hundred rows.
- The partial index on `playlist_tracks.track_id` is non-redundant for the FK lookup that fires when a track is deleted (`ON DELETE SET NULL`). Without it, deleting a track triggers a full scan of `playlist_tracks`.
- `playlists.user_id` cascades on user deletion. Acceptable: deleting a user should remove their data.
- `playlists.cover_path` is a relative path (e.g., `playlist_covers/abc.jpg`); resolved against the configured `data_dir` from `config.yaml`.
### 3.2 Backend module (`internal/playlists`)
New package. Exports a `Service` with methods:
- `Create(ctx, userID, name, description, isPublic) (*PlaylistRow, error)`
- `Get(ctx, callerUserID, playlistID) (*PlaylistDetail, error)` — enforces visibility (owner OR `is_public`).
- `List(ctx, callerUserID) ([]PlaylistRow, error)` — owner's all + others' public, ordered by `updated_at DESC`.
- `Update(ctx, userID, playlistID, name?, description?, isPublic?) error` — owner-only.
- `Delete(ctx, userID, playlistID) error` — owner-only. Cascades `playlist_tracks`; deletes the cached cover file from disk.
- `AppendTracks(ctx, userID, playlistID, trackIDs) error` — owner-only. Validates each track exists; populates the denormalized snapshot fields by joining at insert time. Bumps `track_count`, `duration_sec`, `updated_at`. Triggers cover regeneration.
- `RemoveTrack(ctx, userID, playlistID, position) error` — owner-only. Deletes the row at `position`; renumbers all rows with `position > p` to close the gap. Bumps rollups. Triggers cover regeneration.
- `Reorder(ctx, userID, playlistID, orderedPositions []int) error` — owner-only. Validates the input is a permutation of the existing positions. Atomic re-write in a single transaction. Triggers cover regeneration if the first 4 positions changed.
Typed errors: `ErrNotFound`, `ErrForbidden`, `ErrInvalidInput`, `ErrTrackNotFound`.
`internal/playlists/collage.go` exposes `GenerateCollage(ctx, pool, playlistID, dataDir) (path string, err error)` — fetches the first 4 tracks' cover URLs, downloads / reads the local files, composes a 600×600 2×2 grid via `image/jpeg`, writes to `dataDir/playlist_covers/{playlistID}.jpg`, returns the relative path. The Service calls this synchronously after every mutating method that affects ordering or composition.
**Layout is always 2×2** regardless of track count. Each cell is 300×300, output is 600×600. Missing cells (when the playlist has < 4 tracks, or when a contributing track's `cover_url` is empty / unavailable) are filled with the FabledSword `album-fallback.svg` rasterized to 300×300. This means a 1-track playlist shows the track's cover top-left and the glyph in the other 3 cells; a 3-track playlist fills cells 1-3 with covers and cell 4 with the glyph. Consistent rendering, no special-casing per count.
### 3.3 API handlers (`internal/api/playlists.go`)
Routes registered inside `r.Route("/api", ...)``authed.Group(...)`:
- `GET /api/playlists`
- `POST /api/playlists`
- `GET /api/playlists/{id}`
- `PATCH /api/playlists/{id}`
- `DELETE /api/playlists/{id}`
- `POST /api/playlists/{id}/tracks` (append)
- `PUT /api/playlists/{id}/tracks` (reorder; full-list)
- `DELETE /api/playlists/{id}/tracks/{position}` (remove one)
- `GET /api/playlists/{id}/cover` (serve the cached collage, 404 → glyph fallback in UI)
Owner-only routes use `s.RequireOwner(playlist.user_id)` per-handler check (extension of the existing auth helpers). Read routes apply visibility gating in the service layer.
### 3.4 Frontend
**Files (create):**
- `web/src/lib/api/playlists.ts``listPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist`, `appendTracks`, `reorderPlaylist`, `removeTrack`. TanStack Query factories: `createPlaylistsQuery`, `createPlaylistQuery(id)`.
- `web/src/lib/api/playlists.test.ts`
- `web/src/lib/components/PlaylistCard.svelte` — collage + name + track_count + (creator name when not owned). Used in `/playlists` lists.
- `web/src/lib/components/PlaylistCard.test.ts`
- `web/src/lib/components/AddToPlaylistMenu.svelte` — submenu for `<TrackMenu>`'s "Add to playlist…" slot. Lists the operator's own playlists alphabetically. "+ New playlist…" at the bottom opens a small inline dialog (name field + Create button) that adds the track to the new playlist.
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
- `web/src/lib/components/PlaylistTrackRow.svelte` — variant of `<TrackRow>` for playlist detail. Adds drag handle (owner only), strike-through styling for `track_id=NULL` rows, per-row "Remove" via `<TrackMenu>` extension.
- `web/src/lib/components/PlaylistTrackRow.test.ts`
- `web/src/routes/playlists/+page.svelte` — full rewrite of the placeholder. List + create CTA.
- `web/src/routes/playlists/[id]/+page.svelte` — detail with header + drag-reorderable track list.
- `web/src/routes/playlists/playlists.test.ts`
- `web/src/routes/playlists/[id]/playlist.test.ts`
**Files (modify):**
- `web/src/lib/components/TrackMenu.svelte` — replace the disabled "Add to playlist…" placeholder with `<AddToPlaylistMenu>` mounted as a submenu. Drop the `disabled` flag and "Coming with playlists" tooltip.
- `web/src/lib/components/TrackMenu.test.ts` — update assertion: "Add to playlist…" is now enabled.
- `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`.
- `web/src/lib/components/Shell.svelte` — Playlists already in the nav since the M6a polish; verify the link points at `/playlists`.
### 3.5 Drag-to-reorder
- Use the browser's native HTML5 drag-and-drop (`draggable`, `ondragstart`, `ondragover`, `ondrop`). No third-party drag library.
- Visual: drag handle on the leftmost column (a Lucide `GripVertical` icon, owner only). Dragging shows a ghost row; drop fires the reorder.
- On drop, the client builds the new ordered position array and calls `PUT /api/playlists/{id}/tracks`. Optimistic local update with rollback on error (toast via `copyForCode`).
### 3.6 "Add to playlist" flow
From any `<TrackMenu>`:
1. Operator clicks the kebab on a track row.
2. Hovers/focuses "Add to playlist…" → submenu opens.
3. Submenu lists operator's playlists (`createPlaylistsQuery()` filtered to owned). Each row shows name + small collage.
4. Clicking a playlist appends the track via `appendTracks(playlistID, [trackID])`. Toast: "Added to {playlist name}".
5. Clicking "+ New playlist…" opens an inline dialog (name field, `Create` / `Cancel`). Submitting creates the playlist + appends the track in two API calls (or one if we add a `?with_track=...` query param — implementer choice; default is two calls for simplicity).
### 3.7 Permissions matrix
| Action | Owner | Other authenticated user |
|---------------------------------|:-----:|:------------------------:|
| `GET /api/playlists` (list) | ✓ | ✓ (sees own + others' public) |
| `GET /api/playlists/{id}` | ✓ | ✓ if `is_public`, else 403 |
| `POST /api/playlists` | ✓ (creates own) | ✓ (creates own) |
| `PATCH /api/playlists/{id}` | ✓ | 403 |
| `DELETE /api/playlists/{id}` | ✓ | 403 |
| `POST .../tracks` (append) | ✓ | 403 |
| `DELETE .../tracks/{position}` | ✓ | 403 |
| `PUT .../tracks` (reorder) | ✓ | 403 |
| `GET .../cover` | ✓ | ✓ if playlist visible to caller |
Wire codes: `not_found` (404), `not_authorized` (403, matches existing project convention from #372), `unauthenticated` (401), `bad_request` (400), `server_error` (500). Lidarr is not involved in any of these endpoints — no `lidarr_*` codes.
## 4. File map
### Backend — create
- `internal/db/migrations/0014_playlists.up.sql` + `0014_playlists.down.sql`
- `internal/db/queries/playlists.sql` — Create / Get / Update / Delete / List + tracks insert / delete / reorder
- `internal/playlists/service.go`
- `internal/playlists/service_test.go`
- `internal/playlists/collage.go`
- `internal/playlists/collage_test.go`
- `internal/api/playlists.go`
- `internal/api/playlists_test.go`
### Backend — modify
- `internal/db/dbq/*` — regenerated by `sqlc generate`
- `internal/api/api.go` — register the 9 new routes; add `*playlists.Service` to the `handlers` struct
- `internal/server/server.go` — construct the playlists service; pass to `api.Mount`
### Frontend — create
- `web/src/lib/api/playlists.ts`
- `web/src/lib/api/playlists.test.ts`
- `web/src/lib/components/PlaylistCard.svelte`
- `web/src/lib/components/PlaylistCard.test.ts`
- `web/src/lib/components/AddToPlaylistMenu.svelte`
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
- `web/src/lib/components/PlaylistTrackRow.svelte`
- `web/src/lib/components/PlaylistTrackRow.test.ts`
- `web/src/routes/playlists/+page.svelte` (rewrite of placeholder)
- `web/src/routes/playlists/[id]/+page.svelte`
- `web/src/routes/playlists/playlists.test.ts`
- `web/src/routes/playlists/[id]/playlist.test.ts`
### Frontend — modify
- `web/src/lib/components/TrackMenu.svelte` — wire `<AddToPlaylistMenu>` into the reserved slot; drop the disabled placeholder.
- `web/src/lib/components/TrackMenu.test.ts` — update the "Add to playlist… is disabled" assertion to "is enabled and opens submenu."
- `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`.
- `web/src/lib/api/types.ts` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack` types.
## 5. API contract
### `GET /api/playlists`
Response (200):
```json
{
"owned": [PlaylistRow, ...],
"public": [PlaylistRow, ...]
}
```
Where `PlaylistRow`:
```json
{
"id": "uuid",
"user_id": "uuid",
"owner_username": "alice",
"name": "Saturday morning",
"description": "Mellow start to the weekend",
"is_public": true,
"cover_url": "/api/playlists/{id}/cover",
"track_count": 42,
"duration_sec": 9180,
"created_at": "ts",
"updated_at": "ts"
}
```
`cover_url` is the canonical URL the client renders. Empty playlists → `cover_url = ""`; UI shows the FabledSword glyph fallback.
### `POST /api/playlists`
Body: `{name: string, description?: string, is_public?: boolean}`. Returns the new `PlaylistRow`.
### `GET /api/playlists/{id}`
Returns `PlaylistDetail`:
```json
{
...PlaylistRow,
"tracks": [PlaylistTrack, ...]
}
```
Where `PlaylistTrack`:
```json
{
"position": 0,
"track_id": "uuid", // NULL if track was removed from library
"title": "Roygbiv",
"artist_name": "Boards of Canada",
"album_title": "Music Has The Right To Children",
"album_id": "uuid", // NULL when track_id is NULL (no upstream album to link to)
"artist_id": "uuid", // NULL when track_id is NULL
"duration_sec": 137,
"stream_url": "/api/tracks/uuid/stream", // NULL when track_id is NULL
"added_at": "ts"
}
```
### `PATCH /api/playlists/{id}`
Owner only. Body: `{name?: string, description?: string, is_public?: boolean}`. Returns the updated `PlaylistRow`.
### `DELETE /api/playlists/{id}`
Owner only. 204 on success.
### `POST /api/playlists/{id}/tracks`
Owner only. Body: `{track_ids: [uuid, ...]}`. Appends to the end. Returns the updated `PlaylistDetail`.
### `DELETE /api/playlists/{id}/tracks/{position}`
Owner only. Removes the row at `position`; subsequent rows shift up. Returns the updated `PlaylistDetail`.
### `PUT /api/playlists/{id}/tracks`
Owner only. Body: `{ordered_positions: [int, ...]}` — must be a permutation of the existing positions (e.g., `[3, 0, 1, 2]` if the playlist has 4 entries). Server rewrites all positions. Returns the updated `PlaylistDetail`.
### `GET /api/playlists/{id}/cover`
Returns the cached collage JPEG. 404 when `cover_path` is NULL (UI renders glyph). 403 when caller can't see the playlist.
### Error envelope
All errors use the project's standard `{"error": "code"}` flat shape (the admin-endpoint convention). Codes: `not_found`, `not_authorized`, `unauthenticated`, `bad_request`, `server_error`.
## 6. Error handling (frontend)
- `not_found` (delete/get on missing) → toast "Playlist no longer exists" + invalidate `qk.playlists()`.
- `not_authorized` → toast "You can't edit this playlist." (Defensive — the UI hides edit affordances for non-owners.)
- `bad_request` (e.g., reorder with a non-permutation) → toast the error code's `copyForCode()` message. Reorder rolls back optimistically.
- Network / `connection_refused` → standard `<ConnectionErrorBanner>` from the existing pattern.
- Drag-reorder failures roll back the optimistic local update so the visible order matches the server.
## 7. Testing
### Backend
- `internal/playlists/service_test.go` — happy paths for each method, plus:
- Visibility: caller-not-owner + `is_public=false``ErrForbidden`.
- Visibility: caller-not-owner + `is_public=true` → reads succeed, mutations fail.
- Append populates denormalized snapshot fields correctly.
- Reorder validates permutation (rejects extra/missing/duplicate positions).
- Track delete (`ON DELETE SET NULL`) leaves playlist_tracks row intact with `track_id=NULL` and snapshot still present.
- Cascade: deleting a playlist removes `playlist_tracks` and the cached cover file.
- Rollups (`track_count`, `duration_sec`) update on append / remove / reorder.
- `internal/playlists/collage_test.go` — generates collages for 0 / 1 / 2 / 3 / 4-track playlists, asserts file existence and size; uses image/jpeg fixture decoder to verify dimensions.
- `internal/api/playlists_test.go` — HTTP-level tests for each route, all permissions matrix cases, response envelope shapes.
### Frontend
- `playlists.test.ts` (api helper) — verifies URL shapes, query param handling, error envelope handling.
- `PlaylistCard.test.ts` — renders collage / glyph fallback, name, track_count, owner-vs-other formatting.
- `AddToPlaylistMenu.test.ts` — renders user's playlists alphabetically, "+ New playlist…" creates + adds.
- `PlaylistTrackRow.test.ts` — renders track row, drag handle visible only for owner, greyed-out + strikethrough for `track_id=NULL`.
- `playlists.test.ts` (route) — list page renders both sections, create dialog flow.
- `playlist.test.ts` (route) — detail page renders track list, drag-reorder fires `PUT`, edit/delete buttons hidden for non-owner, public/private chip wired.
## 8. Distribution / migration notes
- **Migration `0014`** introduces two tables. Online migration is safe — no existing rows to backfill. The migration order is: `playlists` first (referenced by `playlist_tracks`'s FK), then `playlist_tracks`. The `playlists.cover_path` column starts NULL for any pre-existing rows (none exist yet), so no special handling.
- `data/playlist_covers/` directory is created by the service on first cover generation; no migration script required.
- `min_client_version` bump: not required. The endpoints are net-new; old web clients (pre-deploy) hide the playlists nav anyway since the placeholder route renders empty.
- No breaking changes elsewhere.
## 9. Origin
M7 #352. Brainstorm 2026-05-02/03 (parked across two sessions; see task-log id 44 on #352 for the mid-stream park-point).
Decisions locked, in order:
1. Slice 1 = CRUD foundation. Slices 2 (system-generated) and 3 (home-page row) are siblings, both in v1, both their own brainstorm/spec/plan cycles.
2. Multi-user model: private by default, owner publishes via `is_public`. Edit/delete/mutate owner-only. Read for owner OR public.
3. Manual playlists only in v1. User-defined smart-rule queries are post-v1. System-generated playlists from #352 slice 2 are still in scope but produce manual-shape `playlist_tracks` rows.
4. `/api/*` only. No Subsonic compat; aligns with `project_subsonic_legacy.md`.
5. Cover art: server-generated 2×2 collage from the first 4 tracks, regenerated synchronously inline on every track-list mutation, cached on disk. Glyph fallback for empty.
6. Track-removal cascade: soft mark with denormalized snapshot. `track_id` becomes NULL on upstream delete; row stays with title/artist/album/duration; UI shows greyed-out strikethrough.
7. Reorder API: full ordered-list rewrite (idempotent, simple).
8. Inline collage generation (synchronous) acceptable for v1; goroutine + disk-cache invalidation can come later if performance becomes an issue.
9. Composite PK on `(playlist_id, position)`; reorder rewrites the PK in-transaction. Acceptable for typical playlist sizes.
10. Denormalized rollups (`track_count`, `duration_sec`) on `playlists`, updated by service-layer on every mutation.
## 10. Related
- M7 #352 — this task (slice 1 of 3). Slices 2 and 3 brainstorm separately.
- M7 #372 (shipped, `1cf58b1`) — track-actions menu reserved the "Add to playlist…" slot for this slice.
- M7 #356 — Flutter mobile client. The playlists schema feeds Flutter slice 2+ when web parity is reached.
- `project_subsonic_legacy.md``/api/*`-only policy that scopes Subsonic out of this slice.
- `feedback_v1_is_full_product.md` — v1 = full product; the three #352 slices all land before tag.
@@ -1,250 +0,0 @@
# M7 — Track-level actions menu
> **Status:** Draft for review · 2026-05-03
>
> **Phase:** M7 task #372. Web-side polish slice. Replaces the single-entry `<TrackMenu>` (built as scaffolding for M5b's quarantine flag) with a real per-track action set. Lands before M7 #352 (Playlists CRUD) so the "Add to playlist…" entry has a menu to slot into. Mirrored on Flutter in a later slice once the web surface is settled.
## 1. Goal
Per-track kebab menu wherever `<TrackRow>` or the `<PlayerBar>` track display is used, exposing the full set of actions a user can take on a track right now: queue manipulation, library state changes (Like, Hide), navigation (Go to album/artist), Lidarr-aware track removal, and a placeholder slot for "Add to playlist…" that #352 fills.
## 2. Goals and non-goals
### Goals
- `<TrackMenu>` exposes nine entries, grouped four ways:
1. **Queue:** Play next · Add to queue
2. **Collection:** Like / Unlike · Add to playlist… (slot reserved; wired by #352)
3. **Navigation:** Go to album · Go to artist
4. **Lifecycle:** Flag this track… · Hide / Unhide · Remove from library *(admin-only)*
- "Like / Unlike" and "Hide / Unhide" entries duplicate functionality already on the row (heart and eye icon buttons). Reason: keyboard discoverability — once the kebab is focused, arrow-keys reach every action; mouse users still get one-tap heart/eye on the row.
- "Add to playlist…" reserves the menu slot but the submenu of playlists is wired in #352, not here. The slot renders as disabled with a tooltip "Coming with playlists" until #352 ships.
- "Go to artist" is single-target (`track.artistId`); compilation/feature artists aren't modeled in the schema today and that's a schema problem to solve before this entry needs a submenu.
- "Remove from library" is admin-only — gated client-side by `currentUser.is_admin`, server-side by `auth.RequireAdmin()` on the new `/api/admin/tracks/{id}` route. The endpoint always deletes the file from disk via `os.Remove` (not via Lidarr, which is album-granular and has no per-track delete) plus the DB row, then cascades album → artist tidy-up. For Lidarr-managed tracks the operator chooses whether Lidarr should look for a replacement (default — no extra action; Lidarr's monitoring re-imports on next scan) or the track should be unmonitored (server calls `Lidarr.UnmonitorTrack(mbid)` to flip `monitored: false`). The `?unmonitor=true` query param drives this — unset / `false` = replace, `true` = unmonitor.
- Keyboard accessibility: kebab is `<button aria-haspopup="menu" aria-expanded>`; opened menu has `role="menu"` with each entry `role="menuitem"`. Up/Down arrows cycle, Enter activates, Escape closes and returns focus to the kebab.
- Error envelope: existing `copyForCode()` helper resolves all surface errors (`lidarr_unreachable`, `lidarr_unauthorized`, `lidarr_server_error`, `not_found`, `unauthorized`, `connection_refused`).
### Non-goals (this slice)
- Per-track metadata editing (title / artist / track_number / genre / year / etc.) — filed as M7 #373 ("Track metadata editing"). Crosses into Lidarr's territory and the scanner's reconciliation logic; deserves its own spec.
- "Add to playlist…" submenu wiring — lands in #352 (Playlists CRUD slice 1).
- "Remove from library" exposed to non-admin users — admin-only matches the existing model (file management is an operator concern).
- Multi-artist disambiguation on "Go to artist" — schema doesn't model collaborator artists today; expanding the menu to a submenu earns its keep only after the schema does.
- Submenu UI infrastructure beyond the playlists hook — no other entries need submenus in this slice.
- Flutter mirror — separate task once web settles (#356 follow-up slice).
## 3. Architecture
### Backend
**One new admin endpoint:** `DELETE /api/admin/tracks/{id}?unmonitor=true|false`. Handler in `internal/api/admin_tracks.go` (new file). Calls a new `internal/tracks` package that owns the removal logic. Also adds one new method to `internal/lidarr/client.go`: `UnmonitorTrack(ctx, trackMbid)` — looks up the track in Lidarr by mbid via `LookupTrack`, then PUTs `/api/v1/track/monitor` with `{trackIds: [lidarrTrackID], monitored: false}`. (Requires a small `put` helper alongside the existing `get` / `post`.)
`internal/tracks/service.go` exposes `RemoveTrack(ctx, trackID, adminID, unmonitor bool)` returning `(deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err)`. Steps:
1. Look up the track. Need: `file_path`, `mbid`, `album_id`, `artist_id`. Returns `ErrNotFound` if missing.
2. **Always:** `os.Remove(file_path)`. Tolerate `os.ErrNotExist` (file already gone — proceed with DB cleanup; the goal is consistency). Other `os.Remove` errors log a warning and proceed; orphan files are recoverable, orphan DB rows are not.
3. Begin a single DB transaction:
- `DeleteTrack(id)` — returns `album_id`, `artist_id` from RETURNING. Existing FK CASCADE on `track_id` handles `play_events`, `general_likes_tracks`, `lidarr_quarantine`, `lidarr_quarantine_actions`. (Verified live — all four already cascade. Future `playlist_tracks` from #352 will also need `ON DELETE CASCADE`.)
- `DeleteAlbumIfEmpty(album_id)`. On `pgx.ErrNoRows` (album still has other tracks) skip. Otherwise set `deletedAlbumID`.
- If album was deleted: `DeleteArtistIfEmpty(artist_id)`. On `pgx.ErrNoRows` skip. Otherwise set `deletedArtistID`.
- Commit.
4. **If `unmonitor` AND track had a non-empty mbid (Lidarr-managed):** call `Lidarr.UnmonitorTrack(ctx, mbid)`. Lidarr errors here are **non-fatal** — log a warning and set a `lidarrUnmonitorFailed` flag, but don't fail the request. The file is already gone and the DB is consistent; failing now would leave the operator with no recovery path. The flag flows to the wire response so the UI can toast a follow-up message.
5. Return `(deletedAlbumID, deletedArtistID, lidarrUnmonitorFailed bool, nil)`.
**API handler** maps the typed errors:
- `ErrNotFound` → 404 `{"error": "not_found"}`
- success → 200 `{"deleted_track_id": "...", "deleted_album_id": "...?", "deleted_artist_id": "...?", "lidarr_unmonitor_failed": true|false}``lidarr_unmonitor_failed` is only set to `true` when the operator requested unmonitor AND the Lidarr call failed; omitted in all other cases.
### Frontend
`<TrackMenu>` is rewritten from "kebab → FlagPopover only" to "kebab → menu with grouped entries". The menu API stays compatible (same `track: TrackRef` and `direction: 'up'|'down'` props), so existing callers (`<TrackRow>`, `<PlayerBar>`) pick up the new entries with no code change.
Two new component primitives:
- `<TrackMenuItem>` — single row of the menu (`<button role="menuitem">` + Lucide icon + label + optional shortcut indicator).
- `<TrackMenuDivider>``<hr>` styled to FabledSword's pewter/slate.
`<TrackMenu>`'s state machine:
- `menuOpen: boolean` — set true on kebab click; toggles `aria-expanded`.
- `popoverOpen: boolean` — set true when "Flag this track…" fires; menuOpen flips to false.
- Outside-click handler closes both.
Audio queue mutations are pure client-state. The existing audio store gets two methods:
```ts
function playNext(track: TrackRef) {
// Splice immediately after the current index.
_queue.splice(_index + 1, 0, track);
}
function addToQueue(track: TrackRef) {
_queue.push(track);
}
```
Like / Unlike + Hide / Unhide reuse the existing `likeTrack` / `unlikeTrack` and `quarantineTrack` / `unquarantineTrack` API helpers + their TanStack Query invalidations.
"Remove from library" calls a new `web/src/lib/api/admin/tracks.ts::removeTrack(id)`. On success, invalidate `qk.albums(track.albumId)`, `qk.artists(track.artistId)`, `qk.home()`, plus any list-page query keys currently rendered. The response payload's optional `deleted_album_id` / `deleted_artist_id` lets the client also evict those entity caches.
Navigation entries call `goto('/albums/' + track.albumId)` and `goto('/artists/' + track.artistId)` directly — no async, no error path.
### Accessibility
- Kebab `<button>` carries `aria-label="Track actions"`, `aria-haspopup="menu"`, `aria-expanded={menuOpen}`.
- Open menu has `role="menu"`. Each entry: `<button role="menuitem">`. Disabled entries (admin-only for non-admin users; "Add to playlist…" pre-#352) have `aria-disabled="true"` rather than being absent — keeps the menu height stable so muscle memory works for both user types.
- Up/Down arrow keys cycle focus through visible enabled items. Home/End jump to first/last. Escape closes and returns focus to the kebab. Enter activates the focused item.
- The submenu plumbing #352 lands will use arrow-right to enter, arrow-left to exit, with focus following.
## 4. File map
### Backend — create
- `internal/tracks/service.go``RemoveTrack(ctx, id, adminID)` + typed errors.
- `internal/tracks/service_test.go` — unit tests covering the six service paths (Lidarr happy, non-Lidarr happy, file-already-gone, Lidarr error, album-empty cascade, artist-empty cascade).
- `internal/api/admin_tracks.go``handleRemoveTrack(w, r)`.
- `internal/api/admin_tracks_test.go` — HTTP tests (admin-only auth, 4 error codes, response envelope).
### Backend — modify
- `internal/api/api.go` — register `admin.Delete("/tracks/{id}", h.handleRemoveTrack)`.
- `internal/db/queries/tracks.sql` — append `DeleteTrack`, `DeleteAlbumIfEmpty`, `DeleteArtistIfEmpty`, `CountTracksByAlbum`, `CountAlbumsByArtist`.
- `internal/db/dbq/*` — regenerated by `sqlc generate`.
- `internal/db/migrations/0014_track_cascades.up.sql`**conditional**, only if any of `play_events`, `general_likes_tracks`, `lidarr_quarantine` lacks `ON DELETE CASCADE` on its `track_id` FK. Read existing migrations first; skip if all cascades are already correct.
### Frontend — create
- `web/src/lib/components/TrackMenuItem.svelte` — single-entry primitive.
- `web/src/lib/components/TrackMenuItem.test.ts`
- `web/src/lib/components/TrackMenuDivider.svelte` — visual separator.
- `web/src/lib/api/admin/tracks.ts``removeTrack(id)`.
- `web/src/lib/api/admin/tracks.test.ts`
### Frontend — modify
- `web/src/lib/components/TrackMenu.svelte` — full rewrite from FlagPopover-only to multi-entry menu.
- `web/src/lib/components/TrackMenu.test.ts` — full rewrite.
- `web/src/lib/components/TrackRow.svelte` — no code change required (uses `<TrackMenu>`'s same prop API).
- `web/src/lib/components/PlayerBar.svelte` — no code change required.
- `web/src/lib/components/TrackRow.test.ts` — adjust expectations (menu now has 9 entries instead of 1).
- `web/src/lib/components/PlayerBar.test.ts` — same.
- `web/src/lib/api/queries.ts` — no new query keys. The Remove flow invalidates existing `qk.albums(id)` / `qk.artists(id)` / `qk.home()` plus any list-page keys currently rendered. There is no admin-tracks list view in this slice.
- `web/src/lib/stores/audio.ts` — add `playNext(track)` and `addToQueue(track)` methods.
- `web/src/lib/stores/audio.test.ts` — extend to cover the new methods.
## 5. API contract
### `DELETE /api/admin/tracks/{id}?unmonitor=true|false`
**Auth:** admin session.
**Query params:**
- `unmonitor` (optional, default `false`) — when `true` AND the track is Lidarr-managed (has an mbid), the server calls `Lidarr.UnmonitorTrack` after the file/DB delete so Lidarr won't search for a replacement. When `false` or omitted, no Lidarr call — Lidarr's monitoring re-imports on next scan, which is the operator's "find a replacement" path.
**Request:** no body.
**Response (200 OK):**
```json
{
"deleted_track_id": "uuid",
"deleted_album_id": "uuid",
"deleted_artist_id": "uuid",
"lidarr_unmonitor_failed": true
}
```
- `deleted_album_id` and `deleted_artist_id` are optional. Present only when removing the track left the parent empty and the row was deleted too.
- `lidarr_unmonitor_failed` is only `true` when the operator requested `unmonitor=true` AND the Lidarr call failed (network / auth / 5xx). The file deletion + DB cleanup still succeeded — the field is informational so the UI can toast "File removed, but Lidarr couldn't be reached to stop the replacement search." Omitted in all other cases.
**Errors:**
| Status | Code | Trigger |
|---|---|---|
| 401 | `unauthenticated` | no session |
| 403 | `unauthorized` | non-admin session |
| 404 | `not_found` | track id doesn't exist |
| 500 | `server_error` | unexpected error (DB, filesystem) before delete completed |
Note: Lidarr errors during the unmonitor step are NOT mapped to wire errors — the response is 200 with `lidarr_unmonitor_failed: true`, since the destructive part (file + DB) already succeeded and the operator needs to know to retry the unmonitor manually.
## 6. Error handling (frontend)
`removeTrack(id)` reuses `apiFetch` with the existing dual-envelope handling. Errors surface as a toast via `copyForCode(code)`:
- `not_found` → "Track no longer exists." Trigger refetch of parent list query.
- `unauthorized` → "Admins only." (Defensive — UI should hide the entry for non-admins.)
- `connection_refused` → existing copy.
- `server_error` → existing copy.
Lidarr-related error codes do NOT come back from this endpoint anymore (they're handled inline as a non-fatal `lidarr_unmonitor_failed: true` flag in the success envelope). When the response sets that flag, surface a follow-up toast: "File removed, but Lidarr couldn't be reached to stop the replacement search. Unmonitor manually in Admin → Integrations if you don't want it to come back."
Audio queue mutations have no error path — they are local state writes. Defensive guard: if `_queue` is `null` (audio store uninitialized), the action is a no-op.
## 7. Testing
### Backend
- `internal/tracks/service_test.go` — unit tests, table-driven where it helps:
- Local-file happy path. `unmonitor=false`. File present, deletes cleanly. DB row gone. No Lidarr call.
- File-already-missing path. `os.Remove` returns `ErrNotExist`. Service logs warning, proceeds. DB row gone.
- Album-empty cascade. Track was the album's only track. Album deleted; artist persists if it has other albums. `deletedAlbumID` returned, `deletedArtistID` nil.
- Artist-empty cascade. Track was the artist's only track. Album AND artist deleted; both IDs returned.
- `unmonitor=true` AND track has mbid → `Lidarr.UnmonitorTrack` is called once with the mbid. `lidarrUnmonitorFailed=false` returned.
- `unmonitor=true` AND Lidarr fails (unreachable / 5xx / auth) → file + DB still deleted, `lidarrUnmonitorFailed=true` returned, no error from RemoveTrack.
- `unmonitor=true` AND track has empty mbid (non-Lidarr) → no Lidarr call, `lidarrUnmonitorFailed=false`.
- `unmonitor=false` AND track has mbid → no Lidarr call.
- `internal/lidarr/client_test.go` — extend with `UnmonitorTrack` tests:
- Track found in lookup → PUT to `/api/v1/track/monitor` with `{trackIds: [...], monitored: false}`.
- Track not found in lookup → typed error.
- Lidarr 5xx → `ErrServerError`.
- Lidarr unreachable → `ErrUnreachable`.
- `internal/api/admin_tracks_test.go` — HTTP tests:
- Non-admin → 403 `unauthorized`.
- No session → 401 `unauthenticated`.
- Unknown id → 404 `not_found`.
- Happy path no unmonitor → 200 with `deleted_track_id` (and optionally `deleted_album_id` / `deleted_artist_id`); no `lidarr_unmonitor_failed` in response.
- Happy path with `?unmonitor=true` and Lidarr failure → 200 with `lidarr_unmonitor_failed: true`.
### Frontend
- `TrackMenu.test.ts` (rewrite) covers:
- Each of the 9 entries renders for an admin user.
- "Remove from library" is hidden for non-admin (or renders disabled with `aria-disabled="true"`).
- "Add to playlist…" renders disabled with the "Coming with playlists" tooltip.
- Kebab `aria-haspopup="menu"`, `aria-expanded` cycles on open/close.
- Up/Down arrow keys cycle focus; Escape closes; Enter activates.
- Outside click closes.
- Submenu plumbing (`role="menu"` on the playlist hook) renders even though disabled.
- `TrackMenuItem.test.ts` — primitive renders icon, label, click handler fires, `aria-disabled` honored.
- `tracks.test.ts` (new admin API helper) — `removeTrack(id, {unmonitor})` sends `?unmonitor=true|false`, parses success envelope (incl. optional `lidarr_unmonitor_failed`), surfaces typed errors via `apiFetch`.
- `audio.test.ts` (extend) — `playNext(track)` splices at `_index+1`; `addToQueue(track)` appends; both no-op on empty queue.
- `TrackRow.test.ts` and `PlayerBar.test.ts` (adjust) — existing assertions about the menu's sole "Flag" entry need updating to match the new 9-entry structure. The compatible prop API means no other test changes needed.
## 8. Distribution / migration notes
- **No migration needed.** All four `track_id` FKs (`play_events`, `general_likes_tracks`, `lidarr_quarantine`, `lidarr_quarantine_actions`) already use `ON DELETE CASCADE`; verified live before plan-write.
- No client breaking changes. The `<TrackMenu>` prop API is unchanged.
- `min_client_version` bump in `internal/server/version.go`: not required — the new endpoint only matters for admins, and the menu degrades gracefully on old clients (they don't show entries they don't know about).
## 9. Origin
M7 #372. Brainstorm 2026-05-03. Decisions locked:
1. Per-track metadata editing is out of scope — filed as M7 #373.
2. Menu structure: 9 entries in 4 groups. Like/Hide duplicated in menu for keyboard discoverability.
3. `Go to artist` single-target — multi-artist data model isn't there yet.
4. `Remove from library` admin-only, with cascade album → artist tidy-up.
5. Submenu plumbing only matters for #352's `Add to playlist…` and is reserved as a slot here.
**Revision 2026-05-03 (during Task 2 implementation):** original spec routed Lidarr-managed track removal through `lidarrquarantine.DeleteViaLidarr`. Implementer surfaced that primitive deletes the entire **Lidarr album** (Lidarr is album-granular), not the single track — the spec's behavior would silently delete sibling tracks. Operator decision: drop the routing-via-Lidarr approach entirely. Always delete file + DB directly through Minstrel; ask the operator at confirm time whether Lidarr should look for a replacement (default — no extra action) or be told to unmonitor (new `Lidarr.UnmonitorTrack(mbid)` primitive). Lidarr unmonitor failure is non-fatal — the destructive part already succeeded; surface a follow-up toast so the operator can manually unmonitor.
## 10. Related
- M7 #352 — Playlists CRUD. Wires `Add to playlist…` submenu into the menu slot reserved here. Brainstorm parked at task-log id 44.
- M7 #373 — Track metadata editing. Split out from #372 in this brainstorm.
- M5b quarantine — `<FlagPopover>` and the `quarantineTrack` / `unquarantineTrack` helpers reused by the Hide/Unhide entries.
- M2 likes — `likeTrack` / `unlikeTrack` and the existing heart icon, now also surfaced as a menu entry.
- M5a Lidarr — Lidarr-aware delete reuses the same primitive `lidarrquarantine.DeleteViaLidarr` uses today.
- M7 #356 — Flutter mirror in a later slice once the web menu settles.