diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2653c48b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Build artifacts and node_modules — never useful inside the Docker context. +**/.git +**/.gitignore +**/node_modules +**/.svelte-kit +**/build +web/build + +# Flutter mobile client — built separately on developer machines / Flutter CI. +# Including it in the Go build context wastes ~70 files and invalidates the +# `COPY . .` layer cache on every Flutter-only change. +flutter_client/ + +# Docs and IDE noise +docs/ +**/.idea +**/.vscode +**/.DS_Store + +# Test outputs +**/coverage +**/.nyc_output + +# Local env / secrets — fail closed. +.env +.env.* +!.env.example + +# CI workflow files don't need to ship in the image. +.forgejo/ +.github/ diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml new file mode 100644 index 00000000..8a6a9b1e --- /dev/null +++ b/.forgejo/workflows/flutter.yml @@ -0,0 +1,101 @@ +name: flutter + +# Analyze + test + build the Flutter mobile client. Only runs when the +# diff touches flutter_client/ or one of the shared web inputs the +# sync_shared.sh script copies. Other pushes skip — this workflow is +# independent from the Go/web test.yml and release.yml. + +on: + push: + branches: [dev, main] + tags: ['v*'] + paths: + - 'flutter_client/**' + - 'web/src/lib/styles/tokens.json' + - 'web/src/lib/styles/error-copy.json' + - 'web/static/placeholders/album-fallback.svg' + - '.forgejo/workflows/flutter.yml' + pull_request: + branches: [main] + paths: + - 'flutter_client/**' + - 'web/src/lib/styles/tokens.json' + - 'web/src/lib/styles/error-copy.json' + - 'web/static/placeholders/album-fallback.svg' + - '.forgejo/workflows/flutter.yml' + workflow_dispatch: + +jobs: + analyze-test-build: + # flutter-ci runner image (CI-Runner/CI-flutter/Dockerfile) bakes in + # Flutter SDK + Android cmdline-tools + platform-34 + build-tools 34.0.0 + # + JDK 17 + a non-root `runner` user. No setup-action ceremony needed. + runs-on: flutter-ci + + defaults: + run: + working-directory: flutter_client + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync shared assets from web/ + run: ./tool/sync_shared.sh + + - name: Verify generated tokens.dart matches JSON + shell: bash + run: | + dart run tool/gen_tokens.dart + if ! git diff --exit-code lib/theme/tokens.dart; then + echo "::error::lib/theme/tokens.dart is out of sync with shared/fabledsword.tokens.json" + echo "Run: cd flutter_client && dart run tool/gen_tokens.dart" + exit 1 + fi + + - name: Pub get + run: flutter pub get + + - name: Analyze + run: flutter analyze --fatal-infos + + - name: Test + run: flutter test --reporter compact + + - name: Build debug APK + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + run: flutter build apk --debug + + - name: Build release APK + if: startsWith(github.ref, 'refs/tags/v') + run: flutter build apk --release + + - name: Upload debug APK artifact + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + uses: forgejo/upload-artifact@v3 + with: + name: minstrel-debug-${{ github.sha }} + path: flutter_client/build/app/outputs/flutter-apk/app-debug.apk + + - name: Attach release APK to release + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + REPO="${GITHUB_REPOSITORY}" + # Look up the release ID for this tag (release must exist already; + # release.yml creates it as part of the server-side release flow). + RELEASE_ID=$(curl -fsSL \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \ + | grep -oP '"id":\s*\K[0-9]+' | head -1) + if [ -z "${RELEASE_ID}" ]; then + echo "::error::release for tag ${TAG} not found" + exit 1 + fi + curl -fsSL \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \ + "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index abf2c715..13ebe16d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -10,6 +10,14 @@ on: push: branches: [main] tags: ['v*'] + # Tag pushes still build (tag releases are intentional, server + Flutter + # share the version). Branch pushes skip when the diff is Flutter-only — + # rebuilding the Go image for a Flutter-only merge wastes CI and churns + # the registry. + paths-ignore: + - 'flutter_client/**' + - 'docs/**' + - '**/*.md' workflow_dispatch: jobs: diff --git a/.forgejo/workflows/test-go.yml b/.forgejo/workflows/test-go.yml new file mode 100644 index 00000000..cb4a405e --- /dev/null +++ b/.forgejo/workflows/test-go.yml @@ -0,0 +1,56 @@ +name: test-go + +# Go server: vet + golangci-lint + short race tests. Runs on push to +# dev/main and PRs to main, scoped to Go-side files only — web-only or +# Flutter-only diffs don't trigger this workflow. +# +# Integration tests needing Postgres/ffmpeg run locally via docker-compose; +# they should guard with testing.Short() so this short-mode run skips them. +# +# `web/build/` has a committed placeholder index.html so go:embed succeeds +# without needing the SPA to be freshly built. Real builds happen in +# release.yml (container) and locally during dev. + +on: + push: + branches: [dev, main] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'sqlc.yaml' + - 'internal/**' + - 'cmd/**' + - '.forgejo/workflows/test-go.yml' + pull_request: + branches: [main] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'sqlc.yaml' + - 'internal/**' + - 'cmd/**' + - '.forgejo/workflows/test-go.yml' + +jobs: + test: + runs-on: go-ci + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Toolchain versions + run: | + go version + golangci-lint --version + + - name: go vet + run: go vet ./... + + - name: golangci-lint + run: golangci-lint run ./... + + - name: go test (short, race) + run: go test -short -race ./... diff --git a/.forgejo/workflows/test-web.yml b/.forgejo/workflows/test-web.yml new file mode 100644 index 00000000..5bcef968 --- /dev/null +++ b/.forgejo/workflows/test-web.yml @@ -0,0 +1,50 @@ +name: test-web + +# Web SPA: vitest + svelte-check. Runs on push to dev/main and PRs to +# main, scoped to web/** changes only — Go-only or Flutter-only diffs +# don't trigger this workflow. + +on: + push: + branches: [dev, main] + paths: + - 'web/**' + - '.forgejo/workflows/test-web.yml' + pull_request: + branches: [main] + paths: + - 'web/**' + - '.forgejo/workflows/test-web.yml' + +jobs: + test: + runs-on: go-ci + + defaults: + run: + working-directory: web + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + # `cache: 'npm'` removed — the Forgejo Actions cache server at + # 172.18.0.27:41161 isn't reachable from this runner's container, + # so setup-node spent 4m41s burning the npm cache restore on + # ETIMEDOUT before failing open. npm ci still works deterministically + # from package-lock.json; just re-fetches from the registry on each + # run. Restore the cache option once the runner-host network reaches + # the cache server. + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install deps + run: npm ci + + - name: Type-check + svelte-check + run: npm run check + + - name: Vitest + run: npm test diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index 3b03fd54..00000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: test - -# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run -# locally via docker-compose; they should guard with testing.Short(). - -on: - # PRs against main are the gate. Direct pushes to dev no longer trigger - # CI on their own — opening a PR fires the pull_request event and gates - # the merge. Avoids the duplicate push+pull_request runs we were - # accumulating on every PR commit. - 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 ./... diff --git a/.gitignore b/.gitignore index 8c2acc30..6cf722a0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,27 @@ 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/ + +# Per-machine Claude Code settings + remember-skill memory artifacts. +# Both are operator-scoped; the canonical project memory lives under +# ~/.claude/projects/ outside the repo. +.claude/ +.remember/ + +# Flutter +flutter_client/.dart_tool/ +flutter_client/.flutter-plugins +flutter_client/.flutter-plugins-dependencies +flutter_client/build/ +flutter_client/.idea/ +flutter_client/ios/Podfile.lock +flutter_client/ios/Pods/ +flutter_client/android/.gradle/ +flutter_client/android/app/build/ +flutter_client/android/local.properties +flutter_client/android/key.properties +flutter_client/*.iml diff --git a/Dockerfile b/Dockerfile index 40b51f31..981b97b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,16 @@ RUN groupadd --system --gid 1000 minstrel \ COPY --from=builder /out/minstrel /usr/local/bin/minstrel COPY config.example.yaml /etc/smartmusic/config.yaml +# Pre-create the data directory owned by the runtime user. Cached artifacts +# (playlist cover collages, artist art, album-cover fallbacks) all land here. +# A non-writable path at this location silently breaks every downstream +# cache, so we create + chown it once at image build. Operators mount a +# named volume on top to persist across container recreates. +RUN mkdir -p /app/data && chown -R minstrel:minstrel /app +WORKDIR /app + USER minstrel EXPOSE 4533 +ENV MINSTREL_STORAGE_DATA_DIR=/app/data ENTRYPOINT ["/usr/local/bin/minstrel"] CMD ["--config", "/etc/smartmusic/config.yaml"] diff --git a/Makefile b/Makefile index 42b1ad57..ac425514 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: generate test test-short lint build -SQLC_VERSION := 1.27.0 +SQLC_VERSION := 1.31.1 generate: docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate diff --git a/README.md b/README.md index 9fb46ebe..a798b81b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,101 @@ # Minstrel -Self-hosted music server with OpenSubsonic compatibility plus an extended API for server-side smart shuffle, a dual-like model (general + contextual), session-aware radio, ListenBrainz scrobble/similarity, and Lidarr integration. Go + Postgres, packaged as a Docker container. +A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence. > State and intelligence belong on the server, not the client. + + +## Highlights + +- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration. +- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices. +- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. +- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit. +- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. +- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356). + +## Quickstart + +```yaml +# compose.yaml +services: + minstrel: + image: git.fabledsword.com/bvandeusen/minstrel:v1.0.0 + ports: ['4533:4533'] + volumes: + - ./music:/music:ro + - minstrel-data:/data + environment: + MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable + MINSTREL_LIBRARY_SCAN_PATHS: /music + depends_on: [db] + + db: + image: postgres:17 + environment: + POSTGRES_USER: minstrel + POSTGRES_PASSWORD: minstrel + POSTGRES_DB: minstrel + volumes: [pgdata:/var/lib/postgresql/data] + +volumes: + minstrel-data: + pgdata: +``` + +```bash +docker compose up -d +``` + +Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings. + +For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). + +## Configuration + +Most operators only need the env vars in the quickstart above. A few extras worth knowing: + +- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews. +- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts. +- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`). +- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start. + +ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. + +Most operational keys have a `MINSTREL_
_` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface. + +## Updating + +- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment. +- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump. + ## Specs -Authoritative v1 scope lives under [`docs/`](./docs): +Authoritative scope lives under [`docs/`](./docs): - [Server spec](./docs/smart-music-server-spec.md) — current implementation focus. -- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app; work starts once the server reaches its Subsonic-compatible baseline. - -## Development workflow - -- Day-to-day work happens on the `dev` branch (or feature branches merged into `dev`). -- `main` is **protected** — changes land only via pull request from `dev`. -- Production builds are cut by tagging a release (`v*`) off `main`. - -## CI / container image - -Forgejo Actions handles: - -- Tests on push to `dev` and on PRs into `main`. -- Container image build + push to the Forgejo registry on merge to `main` (`:main`) and on `v*` tags (`:`). - -Task and milestone tracking: Fable (`Minstrel` project, id 12). +- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app. ## Development -Minstrel has two concurrent dev processes: +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. +1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`. +2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work. ### 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. +`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 container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required. + +### Branches + +- Day-to-day work happens on `dev` (or feature branches merged into `dev`). +- `main` is **protected** — changes land via PR from `dev`. +- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Forgejo registry. + +Task and milestone tracking: Fable (`Minstrel` project, id 12). + +## License + +See [LICENSE](./LICENSE). diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index e35670e8..9bf00598 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -13,11 +13,13 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/server" @@ -33,7 +35,7 @@ func main() { } func run() error { - configPath := flag.String("config", os.Getenv("SMARTMUSIC_CONFIG"), "path to YAML config file") + configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") flag.Parse() cfg, err := config.Load(*configPath) @@ -70,10 +72,65 @@ func run() error { } scanner := library.New(pool, logger, cfg.Library.ScanPaths) + + contact := cfg.Library.ContactEmail + if contact == "" { + contact = "https://git.fabledsword.com/bvandeusen/minstrel" + } + // Swap in the production User-Agent + MinPeriod on the registered + // MBCAA provider. init() registered it with default config; this + // updates the running instance. + coverart.NewMBCAAProviderFromConfig(coverart.FetcherConfig{ + UserAgent: fmt.Sprintf("Minstrel/dev (%s)", contact), + MinPeriod: time.Second, + }) + + coverSettings, err := coverart.NewSettingsService(ctx, pool, logger.With("component", "coverart")) + if err != nil { + logger.Error("coverart settings service init failed", "err", err) + os.Exit(1) + } + if newVer, bumped, berr := coverSettings.BumpVersionIfProvidersChanged(ctx); berr != nil { + logger.Warn("coverart: provider-hash boot check failed", "err", berr) + } else if bumped { + logger.Info("coverart: registered provider set changed; version bumped", + "new_version", newVer) + } + coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) + coverEnricher.DataDir = cfg.Storage.DataDir + + // One unified scan chain: library walk → MBID backfill → cover enrich. + // Boot-time scan and manual-trigger scans share this path; results land + // in scan_runs for the admin overview. if cfg.Library.ScanOnStartup && len(cfg.Library.ScanPaths) > 0 { go func() { - if _, err := scanner.Scan(ctx); err != nil { - logger.Error("startup scan failed", "err", err) + if _, err := library.RunScan(ctx, pool, scanner, coverEnricher, + logger.With("component", "scan_run"), + library.RunScanConfig{ + BackfillCap: 5000, + EnrichCap: cfg.Library.CoverArtBackfillCap, + ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + DataDir: cfg.Storage.DataDir, + }, + ); err != nil { + logger.Warn("startup scan run failed", "err", err) + } + }() + } else { + // No startup file walk, but still run backfill + enrich so cover + // progress doesn't stall just because the operator disabled + // scan-on-startup. + go func() { + if _, err := library.RunScan(ctx, pool, nil, coverEnricher, + logger.With("component", "scan_run"), + library.RunScanConfig{ + BackfillCap: 5000, + EnrichCap: cfg.Library.CoverArtBackfillCap, + ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + DataDir: cfg.Storage.DataDir, + }, + ); err != nil { + logger.Warn("boot-only scan run failed", "err", err) } }() } @@ -97,9 +154,31 @@ func run() error { lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr")) go lidarrReconciler.Run(ctx) + // Ensure DataDir exists before any service tries to write into it. + // Fatal on failure: a non-writable data_dir silently breaks every + // downstream cache (playlist covers, artist art, album-cover fallback) + // and produces "0 successes" symptoms that are hard to diagnose. + if cfg.Storage.DataDir != "" { + if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil { + logger.Error("data_dir create failed; refusing to start", + "path", cfg.Storage.DataDir, "err", err) + os.Exit(1) + } + } + + scanCfg := library.RunScanConfig{ + BackfillCap: 5000, + EnrichCap: cfg.Library.CoverArtBackfillCap, + ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + DataDir: cfg.Storage.DataDir, + } + scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"), + scanner, coverEnricher, scanCfg) + scheduler.Start(ctx) srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, - }, cfg.Events, cfg.Recommendation) + }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler) + playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/config.example.yaml b/config.example.yaml index 6cc062d9..42568fae 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,11 +1,19 @@ # Minstrel example configuration. # Every field is optional; defaults shown below match Default() in internal/config. -# Each setting can be overridden at runtime via SMARTMUSIC_* environment variables +# Each setting can be overridden at runtime via MINSTREL_* environment variables # (see internal/config/config.go for the full list). server: address: ":4533" +storage: + # On-disk root for cached runtime artifacts (currently playlist cover + # collages under /playlist_covers/). Default: "./data" relative + # to the working directory. Production deployments should set an absolute + # path on persistent storage. + # Env: MINSTREL_STORAGE_DATA_DIR + data_dir: "./data" + database: # Postgres connection string, e.g. postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable url: "" @@ -16,25 +24,35 @@ log: auth: # One-shot admin bootstrap. Applied only when the users table is empty. - # Set username to enable; leave password blank to have the server generate - # one and log it to stderr on first start. - # Env: SMARTMUSIC_AUTH_ADMIN_USERNAME, SMARTMUSIC_AUTH_ADMIN_PASSWORD + # Username defaults to "admin" when unset — you can omit this section + # entirely for a working bootstrap. Leave password blank to have the server + # generate one and log it to stderr on first start. + # Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD admin_bootstrap: - username: "" + username: "admin" password: "" library: # Filesystem roots to scan for music. Each path is walked recursively. - # Env: SMARTMUSIC_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) + # Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) scan_paths: [] - # Kick off a scan on server startup. Env: SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP + # Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP scan_on_startup: false +# Per-instance branding. Operators can rename their instance ("Family +# Jukebox", "Office Music", etc.) and override the OG share-preview +# description. Both fields default to sensible Minstrel values when +# unset; you only need this section to override. +# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION +branding: + app_name: "Minstrel" + description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration." + subsonic: # Gate for the legacy `p=` plaintext password parameter on /rest/*. # Leave disabled unless a specific client requires it — Minstrel prefers # apiKey (OpenSubsonic) and falls back to u+t+s token auth. - # Env: SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD + # Env: MINSTREL_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD allow_plaintext_password: false events: diff --git a/docker-compose.yml b/docker-compose.yml index 997de037..469ae663 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,12 +34,12 @@ services: postgres: condition: service_healthy environment: - SMARTMUSIC_DATABASE_URL: postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable - SMARTMUSIC_LOG_FORMAT: text - SMARTMUSIC_LIBRARY_SCAN_PATHS: /music - SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP: "true" - SMARTMUSIC_AUTH_ADMIN_USERNAME: admin - # SMARTMUSIC_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap + MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable + MINSTREL_LOG_FORMAT: text + MINSTREL_LIBRARY_SCAN_PATHS: /music + MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true" + MINSTREL_STORAGE_DATA_DIR: /app/data + # MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap # generates one and prints it to stderr on first boot. Watch the logs # of the minstrel service the first time you bring the stack up. networks: @@ -50,9 +50,14 @@ services: # Point ./music at your test library (symlink, bind-mount, whatever). # Read-only so a buggy scanner can't rewrite your files. - /mnt/Media/Music:/music:ro + # Cached artifacts: playlist cover collages, artist art, album-cover + # fallbacks (when the music dir is RO). Persists across container + # recreates so the operator doesn't redownload art on every up/down. + - minstrel-data:/app/data volumes: minstrel-pgdata: + minstrel-data: networks: minstrel: \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md b/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md deleted file mode 100644 index 2cee0f8d..00000000 --- a/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md +++ /dev/null @@ -1,2124 +0,0 @@ -# Web UI Library Reads 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:** Add five read-only JSON endpoints to `/api/*` — paged artist list (alpha + newest), artist/album/track detail, and unified search — so the SvelteKit SPA can render browse and search views. - -**Architecture:** Handlers live in `internal/api` alongside existing auth code, gated by `RequireUser`. New SQL queries added to `internal/db/queries/` and regenerated via sqlc. Response DTOs follow a Ref/Detail split; lists use a generic `Page[T]` envelope; forward-reference stream/cover URLs are embedded now so Plan 3 can land transparently. Tiny conversion helpers are duplicated inline rather than shared with `internal/subsonic` — the two packages shape responses differently and the helpers are ~5 lines each. - -**Tech Stack:** Go 1.23, chi/v5, pgx/v5, sqlc 1.27, pgxpool, pgtype. - -**Spec:** `docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md` - ---- - -## File Structure - -New files: - -| File | Responsibility | -|---|---| -| `internal/api/library.go` | Artist/album/track list + detail handlers | -| `internal/api/search.go` | Unified search handler | -| `internal/api/convert.go` | UUID↔string, year-from-pgdate, URL builders, dbq→ref projections | -| `internal/api/library_fixtures_test.go` | Shared seed helpers for integration tests | -| `internal/api/library_test.go` | Integration tests for library endpoints | -| `internal/api/search_test.go` | Integration tests for search | - -Modified files: - -| File | Change | -|---|---| -| `internal/api/api.go` | Register new routes inside the `authed` group | -| `internal/api/types.go` | Add `Page[T]`, `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `SearchResponse` | -| `internal/db/queries/artists.sql` | Add `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching` | -| `internal/db/queries/albums.sql` | Add `CountAlbumsMatching` | -| `internal/db/queries/tracks.sql` | Add `CountTracksMatching` | -| `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` | sqlc-regenerated; do not hand-edit | - ---- - -## Working Conventions - -- **Commit after each green test step.** One task = one commit minimum; split further if a task produces multiple logical units. -- **Integration tests require a live Postgres** at `MINSTREL_TEST_DATABASE_URL`. The existing `docker-compose.yml` spins one up; tests skip cleanly when the env var is unset. -- **sqlc regeneration:** run `make generate` after changing any `*.sql` file. Never hand-edit `internal/db/dbq/*.go`. -- **Run the full test suite before committing any task:** `MINSTREL_TEST_DATABASE_URL=postgres://... go test ./...`. Use `-short` only when explicitly noted. -- **`pgtype` imports:** add `"github.com/jackc/pgx/v5/pgtype"` only where needed; Go won't compile with unused imports. - ---- - -## Task 1: Add SQL queries - -**Files:** -- Modify: `internal/db/queries/artists.sql` -- Modify: `internal/db/queries/albums.sql` -- Modify: `internal/db/queries/tracks.sql` -- Regenerate: `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` - -- [ ] **Step 1: Append to `internal/db/queries/artists.sql`** - -Append these four queries (keep existing `ListArtists` untouched — subsonic's `buildArtistIndexes` still uses it): - -```sql - --- name: ListArtistsAlpha :many -SELECT * FROM artists ORDER BY sort_name, name, id LIMIT $1 OFFSET $2; - --- name: ListArtistsNewest :many --- Secondary id tiebreaker keeps pagination stable when created_at ties. -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 || '%'; -``` - -- [ ] **Step 2: Append to `internal/db/queries/albums.sql`** - -```sql - --- name: CountAlbumsMatching :one -SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%'; -``` - -- [ ] **Step 3: Append to `internal/db/queries/tracks.sql`** - -```sql - --- name: CountTracksMatching :one -SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%'; -``` - -- [ ] **Step 4: Regenerate sqlc code** - -Run: `make generate` -Expected: no errors; `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` are updated with the new functions `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching`, `CountAlbumsMatching`, `CountTracksMatching`. - -- [ ] **Step 5: Verify the package builds** - -Run: `go build ./internal/db/...` -Expected: exit 0, no output. - -- [ ] **Step 6: Commit** - -```bash -git add internal/db/queries/*.sql internal/db/dbq/*.sql.go -git commit -m "feat(db): add paged + count queries for library reads" -``` - ---- - -## Task 2: Add DTO types - -**Files:** -- Modify: `internal/api/types.go` - -- [ ] **Step 1: Extend `internal/api/types.go`** - -Append to the existing `internal/api/types.go` (keep `LoginRequest`, `LoginResponse`, `UserView`): - -```go - -// Page is the generic pagination envelope used by list and search endpoints. -// Items is always non-nil at JSON encode time — handlers must initialize -// empty slices explicitly so absent results render as [] rather than null. -type Page[T any] struct { - Items []T `json:"items"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` -} - -// ArtistRef is the lightweight artist shape used in lists and search results. -type ArtistRef struct { - ID string `json:"id"` - Name string `json:"name"` - AlbumCount int `json:"album_count"` -} - -// AlbumRef is the lightweight album shape used in lists, artist details, -// and search results. CoverURL points to /api/albums/{id}/cover which -// Plan 3 implements. -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"` -} - -// TrackRef is the lightweight track shape used in album details and search. -// StreamURL points to /api/tracks/{id}/stream which Plan 3 implements. -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"` -} - -// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref -// so callers read id/name/album_count from the same path as list entries. -type ArtistDetail struct { - ArtistRef - Albums []AlbumRef `json:"albums"` -} - -// AlbumDetail is the response body of GET /api/albums/{id}. -type AlbumDetail struct { - AlbumRef - Tracks []TrackRef `json:"tracks"` -} - -// SearchResponse is the body of GET /api/search. Each facet carries its own -// total + limit + offset; the client sends one shared limit/offset and the -// server applies it uniformly to each facet's LIMIT/OFFSET query. -type SearchResponse struct { - Artists Page[ArtistRef] `json:"artists"` - Albums Page[AlbumRef] `json:"albums"` - Tracks Page[TrackRef] `json:"tracks"` -} -``` - -- [ ] **Step 2: Verify build** - -Run: `go build ./internal/api/...` -Expected: exit 0. - -- [ ] **Step 3: Commit** - -```bash -git add internal/api/types.go -git commit -m "feat(api): add DTOs for library reads and search" -``` - ---- - -## Task 3: Add conversion helpers - -**Files:** -- Create: `internal/api/convert.go` -- Create: `internal/api/convert_test.go` - -- [ ] **Step 1: Write failing test** - -Create `internal/api/convert_test.go`: - -```go -package api - -import ( - "testing" - - "github.com/jackc/pgx/v5/pgtype" -) - -func TestUUIDToString(t *testing.T) { - var u pgtype.UUID - if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil { - t.Fatalf("scan: %v", err) - } - got := uuidToString(u) - want := "00112233-4455-6677-8899-aabbccddeeff" - if got != want { - t.Errorf("uuidToString = %q, want %q", got, want) - } - - var zero pgtype.UUID - if s := uuidToString(zero); s != "" { - t.Errorf("uuidToString(invalid) = %q, want empty", s) - } -} - -func TestParseUUID(t *testing.T) { - u, ok := parseUUID("00112233-4455-6677-8899-aabbccddeeff") - if !ok || !u.Valid { - t.Fatalf("parseUUID valid: ok=%v valid=%v", ok, u.Valid) - } - if _, ok := parseUUID("not-a-uuid"); ok { - t.Error("parseUUID accepted garbage") - } - if _, ok := parseUUID(""); ok { - t.Error("parseUUID accepted empty string") - } -} - -func TestYearFromDate(t *testing.T) { - var d pgtype.Date - if y := yearFromDate(d); y != 0 { - t.Errorf("yearFromDate(invalid) = %d, want 0", y) - } -} - -func TestDurationMsToSec(t *testing.T) { - cases := []struct { - ms int32 - want int - }{ - {0, 0}, - {500, 1}, // rounds up - {499, 0}, // rounds down - {1500, 2}, // rounds up - {60000, 60}, - } - for _, c := range cases { - if got := durationMsToSec(c.ms); got != c.want { - t.Errorf("durationMsToSec(%d) = %d, want %d", c.ms, got, c.want) - } - } -} - -func TestCoverURLAndStreamURL(t *testing.T) { - var u pgtype.UUID - if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil { - t.Fatalf("scan: %v", err) - } - if got := coverURL(u); got != "/api/albums/00112233-4455-6677-8899-aabbccddeeff/cover" { - t.Errorf("coverURL = %q", got) - } - if got := streamURL(u); got != "/api/tracks/00112233-4455-6677-8899-aabbccddeeff/stream" { - t.Errorf("streamURL = %q", got) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1` -Expected: compile error — `undefined: uuidToString` etc. - -- [ ] **Step 3: Create `internal/api/convert.go`** - -```go -package api - -import ( - "github.com/jackc/pgx/v5/pgtype" -) - -// uuidToString renders a pgtype.UUID as a canonical hyphenated string. -// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic -// package intentionally — the two packages must not depend on each other. -func uuidToString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - b := u.Bytes - const hex = "0123456789abcdef" - out := make([]byte, 36) - j := 0 - for i, x := range b { - if i == 4 || i == 6 || i == 8 || i == 10 { - out[j] = '-' - j++ - } - out[j] = hex[x>>4] - out[j+1] = hex[x&0x0f] - j += 2 - } - return string(out) -} - -// parseUUID accepts the canonical hyphenated form produced by uuidToString. -// Returns ok=false on any malformed input so handlers can 400 cleanly. -func parseUUID(s string) (pgtype.UUID, bool) { - var u pgtype.UUID - if err := u.Scan(s); err != nil { - return pgtype.UUID{}, false - } - return u, u.Valid -} - -// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the -// date is unset. Album release years are optional at scan time. -func yearFromDate(d pgtype.Date) int { - if !d.Valid { - return 0 - } - return d.Time.Year() -} - -// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms) -// to seconds, rounding to nearest. Callers display seconds; sub-second precision -// is not useful in UI. -func durationMsToSec(ms int32) int { - return int((ms + 500) / 1000) -} - -// coverURL returns the /api relative URL for an album's cover art. The -// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire -// to it. -func coverURL(albumID pgtype.UUID) string { - return "/api/albums/" + uuidToString(albumID) + "/cover" -} - -// streamURL returns the /api relative URL for a track's audio stream. The -// endpoint ships in Plan 3. -func streamURL(trackID pgtype.UUID) string { - return "/api/tracks/" + uuidToString(trackID) + "/stream" -} -``` - -- [ ] **Step 4: Run — expect PASS** - -Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1 -v` -Expected: all five tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/api/convert.go internal/api/convert_test.go -git commit -m "feat(api): add conversion helpers for library DTOs" -``` - ---- - -## Task 4: Add ref/detail projection helpers - -These project dbq rows into the response DTOs. Keeping them next to the DTO types and conversion helpers means handlers stay thin. - -**Files:** -- Modify: `internal/api/convert.go` (append) -- Modify: `internal/api/convert_test.go` (append) - -- [ ] **Step 1: Append failing tests to `internal/api/convert_test.go`** - -```go - -func TestArtistRefFrom(t *testing.T) { - var id pgtype.UUID - _ = id.Scan("00112233-4455-6677-8899-aabbccddeeff") - a := dbq.Artist{ID: id, Name: "Beatles", SortName: "Beatles"} - got := artistRefFrom(a, 7) - if got.ID != "00112233-4455-6677-8899-aabbccddeeff" { - t.Errorf("ID = %q", got.ID) - } - if got.Name != "Beatles" || got.AlbumCount != 7 { - t.Errorf("ref = %+v", got) - } -} - -func TestAlbumRefFrom(t *testing.T) { - var aid, artID pgtype.UUID - _ = aid.Scan("00000000-0000-0000-0000-000000000001") - _ = artID.Scan("00000000-0000-0000-0000-000000000002") - var rel pgtype.Date - _ = rel.Scan("1969-09-26") - a := dbq.Album{ID: aid, Title: "Abbey Road", ArtistID: artID, ReleaseDate: rel} - got := albumRefFrom(a, "Beatles", 17, 2820) - if got.Title != "Abbey Road" || got.ArtistName != "Beatles" { - t.Errorf("ref = %+v", got) - } - if got.TrackCount != 17 || got.DurationSec != 2820 { - t.Errorf("counts = %+v", got) - } - if got.Year != 1969 { - t.Errorf("year = %d", got.Year) - } - if got.CoverURL != "/api/albums/00000000-0000-0000-0000-000000000001/cover" { - t.Errorf("cover_url = %q", got.CoverURL) - } -} - -func TestTrackRefFrom(t *testing.T) { - var tid, aid, artID pgtype.UUID - _ = tid.Scan("00000000-0000-0000-0000-000000000010") - _ = aid.Scan("00000000-0000-0000-0000-000000000001") - _ = artID.Scan("00000000-0000-0000-0000-000000000002") - trackNum := int32(3) - discNum := int32(1) - t2 := dbq.Track{ - ID: tid, Title: "Something", AlbumID: aid, ArtistID: artID, - TrackNumber: &trackNum, DiscNumber: &discNum, - DurationMs: 183_000, - } - got := trackRefFrom(t2, "Abbey Road", "Beatles") - if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" { - t.Errorf("ref = %+v", got) - } - if got.TrackNumber != 3 || got.DiscNumber != 1 { - t.Errorf("positions = %+v", got) - } - if got.DurationSec != 183 { - t.Errorf("duration = %d, want 183", got.DurationSec) - } - if got.StreamURL != "/api/tracks/00000000-0000-0000-0000-000000000010/stream" { - t.Errorf("stream_url = %q", got.StreamURL) - } -} - -func TestTrackRefFromNilPositions(t *testing.T) { - var tid, aid, artID pgtype.UUID - _ = tid.Scan("00000000-0000-0000-0000-000000000010") - _ = aid.Scan("00000000-0000-0000-0000-000000000001") - _ = artID.Scan("00000000-0000-0000-0000-000000000002") - t2 := dbq.Track{ID: tid, AlbumID: aid, ArtistID: artID, DurationMs: 1000} - got := trackRefFrom(t2, "A", "B") - if got.TrackNumber != 0 || got.DiscNumber != 0 { - t.Errorf("nil positions should be 0, got %+v", got) - } -} -``` - -Add these imports to `internal/api/convert_test.go` (or merge into existing import block): - -```go -import ( - "testing" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1` -Expected: compile error — `undefined: artistRefFrom` etc. - -- [ ] **Step 3: Append to `internal/api/convert.go`** - -Extend the existing import block to include `dbq`: - -```go -import ( - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -``` - -Append to the file: - -```go - -// artistRefFrom projects a dbq.Artist into an ArtistRef. albumCount must be -// pre-computed by the caller (one query per artist in lists). -func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef { - return ArtistRef{ - ID: uuidToString(a.ID), - Name: a.Name, - AlbumCount: albumCount, - } -} - -// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be -// pre-resolved because Album only carries artist_id. trackCount and -// durationSec may be 0 when the caller does not compute them. -func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef { - return AlbumRef{ - ID: uuidToString(a.ID), - Title: a.Title, - ArtistID: uuidToString(a.ArtistID), - ArtistName: artistName, - Year: yearFromDate(a.ReleaseDate), - TrackCount: trackCount, - DurationSec: durationSec, - CoverURL: coverURL(a.ID), - } -} - -// trackRefFrom projects a dbq.Track into a TrackRef. Parent names must be -// pre-resolved because Track only carries album_id and artist_id. -func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef { - ref := TrackRef{ - ID: uuidToString(t.ID), - Title: t.Title, - AlbumID: uuidToString(t.AlbumID), - AlbumTitle: albumTitle, - ArtistID: uuidToString(t.ArtistID), - ArtistName: artistName, - DurationSec: durationMsToSec(t.DurationMs), - StreamURL: streamURL(t.ID), - } - if t.TrackNumber != nil { - ref.TrackNumber = int(*t.TrackNumber) - } - if t.DiscNumber != nil { - ref.DiscNumber = int(*t.DiscNumber) - } - return ref -} -``` - -- [ ] **Step 4: Run — expect PASS** - -Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1 -v` -Expected: four tests PASS. - -- [ ] **Step 5: Full package builds clean** - -Run: `go build ./internal/api/...` -Expected: exit 0, no output. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/convert.go internal/api/convert_test.go -git commit -m "feat(api): add dbq→ref projection helpers" -``` - ---- - -## Task 5: Test fixtures helper - -Shared seed helpers that library_test.go and search_test.go both use. Putting them in `library_fixtures_test.go` means they compile only in the test binary and don't pollute the production package. - -**Files:** -- Create: `internal/api/library_fixtures_test.go` - -- [ ] **Step 1: Create `internal/api/library_fixtures_test.go`** - -```go -package api - -import ( - "context" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// truncateLibrary clears the music tables between tests. Sessions/users are -// cleared by testHandlers. CASCADE covers tracks→albums→artists FK chain. -func truncateLibrary(t *testing.T, pool *pgxpool.Pool) { - t.Helper() - if _, err := pool.Exec(context.Background(), - "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } -} - -// seedArtist inserts an artist with deterministic sort_name = name. -func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist { - t.Helper() - a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: name, - SortName: name, - Mbid: nil, - }) - if err != nil { - t.Fatalf("UpsertArtist(%s): %v", name, err) - } - return a -} - -// seedAlbum inserts an album belonging to artistID. releaseYear=0 leaves -// release_date unset. -func seedAlbum(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string, releaseYear int) dbq.Album { - t.Helper() - var rel pgtype.Date - if releaseYear > 0 { - rel = pgtype.Date{Time: time.Date(releaseYear, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true} - } - a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: title, - SortTitle: title, - ArtistID: artistID, - ReleaseDate: rel, - Mbid: nil, - CoverArtPath: nil, - }) - if err != nil { - t.Fatalf("UpsertAlbum(%s): %v", title, err) - } - return a -} - -// seedTrack inserts a track. trackNumber<=0 leaves it NULL. filePath is -// synthesized from title to stay unique across seeds. -func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32) dbq.Track { - t.Helper() - var tn *int32 - if trackNumber > 0 { - v := int32(trackNumber) - tn = &v - } - tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: albumID, - ArtistID: artistID, - TrackNumber: tn, - DiscNumber: nil, - DurationMs: durationMs, - FilePath: "/seed/" + title + ".flac", - FileSize: 1024, - FileFormat: "flac", - Bitrate: nil, - Mbid: nil, - Genre: nil, - }) - if err != nil { - t.Fatalf("UpsertTrack(%s): %v", title, err) - } - return tr -} -``` - -- [ ] **Step 2: Verify test binary compiles** - -Run: `go test -run '^$' ./internal/api/... -count=1` -Expected: no output and exit 0 (no tests match `^$`, but the package must compile). - -- [ ] **Step 3: Commit** - -```bash -git add internal/api/library_fixtures_test.go -git commit -m "test(api): add library seed fixtures" -``` - ---- - -## Task 6: Implement GET /api/tracks/{id} - -Starting with the simplest detail endpoint so the route-wiring pattern is in place before the more complex ones. TDD: write tests, verify fail, implement. - -**Files:** -- Create: `internal/api/library.go` -- Create: `internal/api/library_test.go` -- Modify: `internal/api/api.go` - -- [ ] **Step 1: Write failing test in `internal/api/library_test.go`** - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" -) - -// newLibraryRouter builds a test-only chi router with the library handlers -// mounted at their path-style routes. Tests hit this router rather than -// calling handler methods directly so chi URL params populate correctly. -// RequireUser is NOT applied — library handlers don't read user context. -func newLibraryRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/artists", h.handleListArtists) - r.Get("/api/artists/{id}", h.handleGetArtist) - r.Get("/api/albums/{id}", h.handleGetAlbum) - r.Get("/api/tracks/{id}", h.handleGetTrack) - r.Get("/api/search", h.handleSearch) - return r -} - -func TestHandleGetTrack_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) - - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID), nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got TrackRef - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v body=%s", err, w.Body.String()) - } - if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" { - t.Errorf("ref = %+v", got) - } - if got.TrackNumber != 3 || got.DurationSec != 183 { - t.Errorf("positions = %+v", got) - } - want := "/api/tracks/" + uuidToString(track.ID) + "/stream" - if got.StreamURL != want { - t.Errorf("stream_url = %q, want %q", got.StreamURL, want) - } -} - -func TestHandleGetTrack_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleGetTrack_NotFound(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - req := httptest.NewRequest(http.MethodGet, - "/api/tracks/00000000-0000-0000-0000-000000000001", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", w.Code) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1` -Expected: compile error — `h.handleGetTrack` undefined, plus sibling methods referenced in `newLibraryRouter`. - -- [ ] **Step 3: Create `internal/api/library.go` with all route method stubs + `handleGetTrack` implemented** - -Stubbing sibling methods (`handleListArtists`, `handleGetArtist`, `handleGetAlbum`, `handleSearch`) lets `newLibraryRouter` compile even though subsequent tasks flesh them out. - -```go -package api - -import ( - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album + -// artist names so the response is self-contained — clients should not need -// a follow-up request to render a track outside an album view. -func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - } - h.logger.Error("api: get track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - album, err := q.GetAlbumByID(r.Context(), track.AlbumID) - if err != nil { - h.logger.Error("api: get track album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), track.ArtistID) - if err != nil { - h.logger.Error("api: get track artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name)) -} - -// handleGetAlbum is implemented in Task 7. -func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} - -// handleGetArtist is implemented in Task 8. -func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} - -// handleListArtists is implemented in Task 9. -func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} - -// handleSearch is implemented in Task 10 (file: search.go). -func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} -``` - -- [ ] **Step 4: Add `writeJSON` helper to `internal/api/errors.go`** - -Open `internal/api/errors.go` and append: - -```go - -// writeJSON emits a 2xx response with the given body JSON-encoded. Mirrors -// writeErr's shape so handlers have one shape for success and one for -// failure. -func writeJSON(w http.ResponseWriter, status int, body any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(body) -} -``` - -- [ ] **Step 5: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1 -v` -Expected: three tests PASS (`_HappyPath`, `_BadUUID`, `_NotFound`). - -- [ ] **Step 6: Full test suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all existing and new tests PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/library.go internal/api/library_test.go internal/api/errors.go -git commit -m "feat(api): GET /api/tracks/{id} handler with parent metadata" -``` - ---- - -## Task 7: Implement GET /api/albums/{id} - -**Files:** -- Modify: `internal/api/library.go` (replace the `handleGetAlbum` stub) -- Modify: `internal/api/library_test.go` (append tests) - -- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`** - -```go - -func TestHandleGetAlbum_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - seedTrack(t, pool, album.ID, artist.ID, "Come Together", 1, 259_000) - seedTrack(t, pool, album.ID, artist.ID, "Something", 2, 183_000) - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got AlbumDetail - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v body=%s", err, w.Body.String()) - } - if got.Title != "Abbey Road" || got.ArtistName != "Beatles" || got.Year != 1969 { - t.Errorf("album = %+v", got) - } - if len(got.Tracks) != 2 { - t.Fatalf("tracks len = %d, want 2", len(got.Tracks)) - } - if got.Tracks[0].Title != "Come Together" || got.Tracks[1].Title != "Something" { - t.Errorf("tracks = %+v", got.Tracks) - } - if got.TrackCount != 2 || got.DurationSec != 442 { // 259 + 183 - t.Errorf("counts = track=%d duration=%d", got.TrackCount, got.DurationSec) - } - want := "/api/albums/" + uuidToString(album.ID) + "/cover" - if got.CoverURL != want { - t.Errorf("cover_url = %q", got.CoverURL) - } - // StreamURL on nested tracks must be set - if got.Tracks[0].StreamURL == "" { - t.Error("nested track missing stream_url") - } -} - -func TestHandleGetAlbum_NoTracks(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Empty", 0) - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got AlbumDetail - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Tracks == nil { - t.Error("tracks = nil, want [] for JSON encoding") - } - if len(got.Tracks) != 0 { - t.Errorf("tracks len = %d, want 0", len(got.Tracks)) - } - // Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON - if got.Year != 0 { - t.Errorf("year = %d, want 0", got.Year) - } -} - -func TestHandleGetAlbum_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleGetAlbum_NotFound(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - req := httptest.NewRequest(http.MethodGet, - "/api/albums/00000000-0000-0000-0000-000000000001", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", w.Code) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL (stub returns 501)** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1` -Expected: all four fail because the stub returns 501. - -- [ ] **Step 3: Replace `handleGetAlbum` stub in `internal/api/library.go`** - -Find the stub: - -```go -// handleGetAlbum is implemented in Task 7. -func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} -``` - -Replace with: - -```go -// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its -// tracks (ordered by disc/track number via the underlying query) with -// duration summed from the track list — keeps one source of truth. -func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id") - return - } - q := dbq.New(h.pool) - album, err := q.GetAlbumByID(r.Context(), id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "album not found") - return - } - h.logger.Error("api: get album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), album.ArtistID) - if err != nil { - h.logger.Error("api: get album artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - tracks, err := q.ListTracksByAlbum(r.Context(), id) - if err != nil { - h.logger.Error("api: list tracks failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - refs := make([]TrackRef, 0, len(tracks)) - durSec := 0 - for _, t := range tracks { - ref := trackRefFrom(t, album.Title, artist.Name) - refs = append(refs, ref) - durSec += ref.DurationSec - } - detail := AlbumDetail{ - AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec), - Tracks: refs, - } - writeJSON(w, http.StatusOK, detail) -} -``` - -- [ ] **Step 4: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1 -v` -Expected: four tests PASS. - -- [ ] **Step 5: Full api suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/library.go internal/api/library_test.go -git commit -m "feat(api): GET /api/albums/{id} with nested tracks" -``` - ---- - -## Task 8: Implement GET /api/artists/{id} - -**Files:** -- Modify: `internal/api/library.go` (replace the `handleGetArtist` stub) -- Modify: `internal/api/library_test.go` (append tests) - -- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`** - -```go - -func TestHandleGetArtist_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album1 := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - album2 := seedAlbum(t, pool, artist.ID, "Let It Be", 1970) - seedTrack(t, pool, album1.ID, artist.ID, "Something", 3, 183_000) - seedTrack(t, pool, album2.ID, artist.ID, "Get Back", 1, 189_000) - - req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got ArtistDetail - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v body=%s", err, w.Body.String()) - } - if got.Name != "Beatles" { - t.Errorf("name = %q", got.Name) - } - if got.AlbumCount != 2 { - t.Errorf("album_count = %d, want 2", got.AlbumCount) - } - if len(got.Albums) != 2 { - t.Fatalf("albums len = %d, want 2", len(got.Albums)) - } - // ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first. - if got.Albums[0].Title != "Abbey Road" { - t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title) - } - for _, a := range got.Albums { - if a.ArtistName != "Beatles" { - t.Errorf("album.artist_name = %q", a.ArtistName) - } - if a.CoverURL == "" { - t.Error("album missing cover_url") - } - if a.TrackCount != 1 { - t.Errorf("album.track_count = %d, want 1", a.TrackCount) - } - } -} - -func TestHandleGetArtist_NoAlbums(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Solo") - - req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got ArtistDetail - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Albums == nil { - t.Error("albums = nil, want []") - } - if got.AlbumCount != 0 { - t.Errorf("album_count = %d, want 0", got.AlbumCount) - } -} - -func TestHandleGetArtist_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/artists/not-a-uuid", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleGetArtist_NotFound(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - req := httptest.NewRequest(http.MethodGet, - "/api/artists/00000000-0000-0000-0000-000000000001", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", w.Code) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL (stub 501)** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1` -Expected: four fail against the stub. - -- [ ] **Step 3: Replace `handleGetArtist` stub in `internal/api/library.go`** - -Replace with: - -```go -// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums; -// each album carries its own track_count (one count query per album, same -// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine. -func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") - return - } - q := dbq.New(h.pool) - artist, err := q.GetArtistByID(r.Context(), id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "artist not found") - return - } - h.logger.Error("api: get artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - albums, err := q.ListAlbumsByArtist(r.Context(), id) - if err != nil { - h.logger.Error("api: list albums by artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - refs := make([]AlbumRef, 0, len(albums)) - for _, a := range albums { - count, cerr := q.CountTracksByAlbum(r.Context(), a.ID) - if cerr != nil { - h.logger.Error("api: count tracks failed", "err", cerr) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0)) - } - detail := ArtistDetail{ - ArtistRef: artistRefFrom(artist, len(albums)), - Albums: refs, - } - writeJSON(w, http.StatusOK, detail) -} -``` - -- [ ] **Step 4: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1 -v` -Expected: four tests PASS. - -- [ ] **Step 5: Full api suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/library.go internal/api/library_test.go -git commit -m "feat(api): GET /api/artists/{id} with nested albums" -``` - ---- - -## Task 9: Implement GET /api/artists (paged list with sort) - -**Files:** -- Modify: `internal/api/library.go` (replace the `handleListArtists` stub; add pagination helper) -- Modify: `internal/api/library_test.go` (append tests) - -- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`** - -```go - -func TestHandleListArtists_DefaultAlpha(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - seedArtist(t, pool, "Beatles") - seedArtist(t, pool, "ABBA") - seedArtist(t, pool, "Zeppelin") - - req := httptest.NewRequest(http.MethodGet, "/api/artists", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got Page[ArtistRef] - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Total != 3 { - t.Errorf("total = %d, want 3", got.Total) - } - if got.Limit != 50 || got.Offset != 0 { - t.Errorf("pagination defaults = limit=%d offset=%d, want 50/0", got.Limit, got.Offset) - } - if len(got.Items) != 3 { - t.Fatalf("items len = %d", len(got.Items)) - } - if got.Items[0].Name != "ABBA" || got.Items[2].Name != "Zeppelin" { - t.Errorf("alpha order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name) - } -} - -func TestHandleListArtists_SortNewest(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - // Seeded in this order; created_at DESC means Third first. - seedArtist(t, pool, "First") - seedArtist(t, pool, "Second") - seedArtist(t, pool, "Third") - - req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=newest", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got Page[ArtistRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if len(got.Items) != 3 { - t.Fatalf("items len = %d", len(got.Items)) - } - if got.Items[0].Name != "Third" || got.Items[2].Name != "First" { - t.Errorf("newest order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name) - } -} - -func TestHandleListArtists_InvalidSort(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - seedArtist(t, pool, "Only") - - req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=garbage", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleListArtists_Pagination(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - for _, n := range []string{"A", "B", "C", "D", "E"} { - seedArtist(t, pool, n) - } - - req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=2&offset=2", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got Page[ArtistRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Total != 5 { - t.Errorf("total = %d, want 5", got.Total) - } - if got.Limit != 2 || got.Offset != 2 { - t.Errorf("page = limit=%d offset=%d", got.Limit, got.Offset) - } - if len(got.Items) != 2 { - t.Fatalf("items len = %d", len(got.Items)) - } - if got.Items[0].Name != "C" || got.Items[1].Name != "D" { - t.Errorf("slice = %q, %q", got.Items[0].Name, got.Items[1].Name) - } -} - -func TestHandleListArtists_LimitClamped(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - seedArtist(t, pool, "One") - - req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=9999", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got Page[ArtistRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Limit != 200 { - t.Errorf("limit = %d, want 200 (max clamp)", got.Limit) - } -} - -func TestHandleListArtists_LimitNonNumeric(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=abc", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleListArtists_AlbumCount(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - seedAlbum(t, pool, artist.ID, "X", 0) - seedAlbum(t, pool, artist.ID, "Y", 0) - - req := httptest.NewRequest(http.MethodGet, "/api/artists", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - var got Page[ArtistRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if len(got.Items) != 1 || got.Items[0].AlbumCount != 2 { - t.Errorf("items = %+v", got.Items) - } -} - -func TestHandleListArtists_EmptyDB(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - - req := httptest.NewRequest(http.MethodGet, "/api/artists", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got Page[ArtistRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Items == nil { - t.Error("items = nil, want []") - } - if got.Total != 0 { - t.Errorf("total = %d", got.Total) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1` -Expected: all eight fail against the stub. - -- [ ] **Step 3: Add pagination parsing helper to `internal/api/convert.go`** - -Append: - -```go - -// parsePaging reads limit/offset from the query string, applying defaults -// and clamping. Returns a 400-worthy error when the values are non-numeric -// or negative; out-of-range values silently clamp (deliberate — UX). -func parsePaging(raw url.Values) (limit, offset int, err error) { - const ( - defLimit = 50 - maxLimit = 200 - ) - limit = defLimit - if s := strings.TrimSpace(raw.Get("limit")); s != "" { - n, perr := strconv.Atoi(s) - if perr != nil { - return 0, 0, errBadPaging - } - if n < 1 { - n = 1 - } - if n > maxLimit { - n = maxLimit - } - limit = n - } - if s := strings.TrimSpace(raw.Get("offset")); s != "" { - n, perr := strconv.Atoi(s) - if perr != nil { - return 0, 0, errBadPaging - } - if n < 0 { - return 0, 0, errBadPaging - } - offset = n - } - return limit, offset, nil -} -``` - -Extend the import block at the top of `internal/api/convert.go` with the new imports + `errBadPaging`: - -```go -import ( - "errors" - "net/url" - "strconv" - "strings" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -var errBadPaging = errors.New("invalid limit or offset") -``` - -- [ ] **Step 4: Replace `handleListArtists` stub in `internal/api/library.go`** - -Replace the stub with: - -```go -// handleListArtists implements GET /api/artists with two sort modes -// (alpha|newest) and enveloped pagination. album_count per artist is -// computed via an N+1 — acceptable at limit=200. -func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) { - sort := r.URL.Query().Get("sort") - if sort == "" { - sort = "alpha" - } - if sort != "alpha" && sort != "newest" { - writeErr(w, http.StatusBadRequest, "bad_request", "sort must be alpha or newest") - return - } - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - - q := dbq.New(h.pool) - var artists []dbq.Artist - switch sort { - case "newest": - artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ - Limit: int32(limit), Offset: int32(offset), - }) - default: // alpha - artists, err = q.ListArtistsAlpha(r.Context(), dbq.ListArtistsAlphaParams{ - Limit: int32(limit), Offset: int32(offset), - }) - } - if err != nil { - h.logger.Error("api: list artists failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - total, err := q.CountArtists(r.Context()) - if err != nil { - h.logger.Error("api: count artists failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - - items := make([]ArtistRef, 0, len(artists)) - for _, a := range artists { - albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) - if aerr != nil { - h.logger.Error("api: list albums for artist failed", "err", aerr) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - items = append(items, artistRefFrom(a, len(albums))) - } - writeJSON(w, http.StatusOK, Page[ArtistRef]{ - Items: items, - Total: int(total), - Limit: limit, - Offset: offset, - }) -} -``` - -- [ ] **Step 5: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1 -v` -Expected: all eight tests PASS. - -- [ ] **Step 6: Full api suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/library.go internal/api/library_test.go internal/api/convert.go -git commit -m "feat(api): GET /api/artists with alpha/newest sort and paging" -``` - ---- - -## Task 10: Implement GET /api/search - -**Files:** -- Create: `internal/api/search.go` -- Create: `internal/api/search_test.go` -- Modify: `internal/api/library.go` (remove `handleSearch` stub) - -- [ ] **Step 1: Write failing tests in `internal/api/search_test.go`** - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestHandleSearch_ThreeFacets(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Beatles Greatest", 1982) - seedTrack(t, pool, album.ID, artist.ID, "Beatles Jam", 1, 60_000) - - req := httptest.NewRequest(http.MethodGet, "/api/search?q=beatles", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got SearchResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Artists.Total != 1 || len(got.Artists.Items) != 1 { - t.Errorf("artists = %+v", got.Artists) - } - if got.Albums.Total != 1 || len(got.Albums.Items) != 1 { - t.Errorf("albums = %+v", got.Albums) - } - if got.Tracks.Total != 1 || len(got.Tracks.Items) != 1 { - t.Errorf("tracks = %+v", got.Tracks) - } - if got.Artists.Items[0].Name != "Beatles" { - t.Errorf("artist name = %q", got.Artists.Items[0].Name) - } - if got.Albums.Items[0].ArtistName != "Beatles" { - t.Errorf("album.artist_name = %q", got.Albums.Items[0].ArtistName) - } - if got.Tracks.Items[0].AlbumTitle != "Beatles Greatest" { - t.Errorf("track.album_title = %q", got.Tracks.Items[0].AlbumTitle) - } -} - -func TestHandleSearch_NoMatches(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - seedArtist(t, pool, "Beatles") - - req := httptest.NewRequest(http.MethodGet, "/api/search?q=nothinghere", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got SearchResponse - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Artists.Total != 0 || got.Albums.Total != 0 || got.Tracks.Total != 0 { - t.Errorf("totals = %+v", got) - } - // All three item slices must JSON-encode as [] not null. - if got.Artists.Items == nil || got.Albums.Items == nil || got.Tracks.Items == nil { - t.Errorf("nil item slice: %+v", got) - } -} - -func TestHandleSearch_EmptyQuery(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/search", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleSearch_BlankQuery(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/search?q=%20%20", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleSearch_PagingAppliedPerFacet(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - // Create three matching artists so paging shows. - a1 := seedArtist(t, pool, "Match One") - a2 := seedArtist(t, pool, "Match Two") - a3 := seedArtist(t, pool, "Match Three") - _ = a1 - _ = a2 - _ = a3 - - req := httptest.NewRequest(http.MethodGet, "/api/search?q=Match&limit=2&offset=1", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got SearchResponse - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Artists.Total != 3 { - t.Errorf("total = %d, want 3", got.Artists.Total) - } - if got.Artists.Limit != 2 || got.Artists.Offset != 1 { - t.Errorf("page = limit=%d offset=%d", got.Artists.Limit, got.Artists.Offset) - } - if len(got.Artists.Items) != 2 { - t.Errorf("items len = %d, want 2", len(got.Artists.Items)) - } -} -``` - -- [ ] **Step 2: Run — expect FAIL (stub returns 501)** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1` -Expected: five fail against stub. - -- [ ] **Step 3: Create `internal/api/search.go`** - -```go -package api - -import ( - "context" - "net/http" - "strings" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// handleSearch implements GET /api/search. Runs three paged facets sharing -// one limit/offset. Each facet returns its own total reflecting the full -// match count (not just the page). Nil slices are explicitly [] so JSON -// doesn't emit null. -func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { - q := strings.TrimSpace(r.URL.Query().Get("q")) - if q == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "q is required") - return - } - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - - dbQ := dbq.New(h.pool) - needle := &q - - artists, err := dbQ.SearchArtists(r.Context(), dbq.SearchArtistsParams{ - Column1: needle, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: search artists failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q) - if err != nil { - h.logger.Error("api: count artists matching failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - artistItems := make([]ArtistRef, 0, len(artists)) - for _, a := range artists { - albums, aerr := dbQ.ListAlbumsByArtist(r.Context(), a.ID) - if aerr != nil { - h.logger.Error("api: list albums for search artist failed", "err", aerr) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - artistItems = append(artistItems, artistRefFrom(a, len(albums))) - } - - albums, err := dbQ.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{ - Column1: needle, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: search albums failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q) - if err != nil { - h.logger.Error("api: count albums matching failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums) - if err != nil { - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - - tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{ - Column1: needle, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: search tracks failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - trackTotal, err := dbQ.CountTracksMatching(r.Context(), q) - if err != nil { - h.logger.Error("api: count tracks matching failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks) - if err != nil { - writeErr(w, http.StatusInternalServerError, "server_error", "search failed") - return - } - - writeJSON(w, http.StatusOK, SearchResponse{ - Artists: Page[ArtistRef]{Items: artistItems, Total: int(artistTotal), Limit: limit, Offset: offset}, - Albums: Page[AlbumRef]{Items: albumItems, Total: int(albumTotal), Limit: limit, Offset: offset}, - Tracks: Page[TrackRef]{Items: trackItems, Total: int(trackTotal), Limit: limit, Offset: offset}, - }) -} - -// resolveAlbumRefs builds AlbumRefs from dbq.Album rows: resolves artist -// name (one query per distinct artist) and track count per album (one -// query each). Acceptable at limit=200 page size. -func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) { - items := make([]AlbumRef, 0, len(albums)) - names, err := resolveArtistNames(ctx, q, collectArtistIDs(albums)) - if err != nil { - h.logger.Error("api: resolve artist names failed", "err", err) - return nil, err - } - for _, a := range albums { - count, cerr := q.CountTracksByAlbum(ctx, a.ID) - if cerr != nil { - h.logger.Error("api: count tracks for album failed", "err", cerr) - return nil, cerr - } - items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0)) - } - return items, nil -} - -// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the -// parent album + artist by id for each track (pair of queries per track). -// At limit=200 this is 400 queries worst case — acceptable for M1.5. -func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) { - items := make([]TrackRef, 0, len(tracks)) - for _, t := range tracks { - album, aerr := q.GetAlbumByID(ctx, t.AlbumID) - if aerr != nil { - h.logger.Error("api: get album for track failed", "err", aerr) - return nil, aerr - } - artist, aerr := q.GetArtistByID(ctx, t.ArtistID) - if aerr != nil { - h.logger.Error("api: get artist for track failed", "err", aerr) - return nil, aerr - } - items = append(items, trackRefFrom(t, album.Title, artist.Name)) - } - return items, nil -} - -// collectArtistIDs extracts the distinct-preserving ordered list of artist -// ids from a slice of albums. resolveArtistNames dedupes internally. -func collectArtistIDs(albums []dbq.Album) []pgtype.UUID { - ids := make([]pgtype.UUID, 0, len(albums)) - for _, a := range albums { - ids = append(ids, a.ArtistID) - } - return ids -} - -// resolveArtistNames fetches each distinct artist id and returns a -// uuid-string → name map. Duplicated from subsonic intentionally. -func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) { - seen := make(map[string]struct{}, len(ids)) - out := make(map[string]string, len(ids)) - for _, id := range ids { - key := uuidToString(id) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - a, err := q.GetArtistByID(ctx, id) - if err != nil { - return nil, err - } - out[key] = a.Name - } - return out, nil -} -``` - -- [ ] **Step 4: Remove the `handleSearch` stub from `internal/api/library.go`** - -Delete this block from `library.go`: - -```go -// handleSearch is implemented in Task 10 (file: search.go). -func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "stub") -} -``` - -- [ ] **Step 5: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1 -v` -Expected: five tests PASS. - -- [ ] **Step 6: Full api suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/search.go internal/api/search_test.go internal/api/library.go -git commit -m "feat(api): GET /api/search with three paged facets" -``` - ---- - -## Task 11: Register routes in production mount - -The tests exercise a test-only router; the production `api.Mount` must also wire all five routes so the SPA can hit them. Once wired, `RequireUser` gates them. - -**Files:** -- Modify: `internal/api/api.go` - -- [ ] **Step 1: Write failing test for production route registration** - -Append to `internal/api/library_test.go`: - -```go - -// TestRoutesRegisteredInMount verifies the production Mount() wires every -// library route. We hit each path through the real Mount (no RequireUser -// stripped — we expect 401 from the middleware, which is fine — that -// proves the route reached the authenticated group). -func TestRoutesRegisteredInMount(t *testing.T) { - h, _ := testHandlers(t) - r := chi.NewRouter() - Mount(r, h.pool, h.logger) - - paths := []string{ - "/api/artists", - "/api/artists/00000000-0000-0000-0000-000000000001", - "/api/albums/00000000-0000-0000-0000-000000000001", - "/api/tracks/00000000-0000-0000-0000-000000000001", - "/api/search?q=x", - } - for _, p := range paths { - req := httptest.NewRequest(http.MethodGet, p, nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - // Unauthenticated → 401. What we must NOT see is 404 (route missing). - if w.Code == http.StatusNotFound { - t.Errorf("path %s returned 404 — route not registered", p) - } - if w.Code != http.StatusUnauthorized { - t.Errorf("path %s status = %d, want 401 (unauth)", p, w.Code) - } - } -} -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1` -Expected: 404s — routes not in Mount yet. - -- [ ] **Step 3: Update `internal/api/api.go`** - -Replace the existing `Mount` function with: - -```go -// Mount attaches /api/* handlers to r. Public endpoints (login) are outside -// RequireUser; everything else is gated by the middleware. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) { - h := &handlers{pool: pool, logger: logger} - - r.Route("/api", func(api chi.Router) { - api.Post("/auth/login", h.handleLogin) - api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) - authed.Post("/auth/logout", h.handleLogout) - authed.Get("/me", h.handleGetMe) - - authed.Get("/artists", h.handleListArtists) - authed.Get("/artists/{id}", h.handleGetArtist) - authed.Get("/albums/{id}", h.handleGetAlbum) - authed.Get("/tracks/{id}", h.handleGetTrack) - authed.Get("/search", h.handleSearch) - }) - }) -} -``` - -- [ ] **Step 4: Run — expect PASS** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1 -v` -Expected: all paths return 401, test PASSes. - -- [ ] **Step 5: api package suite stays green** - -Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1` -Expected: all api tests PASS. - -Optionally run `go test ./... -count=1` to catch cross-package regressions. Known flaky integration tests outside `internal/api/` (bootstrap, scanner) may fail independently — those are pre-existing and out of scope for this plan. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/api.go internal/api/library_test.go -git commit -m "feat(api): register library + search routes under RequireUser" -``` - ---- - -## Task 12: Manual smoke + PR - -Live-binary verification: start the server and hit the endpoints end-to-end, then open a PR. - -- [ ] **Step 1: Build and run the server** - -Run: `make build && ./bin/minstrel server` (or whatever the invocation is on your branch — check the Makefile's `build` target). - -Expected: server listens on the configured port without errors in the log. - -- [ ] **Step 2: Log in to capture a session cookie + token** - -Run: - -```bash -curl -s -i -X POST http://localhost:8080/api/auth/login \ - -H 'content-type: application/json' \ - -d '{"username":"admin","password":""}' -``` - -Expected: `200 OK`, `Set-Cookie: minstrel_session=...`, JSON body with `token` and `user`. - -Copy the token from the JSON body; call it `$TOKEN` below. - -- [ ] **Step 3: Smoke each endpoint** - -Run: - -```bash -curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists | jq . -curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/artists?sort=newest&limit=5' | jq . -curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/search?q=a&limit=3' | jq . -# Grab a real artist id from /api/artists output, substitute below: -curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists/ | jq . -# Grab a real album id from the artist detail output: -curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/ | jq . -# Grab a real track id from the album detail output: -curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tracks/ | jq . -``` - -Expected: -- Each returns 200 with valid JSON in the documented shape. -- List response contains `items`, `total`, `limit`, `offset`. -- Album detail's `cover_url` ends in `/cover` and tracks' `stream_url` end in `/stream`. -- Error path: `curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/not-a-uuid | jq .` returns 400 with `{error:{code:"bad_request",...}}`. - -- [ ] **Step 4: Verify unauth paths are 401** - -Run: `curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/artists` -Expected: `401`. - -- [ ] **Step 5: Push branch and open PR** - -Run: - -```bash -git push -u origin dev -``` - -Then open a PR against `main` via `gh` (or whatever the project uses; the memory notes Forgejo MCP): - -PR title: `feat: library reads + search API (/api/artists, albums, tracks, search)` - -PR body: - -``` -Implements Plan 2 of the web UI rollout: paginated artist list with alpha/newest sort, artist/album/track detail, and unified search. - -Spec: docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md - -Highlights: -- Enveloped pagination: {items, total, limit, offset} -- Ref/Detail response split mirroring subsonic -- Forward-reference stream/cover URLs embedded (Plan 3 implements the endpoints) -- Empty slices always render as [] (never null) - -Test plan: -- [x] Unit tests for conversion helpers -- [x] Integration tests per endpoint (happy path, 400/404 paths, paging, sort) -- [x] Route-registration test verifies all five routes wired under RequireUser -- [x] Manual smoke verified against running server -``` - -- [ ] **Step 6: Wait for CI** - -Check Forgejo Actions; fix any lint/test failures surfaced there and push. Once green, merge per project workflow (dev → protected main). - ---- - -## Self-Review Checklist (run before handing off) - -1. **Spec coverage:** - - Routes: Tasks 6 (tracks), 7 (albums), 8 (artist detail), 9 (artist list), 10 (search), 11 (mount) ✓ - - Pagination envelope `Page[T]`: Task 2 ✓ - - Sort modes alpha/newest: Task 1 (SQL), Task 9 (handler + tests) ✓ - - URL embedding: Task 3 (helpers), projection in Task 4 ✓ - - Error taxonomy (400/404/401/500): exercised per-endpoint test ✓ - - Empty-slice-never-null rule: Tasks 7, 8, 9, 10 assert explicitly ✓ - -2. **Placeholder scan:** no TBD/TODO/"add appropriate error handling" — every step shows code or exact commands. - -3. **Type consistency:** Helpers named `artistRefFrom`, `albumRefFrom`, `trackRefFrom` consistently across Tasks 4–10. `parsePaging` signature `(raw map[string][]string) (limit, offset int, err error)` used identically in Tasks 9 and 10. `newLibraryRouter` and `withUserCtx` helpers defined in Task 6 are reused in Tasks 7–11. diff --git a/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md b/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md deleted file mode 100644 index e43e3351..00000000 --- a/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md +++ /dev/null @@ -1,1458 +0,0 @@ -# Web UI Scaffold — Server Auth Foundation 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:** Land the `/api/*` authentication foundation that the upcoming SvelteKit SPA and the future Flutter client will share. Adds a sessions table, a `POST /api/auth/login` / `POST /api/auth/logout` / `GET /api/me` surface, and a `RequireUser` middleware that accepts either a session cookie or an `Authorization: Bearer` header. - -**Architecture:** New `internal/api` package holds native JSON handlers (no Subsonic envelope). Sessions are opaque 32-byte tokens, stored as sha256 hashes in a new `sessions` table. `POST /api/auth/login` sets an `httpOnly; SameSite=Strict` cookie and returns the same token in the JSON body so Flutter can use `Authorization: Bearer` later. Middleware tries cookie first, bearer second. `/rest/*` is untouched. - -**Tech Stack:** Go 1.23, pgx/v5, sqlc, chi router, golang-migrate, bcrypt, stdlib `crypto/sha256` + `crypto/rand`. - -**Scope guard:** This plan intentionally stops at auth + `/api/me`. Library browse / search / stream / cover-art endpoints and the SvelteKit project are separate plans that land after this one. - ---- - -## File Structure - -**New files:** - -- `internal/db/migrations/0004_sessions.up.sql` — create sessions table -- `internal/db/migrations/0004_sessions.down.sql` — drop sessions table -- `internal/db/queries/sessions.sql` — sqlc queries for sessions -- `internal/db/dbq/sessions.sql.go` — **generated** by `make generate` -- `internal/auth/session.go` — session mint / hash / lookup helpers + `VerifyPassword` + `RequireUser` -- `internal/auth/session_test.go` — unit tests for the helpers -- `internal/api/api.go` — package doc, route mounting (`Mount(chi.Router, pool, logger)`) -- `internal/api/types.go` — shared request/response types -- `internal/api/auth.go` — `handleLogin`, `handleLogout` -- `internal/api/auth_test.go` — handler tests -- `internal/api/me.go` — `handleGetMe` -- `internal/api/me_test.go` — handler test -- `internal/api/errors.go` — `writeErr(w, status, code, message)` helper + tests in `auth_test.go` - -**Modified files:** - -- `internal/server/server.go:35-54` — mount `api.Mount(...)` inside the `/api` route group -- `internal/auth/middleware.go` — no edits; `RequireUser` lives in the new `session.go` alongside cookie helpers - -**Guiding principle:** files stay focused. `session.go` owns the session lifecycle; each `handle*` file owns one HTTP surface. Helpers that would be reused from other `/api/*` handlers (error writer, JSON envelope) live in `api/` for future siblings to import. - ---- - -## Conventions (apply to every task unless a task says otherwise) - -- **Always run `gofmt -w` on any file you touch before committing.** -- **Test commands** are run from repo root (`/home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel`). -- **`sqlc generate` is run via `make generate`.** This requires Docker; if Docker isn't available, fail the task — don't skip regeneration. -- **Commit messages follow existing repo style:** `type(scope): subject` (`feat(api): ...`, `fix(auth): ...`), one-line subject ≤ 72 chars, body explains *why*, trailer is `Co-Authored-By: Claude Opus 4.7 `. -- **Every `git commit` step below uses a HEREDOC** so multi-line messages survive shell quoting. - ---- - -### Task 1: Migration — create `sessions` table - -**Files:** -- Create: `internal/db/migrations/0004_sessions.up.sql` -- Create: `internal/db/migrations/0004_sessions.down.sql` - -- [ ] **Step 1: Write the up migration** - -File: `internal/db/migrations/0004_sessions.up.sql` - -```sql --- Session tokens authenticate /api/* requests. We store sha256(token) so a --- read-only DB leak doesn't grant active sessions; the raw token lives only --- in the client's cookie (web) or bearer header (Flutter). --- --- last_seen_at enables an "active sessions" UI later (not wired in this plan) --- without schema churn. user_agent is captured at issue time for the same --- reason — free metadata now, no migration later. - -CREATE TABLE sessions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash bytea NOT NULL UNIQUE, - user_agent text NOT NULL DEFAULT '', - created_at timestamptz NOT NULL DEFAULT now(), - last_seen_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX sessions_user_id_idx ON sessions (user_id); -``` - -- [ ] **Step 2: Write the down migration** - -File: `internal/db/migrations/0004_sessions.down.sql` - -```sql -DROP INDEX IF EXISTS sessions_user_id_idx; -DROP TABLE IF EXISTS sessions; -``` - -- [ ] **Step 3: Run the migration against a disposable DB to prove it's well-formed** - -Command: -```bash -docker compose exec -T postgres psql -U minstrel -d minstrel -c "SELECT version FROM schema_migrations;" -docker compose restart minstrel -docker compose logs minstrel --since=30s | grep -i migrat -``` - -Expected: logs show "applied 0004_sessions" (or equivalent); `SELECT * FROM sessions LIMIT 0;` works afterwards. - -If the stack isn't running, start it: `docker compose up -d` and repeat. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/migrations/0004_sessions.up.sql internal/db/migrations/0004_sessions.down.sql -git commit -m "$(cat <<'EOF' -feat(db): add sessions table for /api/* auth - -Stores sha256(token) plus user_agent + last_seen_at so future -active-sessions UI doesn't need another migration. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 2: sqlc queries for sessions - -**Files:** -- Create: `internal/db/queries/sessions.sql` -- Regenerate: `internal/db/dbq/sessions.sql.go` (via `make generate`) - -- [ ] **Step 1: Write the queries** - -File: `internal/db/queries/sessions.sql` - -```sql --- name: InsertSession :one -INSERT INTO sessions (user_id, token_hash, user_agent) -VALUES ($1, $2, $3) -RETURNING *; - --- name: GetSessionByTokenHash :one -SELECT * FROM sessions WHERE token_hash = $1; - --- name: TouchSessionLastSeen :exec -UPDATE sessions SET last_seen_at = now() WHERE id = $1; - --- name: DeleteSession :exec -DELETE FROM sessions WHERE id = $1; - --- name: DeleteSessionByTokenHash :exec -DELETE FROM sessions WHERE token_hash = $1; -``` - -- [ ] **Step 2: Regenerate sqlc output** - -Run: `make generate` - -Expected: `internal/db/dbq/sessions.sql.go` is created with `InsertSession`, `GetSessionByTokenHash`, `TouchSessionLastSeen`, `DeleteSession`, `DeleteSessionByTokenHash`. - -- [ ] **Step 3: Verify generated code compiles** - -Run: `go build ./...` -Expected: exit 0, no output. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/queries/sessions.sql internal/db/dbq/sessions.sql.go -git commit -m "$(cat <<'EOF' -feat(dbq): generate session queries - -Adds Insert/Get/Touch/Delete helpers over the sessions table. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 3: Session helpers — mint / hash / verify password - -**Files:** -- Create: `internal/auth/session.go` -- Create: `internal/auth/session_test.go` - -- [ ] **Step 1: Write the failing test** - -File: `internal/auth/session_test.go` - -```go -package auth - -import ( - "crypto/sha256" - "encoding/base64" - "strings" - "testing" - - "golang.org/x/crypto/bcrypt" -) - -func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) { - token, err := MintSessionToken() - if err != nil { - t.Fatalf("MintSessionToken: %v", err) - } - if len(token) < 40 { - t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token)) - } - if strings.ContainsAny(token, "+/=") { - t.Errorf("token %q contains non-url-safe chars", token) - } -} - -func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) { - token := "test-token-xyz" - want := sha256.Sum256([]byte(token)) - - got := HashSessionToken(token) - if len(got) != sha256.Size { - t.Fatalf("hash length = %d, want %d", len(got), sha256.Size) - } - if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) { - t.Errorf("hash = %x, want %x", got, want) - } -} - -func TestVerifyPassword(t *testing.T) { - hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost) - if err != nil { - t.Fatalf("GenerateFromPassword: %v", err) - } - if !VerifyPassword(string(hash), "hunter2") { - t.Error("correct password rejected") - } - if VerifyPassword(string(hash), "wrong") { - t.Error("wrong password accepted") - } - if VerifyPassword("not-a-hash", "hunter2") { - t.Error("malformed hash accepted") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v` -Expected: FAIL with `undefined: MintSessionToken` / `HashSessionToken` / `VerifyPassword`. - -- [ ] **Step 3: Implement** - -File: `internal/auth/session.go` - -```go -package auth - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/base64" - - "golang.org/x/crypto/bcrypt" -) - -// sessionTokenBytes is the raw entropy per session token. 32 bytes of -// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie -// value is 43 chars with no padding. -const sessionTokenBytes = 32 - -// MintSessionToken returns a freshly-generated, url-safe opaque token. -// The token is what the client carries; the DB only ever sees its sha256. -func MintSessionToken() (string, error) { - b := make([]byte, sessionTokenBytes) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -// HashSessionToken is the single source of truth for mapping a raw token to -// the `sessions.token_hash` column. sha256 is fine here — we're not guarding -// against offline brute force (the token has 256 bits of entropy); we only -// want "leaked DB row can't be replayed without also having the raw token." -func HashSessionToken(token string) []byte { - sum := sha256.Sum256([]byte(token)) - return sum[:] -} - -// VerifyPassword is the canonical bcrypt comparison. Returns false on a -// malformed hash so callers don't need to distinguish "hash invalid" from -// "password wrong" — both are auth failures from the client's perspective. -func VerifyPassword(hash, plaintext string) bool { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/auth/session.go internal/auth/session_test.go -git commit -m "$(cat <<'EOF' -feat(auth): session token + password verification helpers - -Shared primitives for /api/* auth: mint a url-safe opaque token, -hash it for storage, verify a bcrypt password hash. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 4: `RequireUser` middleware (cookie or bearer) - -**Files:** -- Modify: `internal/auth/session.go` — add `RequireUser`, `sessionCookieName`, extract helper -- Modify: `internal/auth/session_test.go` — add middleware tests - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/auth/session_test.go`: - -```go -import "net/http" -import "net/http/httptest" -import "context" -// (merge these imports into the existing import block) - -func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) { - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("handler must not be called") - }) - h := RequireUser(nil)(next) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want 401", w.Code) - } -} - -func TestExtractBearerToken(t *testing.T) { - cases := []struct { - header string - want string - }{ - {"", ""}, - {"Bearer abc", "abc"}, - {"bearer abc", "abc"}, - {"Token abc", ""}, - {"Bearer", ""}, - {"Bearer whitespace-token ", "whitespace-token"}, - } - for _, c := range cases { - got := extractBearerToken(c.header) - if got != c.want { - t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want) - } - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `go test ./internal/auth/ -run 'TestRequireUser|TestExtractBearerToken' -v` -Expected: FAIL (`undefined: RequireUser`, `undefined: extractBearerToken`). - -- [ ] **Step 3: Add `GetUserByID` sqlc query (needed by the middleware)** - -Append to `internal/db/queries/users.sql`: - -```sql --- name: GetUserByID :one -SELECT * FROM users WHERE id = $1; -``` - -Run: `make generate` -Expected: `internal/db/dbq/users.sql.go` now contains `GetUserByID(ctx, id pgtype.UUID)`. - -- [ ] **Step 4: Implement the middleware** - -Append to `internal/auth/session.go`: - -```go -import ( - "context" - "errors" - "log/slog" - "net/http" - "strings" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -// (merge these imports into the existing import block at the top of session.go) - -// SessionCookieName is the cookie the web SPA rides. Exported because handlers -// that issue/clear the cookie (handleLogin / handleLogout) need to match it. -const SessionCookieName = "minstrel_session" - -// RequireUser resolves the caller from a session cookie OR Authorization -// bearer header and puts the dbq.User in request context via userCtxKey. -// Requests without a valid session return 401 with no body so callers don't -// leak whether the username existed (matches the /rest/* auth posture). -func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := sessionTokenFromRequest(r) - if token == "" { - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - if pool == nil { - // Test-only path: the test at the top of this file constructs - // the middleware with nil pool to prove the no-token case - // short-circuits without a DB call. Any real token here is - // programmer error. - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - q := dbq.New(pool) - sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token)) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - slog.Error("api: session lookup failed", "err", err) - http.Error(w, "auth lookup failed", http.StatusInternalServerError) - return - } - user, err := q.GetUserByID(r.Context(), sess.UserID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // Session points at a deleted user — treat as unauth, - // best-effort cleanup. - _ = q.DeleteSession(r.Context(), sess.ID) - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - slog.Error("api: user lookup failed", "err", err) - http.Error(w, "auth lookup failed", http.StatusInternalServerError) - return - } - // Best-effort last-seen update. A failure here shouldn't fail the - // request; the session is still valid and this is observability. - if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil { - slog.Warn("api: touch session last_seen failed", "err", err) - } - ctx := context.WithValue(r.Context(), userCtxKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -func sessionTokenFromRequest(r *http.Request) string { - if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" { - return c.Value - } - return extractBearerToken(r.Header.Get("Authorization")) -} - -// extractBearerToken pulls the token out of an Authorization header in -// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is -// missing, malformed, or uses a different scheme. -func extractBearerToken(header string) string { - if header == "" { - return "" - } - const prefix = "bearer " - lower := strings.ToLower(header) - if !strings.HasPrefix(lower, prefix) { - return "" - } - return strings.TrimSpace(header[len(prefix):]) -} -``` - -- [ ] **Step 5: Run middleware tests** - -Run: `go test ./internal/auth/ -v` -Expected: all prior tests plus `TestRequireUser_RejectsWhenNoCookieOrBearer` and `TestExtractBearerToken` PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/auth/session.go internal/auth/session_test.go internal/db/queries/users.sql internal/db/dbq/users.sql.go -git commit -m "$(cat <<'EOF' -feat(auth): RequireUser middleware (cookie or bearer) - -Resolves /api/* callers from session cookie first, Authorization -bearer second. Touches last_seen on success for future active- -sessions UI. Adds GetUserByID query used by the middleware. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 5: `internal/api` package skeleton + error helper - -**Files:** -- Create: `internal/api/api.go` -- Create: `internal/api/errors.go` -- Create: `internal/api/errors_test.go` - -- [ ] **Step 1: Write the failing test** - -File: `internal/api/errors_test.go` - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestWriteErr_EmitsJSONEnvelope(t *testing.T) { - w := httptest.NewRecorder() - writeErr(w, http.StatusBadRequest, "bad_request", "field x required") - - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } - if got := w.Header().Get("Content-Type"); got != "application/json" { - t.Errorf("content-type = %q, want application/json", got) - } - var body struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v\nbody=%s", err, w.Body.String()) - } - if body.Error.Code != "bad_request" || body.Error.Message != "field x required" { - t.Errorf("body = %+v", body) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/api/ -v` -Expected: FAIL — package doesn't exist yet. - -- [ ] **Step 3: Create the package + error helper** - -File: `internal/api/api.go` - -```go -// Package api implements Minstrel's native JSON surface under /api. It is -// consumed by the built-in web SPA and (eventually) the Flutter client. -// Subsonic-compatible endpoints under /rest are intentionally separate — -// see internal/subsonic — and the two packages must not depend on each other. -package api - -import ( - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" -) - -// Mount attaches /api/* handlers to r. Public endpoints (login) are outside -// RequireUser; everything else is gated by the middleware. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) { - h := &handlers{pool: pool, logger: logger} - - r.Route("/api", func(api chi.Router) { - api.Post("/auth/login", h.handleLogin) - api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) - authed.Post("/auth/logout", h.handleLogout) - authed.Get("/me", h.handleGetMe) - }) - }) -} - -type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger -} - -// Stub handlers so Mount() compiles. Real implementations in later tasks -// replace these in place (Task 6 login, Task 7 logout, Task 8 me). -func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "login not wired") -} - -func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired") -} - -func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired") -} -``` - -**Reviewer note:** Real `handleLogin` / `handleLogout` / `handleGetMe` implementations in Tasks 6–8 *move* into their own files (`auth.go`, `me.go`) and the stubs here get deleted as part of each task. - -File: `internal/api/errors.go` - -```go -package api - -import ( - "encoding/json" - "net/http" -) - -// errorBody is the JSON envelope for failures on /api/*. Kept separate from -// the Subsonic envelope so native clients don't have to parse two shapes. -type errorBody struct { - Error errorPayload `json:"error"` -} - -type errorPayload struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// writeErr is the canonical way to emit a failure. Code is a stable -// short-string clients can switch on; message is human-readable. -func writeErr(w http.ResponseWriter, status int, code, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}}) -} -``` - -- [ ] **Step 4: Run tests and verify build** - -Run: `go build ./... && go test ./internal/api/ -v` -Expected: build exits 0; `TestWriteErr_EmitsJSONEnvelope` PASSes. - -- [ ] **Step 5: Commit** - -```bash -git add internal/api/api.go internal/api/errors.go internal/api/errors_test.go -git commit -m "$(cat <<'EOF' -feat(api): package skeleton + error envelope - -Introduces internal/api with Mount(), the {error:{code,message}} -response shape, and stubbed login/logout/me so later tasks can -land one handler at a time without breaking the build. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 6: `POST /api/auth/login` - -**Files:** -- Create: `internal/api/types.go` -- Modify: `internal/api/api.go` — replace login stub with real implementation -- Create: `internal/api/auth_test.go` - -- [ ] **Step 1: Write the failing test** - -File: `internal/api/auth_test.go` - -```go -package api - -import ( - "bytes" - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - "golang.org/x/crypto/bcrypt" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL. -// Skips in -short mode or when the env var is missing, matching the pattern -// used elsewhere (scanner_test.go, etc.). -func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { - t.Helper() - if testing.Short() { - t.Skip("skipping api integration in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if err := db.Migrate(dsn, logger); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return &handlers{pool: pool, logger: logger}, pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User { - t.Helper() - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) - if err != nil { - t.Fatalf("bcrypt: %v", err) - } - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: username, - PasswordHash: string(hash), - ApiToken: "test-api-token-" + username, - IsAdmin: isAdmin, - }) - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - return u -} - -func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) { - h, pool := testHandlers(t) - seedUser(t, pool, "alice", "hunter2", false) - - body := strings.NewReader(`{"username":"alice","password":"hunter2"}`) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.handleLogin(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - - var resp struct { - Token string `json:"token"` - User struct { - Username string `json:"username"` - IsAdmin bool `json:"is_admin"` - } `json:"user"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v\nbody=%s", err, w.Body.String()) - } - if resp.Token == "" { - t.Error("token empty") - } - if resp.User.Username != "alice" || resp.User.IsAdmin { - t.Errorf("user = %+v, want alice/non-admin", resp.User) - } - - var cookieFound bool - for _, c := range w.Result().Cookies() { - if c.Name == auth.SessionCookieName { - cookieFound = true - if !c.HttpOnly { - t.Error("session cookie missing HttpOnly") - } - if c.SameSite != http.SameSiteStrictMode { - t.Errorf("SameSite = %v, want Strict", c.SameSite) - } - if c.Value != resp.Token { - t.Error("cookie value does not match response token") - } - } - } - if !cookieFound { - t.Error("session cookie not set") - } -} - -func TestHandleLogin_WrongPasswordReturns401(t *testing.T) { - h, pool := testHandlers(t) - seedUser(t, pool, "alice", "hunter2", false) - - body := strings.NewReader(`{"username":"alice","password":"wrong"}`) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.handleLogin(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want 401", w.Code) - } -} - -func TestHandleLogin_UnknownUserReturns401(t *testing.T) { - h, _ := testHandlers(t) - body := strings.NewReader(`{"username":"ghost","password":"whatever"}`) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.handleLogin(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want 401", w.Code) - } -} - -func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", - bytes.NewReader([]byte("not-json"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.handleLogin(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin` - -Expected: tests run (integration DSN reachable) and FAIL with "not_implemented" responses. - -If your local stack uses a different DSN, set `MINSTREL_TEST_DATABASE_URL` accordingly. If you're developing without the stack up, skip this step — the CI pipeline runs it. - -- [ ] **Step 3: Define shared types** - -File: `internal/api/types.go` - -```go -package api - -import "github.com/jackc/pgx/v5/pgtype" - -// LoginRequest is the POST /api/auth/login body. Kept boring on purpose — -// web and Flutter both send the same payload. -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// LoginResponse carries the opaque session token AND the authenticated user -// so the SPA can hydrate its auth store in one round-trip. The token is also -// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer. -type LoginResponse struct { - Token string `json:"token"` - User UserView `json:"user"` -} - -// UserView is the /api/* view of a user. Narrower than dbq.User — no hash, -// no api_token, no subsonic_password. -type UserView struct { - ID pgtype.UUID `json:"id"` - Username string `json:"username"` - IsAdmin bool `json:"is_admin"` -} -``` - -- [ ] **Step 4: Implement `handleLogin`** - -File: `internal/api/auth.go` - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire -// server-side yet (future work); the cookie still caps browser-side lifetime -// so an abandoned laptop doesn't stay logged in forever. -const sessionCookieMaxAge = 30 * 24 * time.Hour - -func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") - return - } - if req.Username == "" || req.Password == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "username and password required") - return - } - - q := dbq.New(h.pool) - user, err := q.GetUserByUsername(r.Context(), req.Username) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") - return - } - h.logger.Error("api: user lookup failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if !auth.VerifyPassword(user.PasswordHash, req.Password) { - writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") - return - } - - token, err := auth.MintSessionToken() - if err != nil { - h.logger.Error("api: mint session token failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "mint failed") - return - } - if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{ - UserID: user.ID, - TokenHash: auth.HashSessionToken(token), - UserAgent: r.UserAgent(), - }); err != nil { - h.logger.Error("api: insert session failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - - http.SetCookie(w, &http.Cookie{ - Name: auth.SessionCookieName, - Value: token, - Path: "/", - HttpOnly: true, - Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure - SameSite: http.SameSiteStrictMode, - MaxAge: int(sessionCookieMaxAge.Seconds()), - }) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(LoginResponse{ - Token: token, - User: UserView{ - ID: user.ID, - Username: user.Username, - IsAdmin: user.IsAdmin, - }, - }) -} -``` - -Delete the `handleLogin` stub from `internal/api/api.go` — the real one in `auth.go` replaces it. - -- [ ] **Step 5: Run tests** - -Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin` - -Expected: all four `TestHandleLogin_*` tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/auth.go internal/api/api.go internal/api/types.go internal/api/auth_test.go -git commit -m "$(cat <<'EOF' -feat(api): POST /api/auth/login - -Verifies bcrypt password hash, mints a session token, stores its -sha256 in the sessions table, and returns the token in both the -response body (for Flutter) and an httpOnly/SameSite=Strict -cookie (for the web SPA). - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 7: `POST /api/auth/logout` - -**Files:** -- Modify: `internal/api/auth.go` — add `handleLogout` -- Modify: `internal/api/api.go` — remove logout stub -- Modify: `internal/api/auth_test.go` — add logout tests - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/api/auth_test.go`: - -```go -func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "hunter2", false) - - // Manually create a session to log out of. - token, err := auth.MintSessionToken() - if err != nil { - t.Fatalf("mint: %v", err) - } - if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{ - UserID: user.ID, - TokenHash: auth.HashSessionToken(token), - }); err != nil { - t.Fatalf("insert: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) - req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) - // handleLogout runs behind RequireUser in real routing; simulate that by - // putting the user into context here. - req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) - w := httptest.NewRecorder() - h.handleLogout(w, req) - - if w.Code != http.StatusNoContent { - t.Errorf("status = %d, want 204", w.Code) - } - - // Cookie should be cleared. - var cleared bool - for _, c := range w.Result().Cookies() { - if c.Name == auth.SessionCookieName && c.MaxAge < 0 { - cleared = true - } - } - if !cleared { - t.Error("session cookie not cleared") - } - - // Session row should be gone. - _, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token)) - if err == nil { - t.Error("session row still present after logout") - } -} -``` - -**Reviewer note:** `userCtxKeyForTest()` is a test-only shim that exposes `auth.userCtxKey` to the api package, needed because context keys are unexported. Added in the next step. - -- [ ] **Step 2: Expose a test-only context key accessor** - -Append to `internal/auth/session.go`: - -```go -// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need -// to inject a dbq.User into request context without going through the -// middleware. Do not use this outside _test.go files. -func UserCtxKeyForTest() any { return userCtxKey } -``` - -Append to `internal/api/auth_test.go` (top-level, after imports): - -```go -func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() } -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout` -Expected: FAIL (stub returns 501, not 204). - -- [ ] **Step 4: Implement `handleLogout`** - -Append to `internal/api/auth.go`: - -```go -func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { - // The session token can be on the cookie OR bearer header — RequireUser - // accepted either. Re-resolve it here so we can delete the row. - token := sessionTokenFromHTTP(r) - if token != "" { - if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil { - h.logger.Warn("api: delete session failed", "err", err) - // Continue — logout is best-effort; the client still gets the - // cookie cleared. - } - } - http.SetCookie(w, &http.Cookie{ - Name: auth.SessionCookieName, - Value: "", - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - MaxAge: -1, - }) - w.WriteHeader(http.StatusNoContent) -} - -// sessionTokenFromHTTP duplicates the internal helper in internal/auth -// because that one is unexported. Cheap to repeat here; keeping the auth -// package's internal helper package-private is worth more than DRY. -func sessionTokenFromHTTP(r *http.Request) string { - if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" { - return c.Value - } - h := r.Header.Get("Authorization") - const prefix = "bearer " - if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") { - return h[len(prefix):] - } - return "" -} -``` - -Delete the logout stub from `internal/api/api.go`. - -- [ ] **Step 5: Run tests** - -Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/auth.go internal/api/api.go internal/api/auth_test.go internal/auth/session.go -git commit -m "$(cat <<'EOF' -feat(api): POST /api/auth/logout - -Deletes the session row keyed by the cookie/bearer token and -clears the cookie on the client. Best-effort DB delete — logout -still succeeds for the client if the row's already gone. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 8: `GET /api/me` - -**Files:** -- Create: `internal/api/me.go` -- Modify: `internal/api/api.go` — remove `handleGetMe` stub -- Create: `internal/api/me_test.go` - -- [ ] **Step 1: Write the failing test** - -File: `internal/api/me_test.go` - -```go -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "hunter2", true) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) - w := httptest.NewRecorder() - h.handleGetMe(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - var got UserView - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Username != "alice" || !got.IsAdmin { - t.Errorf("user = %+v, want alice/admin", got) - } -} - -func TestHandleGetMe_MissingContextReturns500(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - w := httptest.NewRecorder() - h.handleGetMe(w, req) - - if w.Code != http.StatusInternalServerError { - t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code) - } - _ = dbq.User{} // keep the import used -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe` -Expected: FAIL (stub returns 501). - -- [ ] **Step 3: Implement `handleGetMe`** - -File: `internal/api/me.go` - -```go -package api - -import ( - "encoding/json" - "net/http" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" -) - -func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - // Hitting /me without RequireUser in front of it is a routing bug; - // it can't happen in real traffic. - h.logger.Error("api: /me reached without authenticated user") - writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context") - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(UserView{ - ID: user.ID, - Username: user.Username, - IsAdmin: user.IsAdmin, - }) -} -``` - -Delete the `handleGetMe` stub from `internal/api/api.go`. - -- [ ] **Step 4: Run tests** - -Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe` -Expected: PASS (both tests). - -- [ ] **Step 5: Run the full api package tests to confirm nothing regressed** - -Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v` -Expected: every test PASSes. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/me.go internal/api/me_test.go internal/api/api.go -git commit -m "$(cat <<'EOF' -feat(api): GET /api/me - -Returns the authenticated user (id/username/is_admin). Shape -matches UserView used in the login response so SPA stores stay -typed against one interface. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 9: Mount `/api/*` in the root router - -**Files:** -- Modify: `internal/server/server.go` - -- [ ] **Step 1: Update the router** - -Replace the existing `Router()` body at `internal/server/server.go:35-54`. The edit is: after the existing `r.Route("/api", ...)` admin block, call `api.Mount(r, s.Pool, s.Logger)` at the same level. But `api.Mount` *also* opens `/api`; chi allows multiple `Route("/api", ...)` calls — but to avoid confusion, we inline the admin routes into the api package structure in a single `Route("/api", ...)` is cleanest. - -Concretely — final shape: - -```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) - } - return r -} -``` - -Add the import for `internal/api`: - -```go -import ( - // existing imports... - "git.fabledsword.com/bvandeusen/minstrel/internal/api" -) -``` - -**Reviewer note:** chi permits both `r.Route("/api", func(api chi.Router) {...})` opened by `api.Mount` and a sibling `r.Route("/api/admin", func...)` because the paths don't overlap at the exact level — chi's internal tree merges prefixes. If you see "handler overlaps" at startup, convert `api.Mount` to accept an optional admin wiring callback and move scan into it; flag this up to the reviewer rather than hacking around it. - -- [ ] **Step 2: Build and verify the server still starts** - -Run: `go build ./...` -Expected: exit 0. - -Run (if stack running): `docker compose restart minstrel && docker compose logs minstrel --since=30s` -Expected: "listening on :4533" and no panics. - -- [ ] **Step 3: Commit** - -```bash -git add internal/server/server.go -git commit -m "$(cat <<'EOF' -feat(server): mount /api/* (login, logout, me) - -Wires the native JSON surface alongside the existing /api/admin -and /rest/* routes. Subsonic compatibility remains untouched. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -### Task 10: End-to-end smoke (manual, recorded in the plan) - -**Files:** none (verification only) - -- [ ] **Step 1: Bring the full stack up** - -```bash -docker compose up --build -d -docker compose logs minstrel --since=60s | grep -iE 'listening|error|panic' -``` - -Expected: "listening on :4533", no errors. - -- [ ] **Step 2: Login with the bootstrap admin** - -```bash -# Replace PASSWORD with the bootstrap password from startup logs. -curl -i -X POST http://localhost:4533/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"PASSWORD"}' -``` - -Expected: HTTP/1.1 200 OK; body contains `{"token":"...","user":{...}}`; `Set-Cookie: minstrel_session=...; HttpOnly; SameSite=Strict`. - -- [ ] **Step 3: Call `/api/me` with the cookie** - -```bash -curl -i http://localhost:4533/api/me \ - -H "Cookie: minstrel_session=" -``` - -Expected: HTTP/1.1 200 OK; body is `{"id":"...","username":"admin","is_admin":true}`. - -- [ ] **Step 4: Call `/api/me` with bearer instead** - -```bash -curl -i http://localhost:4533/api/me \ - -H "Authorization: Bearer " -``` - -Expected: HTTP/1.1 200 OK; same body. - -- [ ] **Step 5: Verify 401 without auth** - -```bash -curl -i http://localhost:4533/api/me -``` - -Expected: HTTP/1.1 401 Unauthorized. - -- [ ] **Step 6: Verify 401 with wrong password on login** - -```bash -curl -i -X POST http://localhost:4533/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"nope"}' -``` - -Expected: HTTP/1.1 401 Unauthorized; body `{"error":{"code":"invalid_credentials","message":"..."}}`. - -- [ ] **Step 7: Logout clears the session** - -```bash -curl -i -X POST http://localhost:4533/api/auth/logout \ - -H "Cookie: minstrel_session=" -``` - -Expected: HTTP/1.1 204 No Content; `Set-Cookie: minstrel_session=; Max-Age=0`. - -Then reuse that token against `/api/me` and expect 401. - -- [ ] **Step 8: Open the PR** - -After all smoke steps pass: - -```bash -git push origin dev -``` - -Then create a PR from `dev` → `main` using the Forgejo MCP (see git workflow memory). Poll CI; merge when green; `git pull --ff-only origin dev` locally. - -Done. - ---- - -## Self-Review - -**Spec coverage check** (against `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`): - -- Sessions table (spec §Authentication → Sessions table) — Task 1. -- sha256(token) at rest (spec §Authentication → Sessions table) — Task 3. -- Login issues both cookie and bearer token (spec §Authentication → Login flow) — Task 6. -- `httpOnly; SameSite=Strict` cookie (spec §Authentication → Login flow) — Task 6. -- Middleware resolves cookie → bearer (spec §Authentication → Middleware) — Task 4. -- `401` on unauthenticated `/api/*` (spec §Authentication → Middleware) — Task 4 + Task 10. -- `/api/me` returns `{id, username, is_admin}` (spec §API surface → first-cut endpoints) — Task 8. -- `/rest/*` untouched (spec §Non-goals and §API surface) — no task modifies `internal/subsonic`, no test imports it. -- Logout deletes session + clears cookie — spec §Authentication → Login flow mentions "expire via Set-Cookie" and sessions table Task 2 includes `DeleteSessionByTokenHash`. — Task 7. -- OIDC headroom (spec §Open questions resolved) — design preserved: login endpoint is the only auth-material touchpoint, so OIDC callback can mint the same cookie+token pair without schema change. - -**Not yet landed** (other plans): -- `/api/artists`, `/api/albums/{id}`, `/api/tracks/{id}`, `/api/search`, `/api/stream/{id}`, `/api/cover/{id}` — Plan 2 (server library reads). -- SvelteKit project, Dockerfile multi-stage, `embed.FS` wiring — Plan 3 (web SPA + packaging). - -**Placeholder scan:** No TBDs, TODOs, or "implement later" markers. Each step has the exact code or command. - -**Type consistency check:** -- `UserView` defined in `types.go` (Task 6), used in `LoginResponse` (Task 6), returned from `handleGetMe` (Task 8) — names match. -- `LoginRequest`/`LoginResponse` — defined once, referenced only by `handleLogin`. -- `SessionCookieName` exported from `internal/auth/session.go` (Task 4), referenced from `internal/api/auth.go` (Task 6), `internal/api/auth_test.go` (Task 6), `internal/api/me.go` (nope — `me.go` uses `UserFromContext`), and the manual curl in Task 10. Consistent. -- `HashSessionToken` / `MintSessionToken` / `VerifyPassword` — defined Task 3, used Tasks 4, 6, 7. Consistent. -- `sessionTokenFromHTTP` (api package, Task 7) vs `sessionTokenFromRequest` (auth package, Task 4) — separate helpers, documented as such. diff --git a/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md b/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md deleted file mode 100644 index 14a069f2..00000000 --- a/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md +++ /dev/null @@ -1,1066 +0,0 @@ -# Web UI Media Endpoints 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 `/api/albums/{id}/cover` and `/api/tracks/{id}/stream` so Plan 2's forward-reference `cover_url` and `stream_url` fields resolve in the SPA. - -**Architecture:** One new file `internal/api/media.go` holds both handlers plus locally-duplicated filesystem/MIME helpers — no shared package with `internal/subsonic`, per the subsonic-is-long-term-legacy direction. Byte-serving uses `http.ServeContent`, which handles Range / If-Modified-Since / ETag automatically. Auth is the existing `RequireUser` middleware (cookie or bearer token). - -**Tech Stack:** Go 1.23, chi/v5, pgx/v5, pgxpool, stdlib `net/http`, `os`, `path/filepath`. - -**Spec:** `docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md` - ---- - -## File Structure - -New files: - -| File | Responsibility | -|---|---| -| `internal/api/media.go` | Both handlers + helpers (`resolveAlbumCoverPath`, `findSidecarCover`, `audioContentType`, `imageContentType`) | -| `internal/api/media_test.go` | Unit tests for MIME helpers + integration tests for both endpoints | - -Modified files: - -| File | Change | -|---|---| -| `internal/api/library_fixtures_test.go` | Add `seedTrackWithFile` helper that materializes bytes on disk | -| `internal/api/library_test.go` | Extend `newLibraryRouter` (register `/cover` + `/stream`) and `TestRoutesRegisteredInMount` (assert production Mount wires both) | -| `internal/api/api.go` | Register both routes inside the existing `authed` chi group | - -No migrations. No new sqlc queries — `GetAlbumByID`, `GetTrackByID`, and `ListTracksByAlbum` already exist. - ---- - -## Working Conventions - -- **Commit after each green step.** One task = at least one commit. Multiple commits per task if convenient. -- **Integration tests require a live Postgres** at `MINSTREL_TEST_DATABASE_URL`. `docker-compose.yml` spins one up; tests skip cleanly when the env var is unset. Run the full suite with `MINSTREL_TEST_DATABASE_URL=postgres://… go test ./...` before committing. -- **Do NOT touch `internal/subsonic/*`**. If you notice the helpers here duplicate code in `internal/subsonic/stream.go`, that's intentional — see the memory note `project_subsonic_legacy.md`. -- **Go unused-import rule:** only add an import once the code actually uses it; the compiler rejects unused imports. -- **File-format and extension tables** in this plan are authoritative — they are the ones the spec approved, which differ slightly from subsonic's table. Do not "fix" them to match subsonic. - ---- - -## Task 1: Scaffold `media.go` with helpers + unit tests - -**Files:** -- Create: `internal/api/media.go` -- Create: `internal/api/media_test.go` -- Modify: `internal/api/library_fixtures_test.go` - -This task produces the four package-private helpers — no handlers yet. The handlers land in Tasks 2 and 3. Separating them keeps each commit's diff tight. - -- [ ] **Step 1: Create `internal/api/media.go` with helpers only** - -```go -// Package api — media endpoints serve raw bytes (cover art, audio streams). -// The helpers below duplicate shape with internal/subsonic/stream.go by -// design; the two packages are kept independent so subsonic can freeze as a -// compatibility surface while /api evolves. See project memory -// `project_subsonic_legacy.md` for the rationale. - -package api - -import ( - "context" - "os" - "path/filepath" - "strings" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// sidecarNames is the lookup order for cover art living next to audio files. -// Matches common library conventions: cover.* wins over folder.*; JPEG wins -// over PNG when both exist. -var sidecarNames = []string{ - "cover.jpg", "cover.jpeg", "cover.png", - "folder.jpg", "folder.jpeg", "folder.png", -} - -// findSidecarCover looks for a conventional cover image in the directory that -// contains trackPath. Returns "" if no sidecar exists. -func findSidecarCover(trackPath string) string { - dir := filepath.Dir(trackPath) - for _, name := range sidecarNames { - candidate := filepath.Join(dir, name) - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - } - return "" -} - -// resolveAlbumCoverPath returns the filesystem path to the album's cover art. -// It prefers an explicit cover_art_path (set by the scanner in a future -// milestone) and falls back to a sidecar next to the first track in the -// album's directory. "" means no art was found. -func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string { - if album.CoverArtPath != nil && *album.CoverArtPath != "" { - if _, err := os.Stat(*album.CoverArtPath); err == nil { - return *album.CoverArtPath - } - } - tracks, err := q.ListTracksByAlbum(ctx, album.ID) - if err != nil || len(tracks) == 0 { - return "" - } - return findSidecarCover(tracks[0].FilePath) -} - -// audioContentType maps the short file_format recorded on tracks (mp3, flac, -// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header. -// Unknown formats fall back to octet-stream so the browser downloads them -// rather than attempting to decode. -func audioContentType(format string) string { - switch strings.ToLower(format) { - case "mp3": - return "audio/mpeg" - case "flac": - return "audio/flac" - case "ogg", "opus": - return "audio/ogg" - case "m4a": - return "audio/mp4" - case "aac": - return "audio/aac" - case "wav": - return "audio/wav" - } - return "application/octet-stream" -} - -// imageContentType maps a file extension to a MIME type for cover art. -// Unknown extensions fall back to octet-stream. -func imageContentType(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - case ".webp": - return "image/webp" - case ".gif": - return "image/gif" - } - return "application/octet-stream" -} -``` - -- [ ] **Step 2: Append `seedTrackWithFile` to `internal/api/library_fixtures_test.go`** - -Inside the same `package api` file, add (alongside the existing helpers): - -```go -// seedTrackWithFile creates a fresh temp directory, writes fileBody to -// /.<ext>, and inserts a Track row whose file_path points at it. -// Returns the inserted Track and the directory (callers drop sidecar covers -// into this dir when a test needs them). FileFormat is ext lowercased. -func seedTrackWithFile(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, fileBody []byte, ext string) (dbq.Track, string) { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, title+"."+ext) - if err := os.WriteFile(path, fileBody, 0o644); err != nil { - t.Fatalf("WriteFile(%s): %v", path, err) - } - one := int32(1) - tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: albumID, - ArtistID: artistID, - TrackNumber: &one, - DiscNumber: nil, - DurationMs: int32(len(fileBody)), - FilePath: path, - FileSize: int64(len(fileBody)), - FileFormat: strings.ToLower(ext), - Bitrate: nil, - Mbid: nil, - Genre: nil, - }) - if err != nil { - t.Fatalf("UpsertTrack(%s): %v", title, err) - } - return tr, dir -} -``` - -Add these imports to `library_fixtures_test.go` (keep existing imports; add only the new ones): - -```go -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -``` - -(`os`, `path/filepath`, `strings` are the additions.) - -- [ ] **Step 3: Create `internal/api/media_test.go` with unit tests for the MIME helpers** - -```go -package api - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -func TestAudioContentType(t *testing.T) { - cases := map[string]string{ - "mp3": "audio/mpeg", - "MP3": "audio/mpeg", - "flac": "audio/flac", - "ogg": "audio/ogg", - "opus": "audio/ogg", - "m4a": "audio/mp4", - "aac": "audio/aac", - "wav": "audio/wav", - "unknown": "application/octet-stream", - "": "application/octet-stream", - } - for in, want := range cases { - if got := audioContentType(in); got != want { - t.Errorf("audioContentType(%q) = %q, want %q", in, got, want) - } - } -} - -func TestImageContentType(t *testing.T) { - cases := map[string]string{ - "/a/cover.jpg": "image/jpeg", - "/a/cover.JPG": "image/jpeg", - "/a/cover.jpeg": "image/jpeg", - "/a/cover.png": "image/png", - "/a/cover.webp": "image/webp", - "/a/cover.gif": "image/gif", - "/a/cover.bmp": "application/octet-stream", - "/a/cover": "application/octet-stream", - } - for in, want := range cases { - if got := imageContentType(in); got != want { - t.Errorf("imageContentType(%q) = %q, want %q", in, got, want) - } - } -} - -func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) { - dir := t.TempDir() - // Both present — cover.jpg wins. - if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil { - t.Fatal(err) - } - got := findSidecarCover(filepath.Join(dir, "any-track.flac")) - if got != filepath.Join(dir, "cover.jpg") { - t.Errorf("got %q, want cover.jpg path", got) - } -} - -func TestFindSidecarCover_FallsBackToFolder(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil { - t.Fatal(err) - } - got := findSidecarCover(filepath.Join(dir, "t.flac")) - if got != filepath.Join(dir, "folder.png") { - t.Errorf("got %q, want folder.png path", got) - } -} - -func TestFindSidecarCover_NoneFound(t *testing.T) { - dir := t.TempDir() - if got := findSidecarCover(filepath.Join(dir, "t.flac")); got != "" { - t.Errorf("got %q, want empty string", got) - } -} - -func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) { - _, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - - // Put an explicit cover file on disk, then write its path to album.cover_art_path. - dir := t.TempDir() - explicit := filepath.Join(dir, "explicit.jpg") - if err := os.WriteFile(explicit, []byte("e"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(context.Background(), - `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil { - t.Fatalf("UPDATE albums: %v", err) - } - // Refetch so album.CoverArtPath reflects the update. - got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) - if got != explicit { - t.Errorf("got %q, want %q", got, explicit) - } -} - -func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) { - _, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - - // Seed track first (helper owns the temp dir), then drop the sidecar in - // that same dir. Explicit cover_art_path points at a file we never create. - _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") - sidecar := filepath.Join(dir, "cover.jpg") - if err := os.WriteFile(sidecar, []byte("s"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(context.Background(), - `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, - filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil { - t.Fatalf("UPDATE albums: %v", err) - } - - got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) - if got != sidecar { - t.Errorf("got %q, want %q", got, sidecar) - } -} - -func TestResolveAlbumCoverPath_NoArt(t *testing.T) { - _, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic /seed path, no real file - got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album) - if got != "" { - t.Errorf("got %q, want empty string", got) - } -} - -// fetchAlbum reloads the album row so tests see updates made via raw SQL. -func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album { - t.Helper() - a, err := dbq.New(pool).GetAlbumByID(context.Background(), id) - if err != nil { - t.Fatalf("GetAlbumByID: %v", err) - } - return a -} -``` - -Add to the imports at top of `media_test.go` as they're used: - -```go -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -``` - -- [ ] **Step 4: Run the new tests; all must pass** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run 'TestAudioContentType|TestImageContentType|TestFindSidecarCover|TestResolveAlbumCoverPath' -v -``` - -Expected: all 7 tests PASS. - -- [ ] **Step 5: Run the full api test suite to catch regressions in shared fixtures** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/... -count=1 -``` - -Expected: all tests PASS (existing + new). - -- [ ] **Step 6: Commit** - -``` -git add internal/api/media.go internal/api/media_test.go internal/api/library_fixtures_test.go -git commit -m "feat(api): scaffold media helpers for cover + stream endpoints" -``` - ---- - -## Task 2: Implement `GET /api/albums/{id}/cover` - -**Files:** -- Modify: `internal/api/media.go` (add handler) -- Modify: `internal/api/media_test.go` (add integration tests) -- Modify: `internal/api/library_test.go` (register route in `newLibraryRouter`) - -TDD order — tests first, then handler. Tests fail first because the handler doesn't exist yet; they pass after it's added. - -- [ ] **Step 1: Register `/cover` in `newLibraryRouter`** - -Edit `internal/api/library_test.go:16-24` and add the cover route: - -```go -func newLibraryRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/artists", h.handleListArtists) - r.Get("/api/artists/{id}", h.handleGetArtist) - r.Get("/api/albums/{id}", h.handleGetAlbum) - r.Get("/api/albums/{id}/cover", h.handleGetCover) - r.Get("/api/tracks/{id}", h.handleGetTrack) - r.Get("/api/search", h.handleSearch) - return r -} -``` - -At this point the package won't compile — `handleGetCover` is undefined. That's expected; the integration tests need the route registered so they can fail with "handler missing" in the same compile unit as the test router. (Implementer hint: if the compile error bothers you, add the method body as `func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {}` as a temporary stub for Step 2. Remove the stub before Step 4.) - -- [ ] **Step 2: Append integration tests to `internal/api/media_test.go`** - -```go -func TestHandleGetCover_SidecarHappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - - _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") - coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload - if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { - t.Errorf("Content-Type = %q", ct) - } - if !bytes.Equal(w.Body.Bytes(), coverBytes) { - t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes) - } -} - -func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - - dir := t.TempDir() - explicit := filepath.Join(dir, "art.png") - pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic - if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(context.Background(), - `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil { - t.Fatal(err) - } - // No track needed — explicit path wins before we ever check the track dir. - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - if ct := w.Header().Get("Content-Type"); ct != "image/png" { - t.Errorf("Content-Type = %q", ct) - } - if !bytes.Equal(w.Body.Bytes(), pngBytes) { - t.Errorf("body mismatch") - } -} - -func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - - _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") - sidecarBytes := []byte("sidecar") - if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(context.Background(), - `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, - filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - if !bytes.Equal(w.Body.Bytes(), sidecarBytes) { - t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes) - } -} - -func TestHandleGetCover_NoArt(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar - - req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d", w.Code) - } - var body errorBody - _ = json.Unmarshal(w.Body.Bytes(), &body) - if body.Error.Code != "not_found" || body.Error.Message != "cover not found" { - t.Errorf("error body = %+v", body) - } -} - -func TestHandleGetCover_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleGetCover_UnknownAlbum(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - req := httptest.NewRequest(http.MethodGet, - "/api/albums/00000000-0000-0000-0000-000000000001/cover", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d", w.Code) - } - var body errorBody - _ = json.Unmarshal(w.Body.Bytes(), &body) - if body.Error.Message != "album not found" { - t.Errorf("message = %q, want \"album not found\"", body.Error.Message) - } -} -``` - -Update the imports at the top of `media_test.go` to add `"bytes"`, `"encoding/json"`, `"net/http"`, `"net/http/httptest"`: - -```go -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) -``` - -- [ ] **Step 3: Run the cover tests — expect FAIL (handler not implemented)** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestHandleGetCover -v -``` - -Expected: compile error OR 6 FAILS. - -- [ ] **Step 4: Append `handleGetCover` to `internal/api/media.go`** - -Add these imports to media.go (alongside existing): - -```go -"errors" -"net/http" - -"github.com/go-chi/chi/v5" -"github.com/jackc/pgx/v5" -``` - -Append the handler: - -```go -// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover -// path (explicit column, else sidecar next to first track), then delegates -// byte-serving to http.ServeContent so clients get Range / If-Modified-Since -// / ETag for free. -func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id") - return - } - q := dbq.New(h.pool) - album, err := q.GetAlbumByID(r.Context(), id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "album not found") - return - } - h.logger.Error("api: get album for cover failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "server error") - return - } - path := resolveAlbumCoverPath(r.Context(), q, album) - if path == "" { - writeErr(w, http.StatusNotFound, "not_found", "cover not found") - return - } - f, err := os.Open(path) - if err != nil { - // resolveAlbumCoverPath saw the file a moment ago; if it vanished - // between stat and open, treat it the same as no-art — the user - // experience (404) matches, and 500 would misleadingly imply a server - // bug rather than a filesystem race. - writeErr(w, http.StatusNotFound, "not_found", "cover not found") - return - } - defer func() { _ = f.Close() }() - info, err := f.Stat() - if err != nil { - h.logger.Error("api: stat cover failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "server error") - return - } - w.Header().Set("Content-Type", imageContentType(path)) - http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f) -} -``` - -- [ ] **Step 5: Run the cover tests — expect all PASS** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestHandleGetCover -v -``` - -Expected: 6 PASS. - -- [ ] **Step 6: Run the full api suite** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/... -count=1 -``` - -Expected: all PASS. - -- [ ] **Step 7: Commit** - -``` -git add internal/api/media.go internal/api/media_test.go internal/api/library_test.go -git commit -m "feat(api): GET /api/albums/{id}/cover with sidecar fallback" -``` - ---- - -## Task 3: Implement `GET /api/tracks/{id}/stream` - -**Files:** -- Modify: `internal/api/media.go` (add handler) -- Modify: `internal/api/media_test.go` (add tests) -- Modify: `internal/api/library_test.go` (register route in `newLibraryRouter`) - -Same TDD shape as Task 2. - -- [ ] **Step 1: Register `/stream` in `newLibraryRouter`** - -Edit `internal/api/library_test.go` so the helper also knows the stream route: - -```go -func newLibraryRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/artists", h.handleListArtists) - r.Get("/api/artists/{id}", h.handleGetArtist) - r.Get("/api/albums/{id}", h.handleGetAlbum) - r.Get("/api/albums/{id}/cover", h.handleGetCover) - r.Get("/api/tracks/{id}", h.handleGetTrack) - r.Get("/api/tracks/{id}/stream", h.handleGetStream) - r.Get("/api/search", h.handleSearch) - return r -} -``` - -(Same compile-time hint as Task 2 — add a stub if that compile error is in your way while writing the tests.) - -- [ ] **Step 2: Append integration tests to `internal/api/media_test.go`** - -```go -// deterministicBytes returns n bytes whose values cycle 0..255. Using a -// fixed pattern (not crypto/rand) makes assertions easy and the test -// reproducible. -func deterministicBytes(n int) []byte { - b := make([]byte, n) - for i := range b { - b[i] = byte(i % 256) - } - return b -} - -func TestHandleGetStream_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - body := deterministicBytes(32 * 1024) - track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") - - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) - } - if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" { - t.Errorf("Content-Type = %q", ct) - } - if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" { - t.Errorf("Accept-Ranges = %q", ar) - } - if cl := w.Header().Get("Content-Length"); cl != "32768" { - t.Errorf("Content-Length = %q, want 32768", cl) - } - if !bytes.Equal(w.Body.Bytes(), body) { - t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body)) - } -} - -func TestHandleGetStream_RangeFromStart(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - body := deterministicBytes(32 * 1024) - track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") - - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) - req.Header.Set("Range", "bytes=0-99") - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusPartialContent { - t.Fatalf("status = %d, want 206", w.Code) - } - if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" { - t.Errorf("Content-Range = %q", cr) - } - if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) { - t.Errorf("body mismatch: len=%d, want 100", len(got)) - } -} - -func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - body := deterministicBytes(32 * 1024) - track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") - - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) - req.Header.Set("Range", "bytes=32000-") - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusPartialContent { - t.Fatalf("status = %d", w.Code) - } - if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" { - t.Errorf("Content-Range = %q", cr) - } - if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) { - t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000) - } -} - -func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - body := deterministicBytes(1024) - track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") - - info, err := os.Stat(track.FilePath) - if err != nil { - t.Fatal(err) - } - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) - req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat)) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusNotModified { - t.Fatalf("status = %d, want 304", w.Code) - } - if got := w.Body.Len(); got != 0 { - t.Errorf("body len = %d, want 0 for 304", got) - } -} - -func TestHandleGetStream_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -func TestHandleGetStream_UnknownTrack(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - req := httptest.NewRequest(http.MethodGet, - "/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d", w.Code) - } - var body errorBody - _ = json.Unmarshal(w.Body.Bytes(), &body) - if body.Error.Message != "track not found" { - t.Errorf("message = %q, want \"track not found\"", body.Error.Message) - } -} - -func TestHandleGetStream_VanishedFile(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - artist := seedArtist(t, pool, "A") - album := seedAlbum(t, pool, artist.ID, "X", 0) - dir := t.TempDir() - // Insert a track whose file_path points somewhere we never create. - gone := filepath.Join(dir, "does-not-exist.mp3") - if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "gone", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3", - }); err != nil { - t.Fatal(err) - } - // Look up the just-inserted track so we can address it by id. - tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone) - if err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil) - w := httptest.NewRecorder() - newLibraryRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d", w.Code) - } - var body errorBody - _ = json.Unmarshal(w.Body.Bytes(), &body) - if body.Error.Message != "track file not found" { - t.Errorf("message = %q, want \"track file not found\"", body.Error.Message) - } -} -``` - -- [ ] **Step 3: Run the stream tests — expect FAIL (handler not implemented)** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestHandleGetStream -v -``` - -Expected: compile error OR 7 FAILS. - -- [ ] **Step 4: Append `handleGetStream` to `internal/api/media.go`** - -```go -// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on -// disk and delegates byte-serving to http.ServeContent, which handles Range, -// If-Modified-Since, and ETag based on the file's mod time. -func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - } - h.logger.Error("api: get track for stream failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "server error") - return - } - f, err := os.Open(track.FilePath) - if err != nil { - // File vanished (scanner indexed it, filesystem lost it). Treat as - // 404 so clients can fall back to the next item in a queue. - writeErr(w, http.StatusNotFound, "not_found", "track file not found") - return - } - defer func() { _ = f.Close() }() - info, err := f.Stat() - if err != nil { - h.logger.Error("api: stat track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "server error") - return - } - w.Header().Set("Content-Type", audioContentType(track.FileFormat)) - w.Header().Set("Accept-Ranges", "bytes") - http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f) -} -``` - -- [ ] **Step 5: Run the stream tests — expect all PASS** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestHandleGetStream -v -``` - -Expected: 7 PASS. - -- [ ] **Step 6: Run the full suite** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./... -count=1 -``` - -Expected: all PASS. - -- [ ] **Step 7: Commit** - -``` -git add internal/api/media.go internal/api/media_test.go internal/api/library_test.go -git commit -m "feat(api): GET /api/tracks/{id}/stream with Range support" -``` - ---- - -## Task 4: Wire routes into production `Mount` + route-registration regression test - -**Files:** -- Modify: `internal/api/api.go` -- Modify: `internal/api/library_test.go` - -- [ ] **Step 1: Extend the existing `TestRoutesRegisteredInMount` paths list** - -Edit `internal/api/library_test.go:436-441` (the `paths := []string{...}` slice inside `TestRoutesRegisteredInMount`) so it becomes: - -```go -paths := []string{ - "/api/artists", - "/api/artists/00000000-0000-0000-0000-000000000001", - "/api/albums/00000000-0000-0000-0000-000000000001", - "/api/albums/00000000-0000-0000-0000-000000000001/cover", - "/api/tracks/00000000-0000-0000-0000-000000000001", - "/api/tracks/00000000-0000-0000-0000-000000000001/stream", - "/api/search?q=x", -} -``` - -- [ ] **Step 2: Run the regression test — expect FAIL (routes not in Mount)** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestRoutesRegisteredInMount -v -``` - -Expected: FAIL — the two new paths return 404 because `Mount` doesn't wire them yet. - -- [ ] **Step 3: Register both routes in production `Mount`** - -Edit `internal/api/api.go:23-34` (the authed chi.Group body) to add the two new routes. The full authed block becomes: - -```go -api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) - authed.Post("/auth/logout", h.handleLogout) - authed.Get("/me", h.handleGetMe) - - authed.Get("/artists", h.handleListArtists) - authed.Get("/artists/{id}", h.handleGetArtist) - authed.Get("/albums/{id}", h.handleGetAlbum) - authed.Get("/albums/{id}/cover", h.handleGetCover) - authed.Get("/tracks/{id}", h.handleGetTrack) - authed.Get("/tracks/{id}/stream", h.handleGetStream) - authed.Get("/search", h.handleSearch) -}) -``` - -- [ ] **Step 4: Run the regression test — expect PASS** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./internal/api/ -run TestRoutesRegisteredInMount -v -``` - -Expected: PASS — both new paths now return 401 (auth required), not 404. - -- [ ] **Step 5: Run the full suite** - -``` -MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \ - go test ./... -count=1 -``` - -Expected: all PASS. - -- [ ] **Step 6: Commit** - -``` -git add internal/api/api.go internal/api/library_test.go -git commit -m "feat(api): register cover + stream routes under RequireUser" -``` - ---- - -## Post-merge smoke checklist - -After the PR merges, run this sanity pass against a live build to confirm end-to-end behavior — unit and integration tests cover correctness, but browsers reveal surprises. - -- Log in through the SPA and hit `GET /api/albums/<id>/cover` for an album with a sidecar — expect the image bytes and `Content-Type: image/jpeg`. -- `GET /api/albums/<id>/cover` for an album with no art — expect a JSON 404 body. -- `GET /api/tracks/<id>/stream` in a browser `<audio>` tag — expect playback and seekable timeline (Range support). -- `curl -I -H 'Range: bytes=0-99'` against a stream — expect `206 Partial Content` and `Content-Range: bytes 0-99/<size>`. -- `curl` a second time with `If-Modified-Since` set to the first response's `Last-Modified` — expect `304 Not Modified`. diff --git a/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md b/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md deleted file mode 100644 index 5171952d..00000000 --- a/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md +++ /dev/null @@ -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 - %sveltekit.head% - - -
%sveltekit.body%
- - -``` - -- [ ] **Step 8: Create `web/src/app.css`** - -```css -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - color-scheme: dark; -} - -html, -body { - height: 100%; - margin: 0; -} -``` - -- [ ] **Step 9: Create `web/src/routes/+layout.svelte`** - -```svelte - - - -``` - -- [ ] **Step 10: Create `web/src/routes/+page.svelte`** - -```svelte -
-
-

Minstrel

-

- Scaffold — UI features land in subsequent plans. -

-
-
-``` - -- [ ] **Step 11: Create `web/.gitignore`** - -``` -node_modules/ -.svelte-kit/ -build/* -!build/index.html -``` - -- [ ] **Step 12: Create placeholder `web/static/favicon.png`** - -Run: -```bash -mkdir -p web/static -python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png -``` - -Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes). - -- [ ] **Step 13: Create placeholder `web/build/index.html`** - -This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed. - -```html - - - - - Minstrel - - -

- Minstrel SPA has not been built. Run - cd web && npm install && npm run build or - build the container image, which runs the web build during - docker build. -

- - -``` - -- [ ] **Step 14: Install dependencies** - -Run: `cd web && npm install` -Expected: `node_modules/` populated, `package-lock.json` written, exit 0. - -- [ ] **Step 15: Verify SvelteKit sync + build** - -Run: `cd web && npm run build` -Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.) - -Undo the local overwrite so the committed placeholder remains: - -Run: `git checkout -- web/build/index.html` -Expected: placeholder restored. - -- [ ] **Step 16: Commit** - -```bash -git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \ - web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \ - web/src/ web/static/ web/build/index.html -git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static - -- SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5 -- adapter-static with fallback:'index.html' for SPA deep-link routing -- Tailwind configured with dark-only palette matching spec -- Placeholder landing page at / -- Committed web/build/index.html placeholder (via .gitignore negation) so - go:embed works before the SvelteKit build has been run" -``` - ---- - -## Task 2: Vitest smoke test - -**Files:** -- Create: `web/vitest.config.ts` -- Create: `web/src/lib/example.test.ts` - -- [ ] **Step 1: Create `web/vitest.config.ts`** - -```ts -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [sveltekit()], - test: { - environment: 'jsdom', - include: ['src/**/*.test.ts'] - } -}); -``` - -- [ ] **Step 2: Write failing smoke test** - -Create `web/src/lib/example.test.ts`: - -```ts -import { describe, expect, it } from 'vitest'; - -describe('scaffold smoke', () => { - it('runs vitest', () => { - expect(1 + 1).toBe(2); - }); -}); -``` - -- [ ] **Step 3: Run tests** - -Run: `cd web && npm test` -Expected: 1 test passes. Exit 0. - -- [ ] **Step 4: Commit** - -```bash -git add web/vitest.config.ts web/src/lib/example.test.ts -git commit -m "test(web): add Vitest smoke test and jsdom config" -``` - ---- - -## Task 3: Go embed package + SPA fallback handler - -**Files:** -- Create: `web/embed.go` -- Create: `web/embed_test.go` - -- [ ] **Step 1: Write failing tests** - -Create `web/embed_test.go`: - -```go -package web - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestHandler_ServesIndexAtRoot(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - Handler().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q, want text/html*", ct) - } - body, _ := io.ReadAll(rec.Body) - if !strings.Contains(strings.ToLower(string(body)), " build/index.html -// - GET / -> that file, via http.FileServer -// - GET / -> build/index.html (SPA fallback) -// -// Callers are expected to register this as the router's NotFound/catch-all so -// explicitly-routed paths (like /api/* and /rest/*) take precedence. -func Handler() http.Handler { - sub, err := fs.Sub(buildFS, "build") - if err != nil { - // fs.Sub on a valid embed path can only fail if the embed directive - // is malformed, which the compile would have caught. - panic("web: fs.Sub(build) failed: " + err.Error()) - } - - index, err := fs.ReadFile(sub, "index.html") - if err != nil { - panic("web: embedded build/index.html missing: " + err.Error()) - } - - fileServer := http.FileServer(http.FS(sub)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - clean := path.Clean(r.URL.Path) - if clean == "/" || clean == "." { - serveIndex(w, index) - return - } - name := strings.TrimPrefix(clean, "/") - if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() { - fileServer.ServeHTTP(w, r) - return - } - serveIndex(w, index) - }) -} - -func serveIndex(w http.ResponseWriter, index []byte) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - _, _ = w.Write(index) -} -``` - -- [ ] **Step 4: Run tests — expect PASS** - -Run: `go test ./web/ -v` -Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add web/ -git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler" -``` - ---- - -## Task 4: Wire web handler into server router - -**Files:** -- Modify: `internal/server/server.go` -- Modify: `internal/server/server_test.go` - -- [ ] **Step 1: Write failing tests** - -Add to `internal/server/server_test.go` (also add `"strings"` to the imports): - -```go -func TestRouter_ServesSPAAtRoot(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) - ts := httptest.NewServer(s.Router()) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/") - if err != nil { - t.Fatalf("GET /: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q, want text/html*", ct) - } -} - -func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) - ts := httptest.NewServer(s.Router()) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001") - if err != nil { - t.Fatalf("GET deep link: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q, want text/html*", ct) - } -} - -func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) - ts := httptest.NewServer(s.Router()) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/api/does-not-exist") - if err != nil { - t.Fatalf("GET /api/does-not-exist: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("status = %d, want 404", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q; /api/* must never return text/html", ct) - } -} - -func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) - ts := httptest.NewServer(s.Router()) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/rest/does-not-exist") - if err != nil { - t.Fatalf("GET /rest/does-not-exist: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("status = %d, want 404", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct) - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/server/ -v` -Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes). - -- [ ] **Step 3: Wire `web.Handler()` into `Router()`** - -Modify `internal/server/server.go`: - -a) Add imports (preserving alphabetical order within the group): - -```go -import ( - "context" - "encoding/json" - "log/slog" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/api" - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" - "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" - "git.fabledsword.com/bvandeusen/minstrel/web" -) -``` - -b) Replace the `Router()` method with: - -```go -func (s *Server) Router() http.Handler { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.Recoverer) - - r.Get("/healthz", s.handleHealthz) - - if s.Pool != nil { - api.Mount(r, s.Pool, s.Logger) - r.Route("/api/admin", func(admin chi.Router) { - admin.Use(auth.RequireAdmin(s.Pool)) - if s.Scanner != nil { - admin.Post("/scan", s.handleAdminScan) - } - }) - subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg) - } - - spa := web.Handler() - r.NotFound(func(w http.ResponseWriter, req *http.Request) { - p := req.URL.Path - if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`)) - return - } - spa.ServeHTTP(w, req) - }) - return r -} -``` - -- [ ] **Step 4: Run tests — expect PASS** - -Run: `go test ./internal/server/ -v` -Expected: all tests PASS, including the existing `TestHealthz` and the four new ones. - -- [ ] **Step 5: Run full test suite** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 6: Run linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 7: Commit** - -```bash -git add internal/server/ -git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out - -- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404 -- Everything else falls through to the embedded web handler, which - resolves static assets or returns index.html for SPA deep links" -``` - ---- - -## Task 5: Multi-stage Dockerfile - -**Files:** -- Rewrite: `Dockerfile` - -- [ ] **Step 1: Replace `Dockerfile` contents** - -```dockerfile -# syntax=docker/dockerfile:1.6 - -FROM node:22-bookworm-slim AS web -WORKDIR /web -COPY web/package.json web/package-lock.json ./ -RUN npm ci -COPY web/ ./ -RUN npm run build - -FROM golang:1.23-bookworm AS builder -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -# Overwrite the committed placeholder with the freshly-built SPA assets. -COPY --from=web /web/build ./web/build -ENV CGO_ENABLED=0 -RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel - -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ - && rm -rf /var/lib/apt/lists/* - -RUN groupadd --system --gid 1000 minstrel \ - && useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel - -COPY --from=builder /out/minstrel /usr/local/bin/minstrel -COPY config.example.yaml /etc/smartmusic/config.yaml - -USER minstrel -EXPOSE 4533 -ENTRYPOINT ["/usr/local/bin/minstrel"] -CMD ["--config", "/etc/smartmusic/config.yaml"] -``` - -- [ ] **Step 2: Build image locally** - -Run: `docker build -t minstrel:scaffold-smoke .` -Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`. - -- [ ] **Step 3: Inspect image for SPA assets** - -Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: output `ok` (verifies the binary was copied in and is executable). - -- [ ] **Step 4: Commit** - -```bash -git add Dockerfile -git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA - -Web assets are built in the node stage, copied into the go stage before -go build so //go:embed picks them up, then the minimal slim runtime -carries only the final binary and ffmpeg." -``` - ---- - -## Task 6: CI — install Node and build web before Go - -**Files:** -- Modify: `.forgejo/workflows/test.yml` - -- [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents** - -```yaml -name: test - -# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run -# locally via docker-compose; they should guard with testing.Short(). - -on: - push: - branches: [dev] - pull_request: - branches: [main] - -jobs: - test: - runs-on: go-ci - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - cache-dependency-path: web/package-lock.json - - - name: Install web deps - working-directory: web - run: npm ci - - - name: Web build (populates web/build/ for go:embed) - working-directory: web - run: npm run build - - - name: Web test (vitest) - working-directory: web - run: npm test - - - name: Detect Go project - id: gomod - shell: bash - run: | - if [ -f go.mod ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands" - fi - - - name: Toolchain versions - if: steps.gomod.outputs.present == 'true' - run: | - go version - golangci-lint --version - - - name: go vet - if: steps.gomod.outputs.present == 'true' - run: go vet ./... - - - name: golangci-lint - if: steps.gomod.outputs.present == 'true' - run: golangci-lint run ./... - - - name: go test (short, race) - if: steps.gomod.outputs.present == 'true' - run: go test -short -race ./... -``` - -- [ ] **Step 2: Commit** - -```bash -git add .forgejo/workflows/test.yml -git commit -m "ci: install Node + build web before Go steps - -Go's //go:embed needs web/build/ to exist at compile time. CI now -runs 'npm ci && npm run build && npm test' ahead of the Go steps -so the embed directive sees real, freshly-built assets." -``` - ---- - -## Task 7: README dev-workflow section - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Read current README** - -Run: `cat README.md` -Note the existing structure (headings, sections). - -- [ ] **Step 2: Append or replace a "Development" section** - -Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file. - -```markdown -## Development - -Minstrel has two concurrent dev processes: - -1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`. -2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. - -The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`, -so session cookies work correctly when the SPA is loaded from the Vite origin. - -### Production build - -`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, -copies the output into the `golang` stage, and `//go:embed`s it into the -final binary. The resulting container serves the SPA from `/` alongside the -API surfaces. No separate static-file server is required. -``` - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "docs: document two-process dev workflow with Vite proxy" -``` - ---- - -## Task 8: Final verification - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 3: Web tests + build** - -Run: `cd web && npm test && npm run build` -Expected: PASS. Verify `web/build/index.html` was regenerated; then: - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored (the fresh build output stays out of version control). - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'` -Expected: image builds; size printed (bytes). - -- [ ] **Step 5: Local end-to-end** - -Bring up the stack: -```bash -docker compose up --build -d -``` - -Then in another terminal: -```bash -curl -sI http://localhost:4533/ -curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001 -curl -sI http://localhost:4533/healthz -curl -sI http://localhost:4533/api/nonexistent -``` - -Expected responses: -- `GET /` → `200 OK`, `Content-Type: text/html; charset=utf-8`. -- `GET /artists/…` → `200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback). -- `GET /healthz` → `200 OK`, `Content-Type: application/json`. -- `GET /api/nonexistent` → `401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. diff --git a/docs/superpowers/plans/2026-04-22-web-ui-auth.md b/docs/superpowers/plans/2026-04-22-web-ui-auth.md deleted file mode 100644 index 1375969b..00000000 --- a/docs/superpowers/plans/2026-04-22-web-ui-auth.md +++ /dev/null @@ -1,1245 +0,0 @@ -# Web UI Auth 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 the browser-side auth flow — login page, session store, persistent Shell, and typed fetch wrapper — on top of the existing SvelteKit scaffold, so every subsequent frontend plan can assume a known-authenticated user and use `api.get(path)` out of the box. - -**Architecture:** Three layers under SvelteKit. `lib/api/client.ts` is a typed wrapper around `fetch` that parses JSON, throws typed `ApiError`, and silently logs the user out on 401. `lib/auth/store.svelte.ts` is a Svelte 5 rune-based store holding the current user, bootstrapped once from `/api/me` in the root `+layout.ts`. `lib/query/client.ts` is a TanStack Query `QueryClient` installed via `QueryClientProvider` at the root layout. Route guarding and shell rendering happen in `+layout.svelte`. - -**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library (`@testing-library/svelte`, `@testing-library/jest-dom`), Tailwind. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-22-web-ui-auth-design.md`. - ---- - -## File Structure - -**New files under `web/`:** - -| File | Responsibility | -|---|---| -| `src/lib/api/client.ts` | `apiFetch`, `api.{get,post,del}`, types `User`, `LoginResponse`, `ApiError` | -| `src/lib/api/client.test.ts` | Unit tests for the transport layer | -| `src/lib/auth/store.svelte.ts` | `$user` rune, `bootstrap`, `login`, `logout` | -| `src/lib/auth/store.test.ts` | Unit tests for auth store | -| `src/lib/query/client.ts` | Singleton `QueryClient` export | -| `src/lib/components/Shell.svelte` | Header + sidebar + main slot | -| `src/lib/components/Shell.test.ts` | Shell component tests | -| `src/routes/+layout.ts` | `load()` → `await bootstrap()` | -| `src/routes/login/+page.svelte` | Login form UI + behavior | -| `src/routes/login/login.test.ts` | Login page tests (note: NOT `+page.test.ts` — the `+` prefix would trigger SvelteKit's route walker) | -| `src/routes/search/+page.svelte` | "Coming soon" placeholder | -| `src/routes/playlists/+page.svelte` | "Coming soon" placeholder | -| `vitest.setup.ts` | jest-dom matcher registration | - -**Modified files:** - -| File | Change | -|---|---| -| `web/package.json` | Add `@tanstack/svelte-query` (runtime) + `@testing-library/svelte`, `@testing-library/jest-dom` (dev) | -| `web/vitest.config.ts` | `setupFiles: ['./vitest.setup.ts']` | -| `web/src/routes/+layout.svelte` | Mount `QueryClientProvider`, run route guard, render `` or bare `` | -| `web/src/routes/+page.svelte` | Replace scaffold text with a minimal "Library" placeholder | - ---- - -## Task 1: Add dependencies + jest-dom setup - -**Files:** -- Modify: `web/package.json` -- Modify: `web/vitest.config.ts` -- Create: `web/vitest.setup.ts` - -- [ ] **Step 1: Install packages** - -Run: -```bash -cd web && npm install --save @tanstack/svelte-query@^5 && npm install --save-dev @testing-library/svelte@^5 @testing-library/jest-dom@^6 -``` - -Expected: three packages added to `package.json`; `package-lock.json` updated. No errors. - -- [ ] **Step 2: Create `web/vitest.setup.ts`** - -```ts -import '@testing-library/jest-dom/vitest'; -``` - -- [ ] **Step 3: Update `web/vitest.config.ts`** - -Replace the whole file with: - -```ts -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [sveltekit()], - test: { - environment: 'jsdom', - include: ['src/**/*.test.ts'], - setupFiles: ['./vitest.setup.ts'] - } -}); -``` - -- [ ] **Step 4: Sanity-check the existing test still passes** - -Run: `cd web && npm test` -Expected: PASS — `src/lib/example.test.ts (1 test)` passes. The new setup file doesn't break anything. - -- [ ] **Step 5: Commit** - -```bash -git add web/package.json web/package-lock.json web/vitest.config.ts web/vitest.setup.ts -git commit -m "build(web): add TanStack Query + Testing Library deps - -Wires @tanstack/svelte-query for the data layer and -@testing-library/{svelte,jest-dom} for component-level tests. Registers -the jest-dom matchers via a new vitest setup file." -``` - ---- - -## Task 2: Types and the `ApiError` shape - -**Files:** -- Create: `web/src/lib/api/client.ts` (types only so far) - -- [ ] **Step 1: Write `web/src/lib/api/client.ts` with types only** - -```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; -}; -``` - -- [ ] **Step 2: Verify tsc is happy** - -Run: `cd web && npm run check` -Expected: `svelte-check found 0 errors and 0 warnings`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/api/client.ts -git commit -m "feat(web): add api client types (User, LoginResponse, ApiError)" -``` - ---- - -## Task 3: `apiFetch` happy path + error envelope - -**Files:** -- Modify: `web/src/lib/api/client.ts` -- Create: `web/src/lib/api/client.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/lib/api/client.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { apiFetch } from './client'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function stubFetch(status: number, body: unknown, init: Partial = {}) { - const res = new Response( - body === null ? null : JSON.stringify(body), - { status, headers: { 'Content-Type': 'application/json' }, ...init } - ); - const spy = vi.fn().mockResolvedValue(res); - vi.stubGlobal('fetch', spy); - return spy; -} - -describe('apiFetch', () => { - test('resolves with parsed JSON on 200', async () => { - stubFetch(200, { hello: 'world' }); - const result = await apiFetch('/api/ping'); - expect(result).toEqual({ hello: 'world' }); - }); - - test('sends Content-Type application/json by default', async () => { - const spy = stubFetch(200, {}); - await apiFetch('/api/ping'); - const init = spy.mock.calls[0][1] as RequestInit; - expect((init.headers as Record)['Content-Type']).toBe('application/json'); - }); - - test('resolves to null on 204', async () => { - stubFetch(204, null); - const result = await apiFetch('/api/ping', { method: 'DELETE' }); - expect(result).toBeNull(); - }); - - test('throws ApiError from error envelope on 4xx', async () => { - stubFetch(404, { error: { code: 'not_found', message: 'track not found' } }); - await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ - code: 'not_found', - message: 'track not found', - status: 404 - }); - }); - - test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - new Response('500', { status: 500, statusText: 'Internal Server Error' }) - )); - await expect(apiFetch('/api/ping')).rejects.toMatchObject({ - code: 'unknown', - message: 'Internal Server Error', - status: 500 - }); - }); -}); -``` - -- [ ] **Step 2: Run the test, confirm it fails** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: FAIL — `apiFetch` not exported from `./client`. - -- [ ] **Step 3: Implement `apiFetch`** - -Append to `web/src/lib/api/client.ts`: - -```ts -export async function apiFetch(path: string, init?: RequestInit): Promise { - 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) { - const envelope = body && (body as { error?: { code?: string; message?: string } }).error; - const err: ApiError = { - code: envelope?.code ?? 'unknown', - message: envelope?.message ?? res.statusText, - status: res.status - }; - throw err; - } - return body; -} -``` - -- [ ] **Step 4: Run the test again, confirm it passes** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts -git commit -m "feat(web): add apiFetch with typed error envelope - -Wraps fetch, parses JSON, and throws ApiError{code,message,status} -on non-2xx. Degrades to {code:'unknown'} when the error body is not -the expected envelope shape." -``` - ---- - -## Task 4: `api.get` / `api.post` / `api.del` facade - -**Files:** -- Modify: `web/src/lib/api/client.ts` -- Modify: `web/src/lib/api/client.test.ts` - -- [ ] **Step 1: Add failing tests** - -Append inside `web/src/lib/api/client.test.ts`: - -```ts -import { api } from './client'; - -describe('api.get/post/del', () => { - test('api.get returns typed JSON on 200', async () => { - stubFetch(200, { id: 'abc', name: 'A' }); - type T = { id: string; name: string }; - const result = await api.get('/api/artists/abc'); - expect(result).toEqual({ id: 'abc', name: 'A' }); - }); - - test('api.post serializes body and sets method', async () => { - const spy = stubFetch(200, { ok: true }); - await api.post('/api/auth/login', { username: 'u', password: 'p' }); - const init = spy.mock.calls[0][1] as RequestInit; - expect(init.method).toBe('POST'); - expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' })); - }); - - test('api.del sends DELETE and resolves to null on 204', async () => { - const spy = stubFetch(204, null); - const result = await api.del('/api/sessions/xyz'); - expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); - expect(result).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: FAIL — `api` not exported. - -- [ ] **Step 3: Implement the facade** - -Append to `web/src/lib/api/client.ts`: - -```ts -export const api = { - get: (path: string): Promise => apiFetch(path) as Promise, - post: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, - del: (path: string): Promise => - apiFetch(path, { method: 'DELETE' }) as Promise -}; -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: PASS — 8 tests total (5 from Task 3 + 3 new). - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts -git commit -m "feat(web): add api.{get,post,del} typed facade over apiFetch" -``` - ---- - -## Task 5: `QueryClient` singleton - -**Files:** -- Create: `web/src/lib/query/client.ts` - -- [ ] **Step 1: Write the module** - -Create `web/src/lib/query/client.ts`: - -```ts -import { QueryClient } from '@tanstack/svelte-query'; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 30_000, - refetchOnWindowFocus: false - } - } -}); -``` - -- [ ] **Step 2: Verify it type-checks** - -Run: `cd web && npm run check` -Expected: `svelte-check found 0 errors and 0 warnings`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/query/client.ts -git commit -m "feat(web): add TanStack Query client singleton - -Tuned defaults: retry disabled (we throw typed errors), 30s staleTime -to make tab-switch re-navigation feel instant, no refetchOnWindowFocus -(users on a music app don't expect spurious network when they tab back)." -``` - ---- - -## Task 6: `auth` store — `bootstrap`, `login`, `logout` - -**Files:** -- Create: `web/src/lib/auth/store.svelte.ts` -- Create: `web/src/lib/auth/store.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/auth/store.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -vi.mock('$lib/api/client', () => ({ - api: { - get: vi.fn(), - post: vi.fn(), - del: vi.fn() - } -})); - -vi.mock('$lib/query/client', () => ({ - queryClient: { clear: vi.fn() } -})); - -import { api } from '$lib/api/client'; -import { queryClient } from '$lib/query/client'; -import { bootstrap, login, logout, user } from './store.svelte'; - -beforeEach(() => { - vi.resetAllMocks(); -}); - -afterEach(() => { - // Force-clear the store between tests by stubbing api.get to reject, - // then calling bootstrap. -}); - -describe('auth store', () => { - test('bootstrap populates user on 200', async () => { - (api.get as ReturnType).mockResolvedValue({ - id: '1', username: 'alice', is_admin: false - }); - await bootstrap(); - expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false }); - }); - - test('bootstrap leaves user null on failure', async () => { - (api.get as ReturnType).mockRejectedValue( - { code: 'unauthorized', message: 'no session', status: 401 } - ); - await bootstrap(); - expect(user.value).toBeNull(); - }); - - test('login sets user from LoginResponse.user', async () => { - (api.post as ReturnType).mockResolvedValue({ - token: 't', user: { id: '2', username: 'bob', is_admin: true } - }); - await login('bob', 'pw'); - expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true }); - expect(api.post).toHaveBeenCalledWith('/api/auth/login', { - username: 'bob', password: 'pw' - }); - }); - - test('logout clears user, calls /api/auth/logout, and clears query cache', async () => { - (api.post as ReturnType).mockResolvedValue(null); - await logout(); - expect(user.value).toBeNull(); - expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {}); - expect(queryClient.clear).toHaveBeenCalledTimes(1); - }); - - test('logout with silent:true skips the POST but still clears state', async () => { - await logout({ silent: true }); - expect(user.value).toBeNull(); - expect(api.post).not.toHaveBeenCalled(); - expect(queryClient.clear).toHaveBeenCalledTimes(1); - }); - - test('logout swallows POST errors (best-effort)', async () => { - (api.post as ReturnType).mockRejectedValue( - { code: 'server_error', message: 'boom', status: 500 } - ); - await expect(logout()).resolves.toBeUndefined(); - expect(user.value).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/auth/store.test.ts` -Expected: FAIL — module `./store.svelte` does not exist. - -- [ ] **Step 3: Implement the store** - -Create `web/src/lib/auth/store.svelte.ts`: - -```ts -import { api, type User, type LoginResponse } from '$lib/api/client'; -import { queryClient } from '$lib/query/client'; - -let _user = $state(null); - -export const user = { - get value(): User | null { - return _user; - } -}; - -export async function bootstrap(): Promise { - try { - _user = await api.get('/api/me'); - } catch { - _user = null; - } -} - -export async function login(username: string, password: string): Promise { - const res = await api.post('/api/auth/login', { username, password }); - _user = res.user; -} - -export async function logout(opts: { silent?: boolean } = {}): Promise { - if (!opts.silent) { - try { - await api.post('/api/auth/logout', {}); - } catch { - // best effort — server-side session may already be gone - } - } - _user = null; - queryClient.clear(); -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/auth/store.test.ts` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/auth/store.svelte.ts web/src/lib/auth/store.test.ts -git commit -m "feat(web): add rune-based auth store with bootstrap/login/logout - -Central synchronous source of truth for 'who is signed in'. bootstrap() -runs once in +layout.ts load(); login/logout mutate _user and the -TanStack query cache. The silent option on logout is used by the 401 -interceptor (next commit) to avoid POSTing /logout against an already- -invalid session." -``` - ---- - -## Task 7: Wire 401 → `logout({silent:true})` into `apiFetch` - -**Files:** -- Modify: `web/src/lib/api/client.ts` -- Modify: `web/src/lib/api/client.test.ts` - -**Why a separate task:** the 401 interceptor requires `auth/store` to exist, and `auth/store` imports from `api/client`. To break the cycle, `apiFetch` uses a dynamic `import()` — introducing this logic only after the store is in place. - -- [ ] **Step 1: Add the failing test** - -Append inside `web/src/lib/api/client.test.ts`: - -```ts -describe('apiFetch 401 interceptor', () => { - test('401 response triggers auth.logout({silent:true}) and still throws', async () => { - const logoutSpy = vi.fn(); - vi.doMock('$lib/auth/store.svelte', () => ({ - logout: logoutSpy, - login: vi.fn(), - bootstrap: vi.fn(), - user: { value: null } - })); - // Re-import to pick up the mock. - const { apiFetch: apiFetchFresh } = await import('./client'); - stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } }); - await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({ - code: 'unauthorized', - status: 401 - }); - expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); - vi.doUnmock('$lib/auth/store.svelte'); - }); -}); -``` - -- [ ] **Step 2: Run, confirm the new test fails** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: FAIL — `logoutSpy` not called. - -- [ ] **Step 3: Add the interceptor in `apiFetch`** - -Replace the `if (!res.ok)` block in `web/src/lib/api/client.ts` with: - -```ts - if (!res.ok) { - if (res.status === 401) { - // Lazy import: auth/store imports from this file, so a top-level - // import would be circular. By the time any 401 actually happens - // at runtime, both modules have finished loading. - const { logout } = await import('$lib/auth/store.svelte'); - await logout({ silent: true }); - } - const envelope = body && (body as { error?: { code?: string; message?: string } }).error; - const err: ApiError = { - code: envelope?.code ?? 'unknown', - message: envelope?.message ?? res.statusText, - status: res.status - }; - throw err; - } -``` - -- [ ] **Step 4: Run all client tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/api/client.test.ts` -Expected: PASS — 9 tests total. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts -git commit -m "feat(web): auto-logout on 401 inside apiFetch - -Dynamic import breaks the apiFetch <-> auth/store cycle. silent:true -prevents re-POSTing /logout against an already-dead session." -``` - ---- - -## Task 8: Shell component - -**Files:** -- Create: `web/src/lib/components/Shell.svelte` -- Create: `web/src/lib/components/Shell.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/Shell.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; - -vi.mock('$app/state', () => ({ - page: { url: new URL('http://localhost/') } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: '1', username: 'alice', is_admin: false } }, - logout: vi.fn().mockResolvedValue(undefined) -})); - -import Shell from './Shell.svelte'; -import { logout } from '$lib/auth/store.svelte'; -import { goto } from '$app/navigation'; - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('Shell', () => { - test('renders the username in the header', () => { - render(Shell); - expect(screen.getByText('alice')).toBeInTheDocument(); - }); - - test('renders three nav items: Library, Search, Playlists', () => { - render(Shell); - expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); - expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); - expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); - }); - - test('user-menu "Log out" calls logout() and navigates to /login', async () => { - render(Shell); - await fireEvent.click(screen.getByRole('button', { name: /alice/i })); - await fireEvent.click(screen.getByRole('button', { name: /log out/i })); - expect(logout).toHaveBeenCalledTimes(1); - expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); - }); -}); -``` - -- [ ] **Step 2: Run the tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/Shell.test.ts` -Expected: FAIL — component does not exist. - -- [ ] **Step 3: Implement the component** - -Create `web/src/lib/components/Shell.svelte`: - -```svelte - - - (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> - -
-
-
Minstrel
-
- - {#if menuOpen} -
e.stopPropagation()} - role="menu" - > - -
- {/if} -
-
- - - -
- {@render children()} -
-
-``` - -- [ ] **Step 4: Run the tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/Shell.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts -git commit -m "feat(web): add Shell component with header, sidebar, and user menu - -Persistent chrome used by every authenticated route. Sidebar hides -below md:; mobile nav is a separate concern for a later plan." -``` - ---- - -## Task 9: Placeholder pages for `/`, `/search`, `/playlists` - -**Files:** -- Modify: `web/src/routes/+page.svelte` -- Create: `web/src/routes/search/+page.svelte` -- Create: `web/src/routes/playlists/+page.svelte` - -- [ ] **Step 1: Replace `web/src/routes/+page.svelte`** - -Overwrite the file with: - -```svelte -

Library

-

Library views land in a subsequent plan.

-``` - -- [ ] **Step 2: Create `web/src/routes/search/+page.svelte`** - -```svelte -

Search

-

Search lands in a subsequent plan.

-``` - -- [ ] **Step 3: Create `web/src/routes/playlists/+page.svelte`** - -```svelte -

Playlists

-

Playlists land in a subsequent plan.

-``` - -- [ ] **Step 4: Verify nothing breaks** - -Run: `cd web && npm run check && npm run build` -Expected: check passes with 0 errors; build emits `web/build/` including `index.html`. - -Then restore the committed placeholder (the build regenerates it; we don't want the real build output in git): - -Run: `git checkout -- web/build/index.html` -Expected: `git status` shows no changes under `web/build/`. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/routes/+page.svelte web/src/routes/search/+page.svelte web/src/routes/playlists/+page.svelte -git commit -m "feat(web): add Library/Search/Playlists placeholder pages - -Library replaces the scaffold splash. Search and Playlists are routable -so the Shell's sidebar nav works end-to-end before the real features land." -``` - ---- - -## Task 10: Login page - -**Files:** -- Create: `web/src/routes/login/+page.svelte` -- Create: `web/src/routes/login/login.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/login/login.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; - -// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy -// and can run before top-level `let` is initialized, since imports are -// hoisted above variable declarations. -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') })); - -vi.mock('$app/state', () => ({ - page: { - get url() { return state.pageUrl; } - } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/auth/store.svelte', () => ({ - login: vi.fn(), - user: { value: null } -})); - -import LoginPage from './+page.svelte'; -import { login } from '$lib/auth/store.svelte'; -import { goto } from '$app/navigation'; - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/login'); -}); - -async function submit(username = 'alice', password = 'pw') { - const u = screen.getByLabelText(/username/i) as HTMLInputElement; - const p = screen.getByLabelText(/password/i) as HTMLInputElement; - await fireEvent.input(u, { target: { value: username } }); - await fireEvent.input(p, { target: { value: password } }); - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })); -} - -describe('login page', () => { - test('renders username, password, and sign-in button', () => { - render(LoginPage); - expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); - }); - - test('invalid credentials show inline error and clear password', async () => { - (login as ReturnType).mockRejectedValue({ - code: 'invalid_credentials', message: 'bad creds', status: 401 - }); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i); - }); - expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe(''); - expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); - }); - - test('server error shows generic message and preserves both fields', async () => { - (login as ReturnType).mockRejectedValue({ - code: 'server_error', message: 'boom', status: 500 - }); - render(LoginPage); - await submit('alice', 'pw'); - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i); - }); - expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw'); - expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); - }); - - test('success navigates to returnTo when safe', async () => { - state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc'); - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true }); - }); - }); - - test('success falls back to "/" when returnTo is missing', async () => { - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); - }); - }); - - test.each([ - ['//evil.com', 'protocol-relative'], - ['http://evil.com', 'absolute URL'], - ['/login', 'self-redirect'], - ['../admin', 'parent traversal'] - ])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => { - state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`); - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); - }); - }); - - test('submit button disables and marks aria-busy while pending', async () => { - let release!: () => void; - (login as ReturnType).mockReturnValue( - new Promise((resolve) => { release = () => resolve(); }) - ); - render(LoginPage); - await submit(); - const btn = screen.getByRole('button', { name: /sign in/i }); - expect(btn).toBeDisabled(); - expect(btn).toHaveAttribute('aria-busy', 'true'); - release(); - await waitFor(() => expect(btn).not.toBeDisabled()); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/login/+page.test.ts` -Expected: FAIL — component does not exist. - -- [ ] **Step 3: Implement `web/src/routes/login/+page.svelte`** - -```svelte - - -
-
-

Minstrel

-
- - - - {#if error} - - {/if} -
-
-
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/login/+page.test.ts` -Expected: PASS — 10 tests (6 `test(...)` blocks + 4 from `test.each` with 4 rows). - -- [ ] **Step 5: Commit** - -```bash -git add web/src/routes/login/+page.svelte web/src/routes/login/login.test.ts -git commit -m "feat(web): add login page with returnTo support and typed error UX - -Inline errors for invalid creds vs. server-side failures. Validates -returnTo to block open-redirect vectors (//, http://, /login, -parent-traversal)." -``` - ---- - -## Task 11: Root layout wiring — bootstrap, QueryClientProvider, guard, Shell - -**Files:** -- Create: `web/src/routes/+layout.ts` -- Modify: `web/src/routes/+layout.svelte` - -- [ ] **Step 1: Create `web/src/routes/+layout.ts`** - -```ts -import type { LayoutLoad } from './$types'; -import { bootstrap } from '$lib/auth/store.svelte'; - -export const ssr = false; // adapter-static fallback; we're SPA-only -export const prerender = false; - -export const load: LayoutLoad = async () => { - await bootstrap(); - return {}; -}; -``` - -- [ ] **Step 2: Overwrite `web/src/routes/+layout.svelte`** - -```svelte - - - - {#if user.value !== null && page.url.pathname !== '/login'} - {@render children()} - {:else} - {@render children()} - {/if} - -``` - -- [ ] **Step 3: Type-check** - -Run: `cd web && npm run check` -Expected: `svelte-check found 0 errors and 0 warnings`. - -- [ ] **Step 4: Build** - -Run: `cd web && npm run build` -Expected: build succeeds. Then: - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Full test suite** - -Run: `cd web && npm test` -Expected: all test files pass. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/+layout.ts web/src/routes/+layout.svelte -git commit -m "feat(web): wire root layout — bootstrap, guard, Shell, QueryProvider - -+layout.ts runs auth.bootstrap() in load() so the shell sees the user -synchronously. +layout.svelte installs the TanStack QueryClientProvider, -runs the route guard as a \$effect, and swaps Shell vs. bare slot -based on auth state." -``` - ---- - -## Task 12: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (make sure the frontend work didn't accidentally touch Go files)** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 3: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0 errors; all vitest files pass; build emits to `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:auth-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:auth-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual test** - -Bring up the stack: - -```bash -docker compose up --build -d -``` - -Seed a user into Postgres if one does not already exist (reuse whatever bootstrap the auth-foundation plan established; e.g., `psql` insert with a bcrypt hash). Record the username/password used. - -Then in a browser, verify these five flows: - -1. **Deep-link gated:** visit `http://localhost:4533/artists/abc`. Expected: URL changes to `/login?returnTo=%2Fartists%2Fabc`; login form is visible. -2. **Login + deep-link return:** sign in with seeded creds. Expected: URL changes to `/artists/abc`; page is blank (SPA fallback — library plan fills this in) but the Shell header shows your username and sidebar is visible. -3. **Refresh does not flash:** press F5. Expected: Shell renders without the login form appearing first. -4. **Logout:** click username menu → "Log out". Expected: URL changes to `/login`; visiting `/` redirects back to `/login`. -5. **Cross-tab session death:** log in again, open a second tab to `/`. In dev tools, clear the `minstrel_session` cookie. Click "Search" in the nav of the second tab. Expected: after the first API call in the tab (which will 401), the tab redirects to `/login`. - -Tear down: - -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`: present the four-option menu. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage check:** -- `apiFetch` contract → Tasks 3, 7 -- `api` facade → Task 4 -- `auth` store → Task 6 -- QueryClient → Task 5 -- Shell component → Task 8 -- Login page → Task 10 -- Placeholder pages → Task 9 -- Root layout bootstrap + guard → Task 11 -- Testing coverage → each feature task has its own test step -- Dependencies → Task 1 -- Final verification & branch finish → Task 12 - -All spec sections map to tasks. No gaps. - -**Type consistency:** -- `User` shape `{id, username, is_admin}` — used identically in Tasks 2, 6, 8. -- `ApiError` shape `{code, message, status}` — used identically in Tasks 2, 3, 7, 10. -- `LoginResponse` shape `{token, user}` — used in Tasks 2, 6. -- `user.value` getter — Tasks 6 (definition), 8, 11 (consumption). -- `logout(opts?: {silent?: boolean})` — Tasks 6 (definition), 7 (silent call), 8 (plain call). -- `page` from `$app/state` (not `$app/stores`) — consistent across Tasks 8, 10, 11. diff --git a/docs/superpowers/plans/2026-04-23-web-ui-library-views.md b/docs/superpowers/plans/2026-04-23-web-ui-library-views.md deleted file mode 100644 index 30031e08..00000000 --- a/docs/superpowers/plans/2026-04-23-web-ui-library-views.md +++ /dev/null @@ -1,1815 +0,0 @@ -# Web UI Library Views 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:** Wire up the three library browse routes — artists list at `/`, artist detail at `/artists/:id`, album detail at `/albums/:id` — consuming the existing `/api/artists*`, `/api/albums/:id`, and `/api/albums/:id/cover` endpoints, so an authenticated user can navigate the music library entirely in the SPA. - -**Architecture:** TanStack Query handles all fetches and caching via a thin `lib/api/queries.ts` helper. Each route is a tiny `+page.svelte` that imports a `create*Query` helper and delegates repeating visual units to small shared components (`ArtistRow`, `AlbumCard`, `TrackRow`, `LibrarySkeleton`, `ApiErrorBanner`). Loading/error/empty states follow the same pattern on all three routes. - -**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library, Tailwind. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md`. - ---- - -## File Structure - -**New files under `web/`:** - -| File | Responsibility | -|---|---| -| `src/lib/api/types.ts` | Mirrors backend `internal/api/types.go` — `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` | -| `src/lib/api/queries.ts` | `qk.*` key helpers and `createArtistsQuery`/`createArtistQuery`/`createAlbumQuery` wrappers | -| `src/lib/api/queries.test.ts` | Tests the pure `qk` key generators | -| `src/lib/media/covers.ts` | `FALLBACK_COVER` data URL + `coverUrl(id)` helper | -| `src/lib/media/duration.ts` | `formatDuration(seconds)` returning `mm:ss` or `h:mm:ss` | -| `src/lib/media/duration.test.ts` | Unit tests for `formatDuration` | -| `src/lib/utils/useDelayed.svelte.ts` | `useDelayed(getValue, delayMs)` rune helper for skeleton suppression | -| `src/lib/utils/useDelayed.svelte.test.ts` | Unit tests for `useDelayed` | -| `src/lib/components/ArtistRow.svelte` | Artists list row — avatar initial, name, album count, chevron, wrapping `` | -| `src/lib/components/ArtistRow.test.ts` | Component test | -| `src/lib/components/AlbumCard.svelte` | Album grid card — cover, title, year, wrapping `` | -| `src/lib/components/AlbumCard.test.ts` | Component test | -| `src/lib/components/TrackRow.svelte` | Album track row — number, title, duration | -| `src/lib/components/TrackRow.test.ts` | Component test | -| `src/lib/components/LibrarySkeleton.svelte` | Skeleton shimmer with variants `list`/`grid`/`album` | -| `src/lib/components/LibrarySkeleton.test.ts` | Component test | -| `src/lib/components/ApiErrorBanner.svelte` | Retry-capable error card | -| `src/lib/components/ApiErrorBanner.test.ts` | Component test | -| `src/test-utils/query.ts` | Shared `mockQuery` / `mockInfiniteQuery` factories for page tests | -| `src/routes/artists/[id]/+page.svelte` | Artist detail page | -| `src/routes/artists/[id]/artist.test.ts` | Page test (NOT `+page.test.ts` — route walker collision) | -| `src/routes/albums/[id]/+page.svelte` | Album detail page | -| `src/routes/albums/[id]/album.test.ts` | Page test | -| `src/routes/artists.test.ts` | Page test for the artists list (lives at `src/routes/`, not route-colocated in a `+` file) | - -**Modified files:** - -| File | Change | -|---|---| -| `src/routes/+page.svelte` | Replace the "Library" placeholder with the real artists list | - ---- - -## Task 1: API types mirroring `internal/api/types.go` - -**Files:** -- Create: `web/src/lib/api/types.ts` - -- [ ] **Step 1: Write `web/src/lib/api/types.ts`** - -```ts -// Mirrors internal/api/types.go. Kept minimal — only the shapes the SPA -// actually reads today (no LoginRequest/LoginResponse here; those live in -// client.ts alongside the auth plumbing). - -export type Page = { - items: T[]; - total: number; - limit: number; - offset: number; -}; - -export type ArtistRef = { - id: string; - name: string; - album_count: number; -}; - -export type AlbumRef = { - id: string; - title: string; - artist_id: string; - artist_name: string; - year?: number; - track_count: number; - duration_sec: number; - cover_url: string; -}; - -export type TrackRef = { - id: string; - title: string; - album_id: string; - album_title: string; - artist_id: string; - artist_name: string; - track_number?: number; - disc_number?: number; - duration_sec: number; - stream_url: string; -}; - -export type ArtistDetail = ArtistRef & { - albums: AlbumRef[]; -}; - -export type AlbumDetail = AlbumRef & { - tracks: TrackRef[]; -}; -``` - -- [ ] **Step 2: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/api/types.ts -git commit -m "feat(web): add library API types mirroring internal/api/types.go" -``` - ---- - -## Task 2: `formatDuration` helper + tests - -**Files:** -- Create: `web/src/lib/media/duration.ts` -- Create: `web/src/lib/media/duration.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/media/duration.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { formatDuration } from './duration'; - -describe('formatDuration', () => { - test('under a minute renders 0:SS', () => { - expect(formatDuration(42)).toBe('0:42'); - expect(formatDuration(5)).toBe('0:05'); - }); - - test('under an hour renders M:SS', () => { - expect(formatDuration(242)).toBe('4:02'); - expect(formatDuration(600)).toBe('10:00'); - }); - - test('at or above an hour renders H:MM:SS', () => { - expect(formatDuration(3600)).toBe('1:00:00'); - expect(formatDuration(3723)).toBe('1:02:03'); - expect(formatDuration(36000)).toBe('10:00:00'); - }); - - test('zero renders 0:00', () => { - expect(formatDuration(0)).toBe('0:00'); - }); - - test('negative values clamp to 0:00', () => { - expect(formatDuration(-1)).toBe('0:00'); - expect(formatDuration(-100)).toBe('0:00'); - }); - - test('fractional seconds floor', () => { - expect(formatDuration(61.9)).toBe('1:01'); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/media/duration.test.ts` -Expected: FAIL — `formatDuration` not exported. - -- [ ] **Step 3: Implement `web/src/lib/media/duration.ts`** - -```ts -// Formats a non-negative number of seconds as `mm:ss` when under an hour, -// `h:mm:ss` otherwise. Negative values clamp to zero (callers shouldn't -// pass them, but the UI shouldn't crash if they do). -export function formatDuration(seconds: number): string { - const total = Math.max(0, Math.floor(seconds)); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - const ss = String(s).padStart(2, '0'); - if (h > 0) { - const mm = String(m).padStart(2, '0'); - return `${h}:${mm}:${ss}`; - } - return `${m}:${ss}`; -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/media/duration.test.ts` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/media/duration.ts web/src/lib/media/duration.test.ts -git commit -m "feat(web): add formatDuration helper - -Renders seconds as mm:ss or h:mm:ss. Negative inputs clamp to 0:00 -so a bad backend value doesn't crash the UI." -``` - ---- - -## Task 3: `covers.ts` helper - -**Files:** -- Create: `web/src/lib/media/covers.ts` - -- [ ] **Step 1: Write `web/src/lib/media/covers.ts`** - -```ts -// Fallback cover — an inline SVG data URL showing a music-note glyph on the -// surface color. Used by AlbumCard's onerror handler when the real cover -// fails (e.g. scanner didn't pick up an embedded image and there's no -// sidecar). Data URL avoids an extra HTTP round-trip. -export const FALLBACK_COVER = - 'data:image/svg+xml;utf8,' + - encodeURIComponent( - ` - - - ` - ); - -export function coverUrl(albumId: string): string { - return `/api/albums/${albumId}/cover`; -} -``` - -- [ ] **Step 2: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/media/covers.ts -git commit -m "feat(web): add cover URL helper + fallback SVG data URL" -``` - ---- - -## Task 4: `useDelayed` rune helper - -**Files:** -- Create: `web/src/lib/utils/useDelayed.svelte.ts` -- Create: `web/src/lib/utils/useDelayed.svelte.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/utils/useDelayed.svelte.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { flushSync } from 'svelte'; -import { useDelayed } from './useDelayed.svelte'; - -describe('useDelayed', () => { - test('value stays false until delay elapses while source is true', () => { - vi.useFakeTimers(); - try { - let source = $state(false); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - - expect(delayed.value).toBe(false); - - source = true; - flushSync(); - expect(delayed.value).toBe(false); - - vi.advanceTimersByTime(50); - expect(delayed.value).toBe(false); - - vi.advanceTimersByTime(60); - expect(delayed.value).toBe(true); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); - - test('source flipping false before delay cancels the pending timeout', () => { - vi.useFakeTimers(); - try { - let source = $state(false); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - - source = true; - flushSync(); - vi.advanceTimersByTime(40); - source = false; - flushSync(); - vi.advanceTimersByTime(200); - expect(delayed.value).toBe(false); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); - - test('value resets to false immediately when source becomes false', () => { - vi.useFakeTimers(); - try { - let source = $state(true); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - flushSync(); // run the initial $effect so the setTimeout is scheduled - - vi.advanceTimersByTime(200); - expect(delayed.value).toBe(true); - - source = false; - flushSync(); - expect(delayed.value).toBe(false); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement `web/src/lib/utils/useDelayed.svelte.ts`** - -```ts -// Returns a rune-wrapped boolean that mirrors the source getter, but only -// flips to true after the source has been true for delayMs continuously. -// Flips back to false immediately when the source becomes false. -// -// Used to suppress flash-of-skeleton on cache-hit navigation: skeletons -// only render for loads that take longer than ~100ms. -export function useDelayed( - source: () => boolean, - delayMs = 100 -): { readonly value: boolean } { - let delayed = $state(false); - let timeout: ReturnType | null = null; - - $effect(() => { - const v = source(); - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - if (v) { - timeout = setTimeout(() => { - delayed = true; - timeout = null; - }, delayMs); - } else { - delayed = false; - } - return () => { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - }; - }); - - return { get value() { return delayed; } }; -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/utils/useDelayed.svelte.ts web/src/lib/utils/useDelayed.svelte.test.ts -git commit -m "feat(web): add useDelayed rune helper - -Boolean mirror that flips true only after the source stays true for -delayMs. Used to hide the skeleton on sub-100ms cache hits so -back-navigation feels instant." -``` - ---- - -## Task 5: `queries.ts` + key-generator tests - -**Files:** -- Create: `web/src/lib/api/queries.ts` -- Create: `web/src/lib/api/queries.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/api/queries.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { qk } from './queries'; - -describe('qk key generators', () => { - test('artists key includes sort discriminator', () => { - expect(qk.artists('alpha')).toEqual(['artists', { sort: 'alpha' }]); - expect(qk.artists('newest')).toEqual(['artists', { sort: 'newest' }]); - }); - - test('artist key tuple', () => { - expect(qk.artist('abc')).toEqual(['artist', 'abc']); - }); - - test('album key tuple', () => { - expect(qk.album('xyz')).toEqual(['album', 'xyz']); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/api/queries.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement `web/src/lib/api/queries.ts`** - -```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'; -export 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>( - `/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(`/api/artists/${id}`), - }); -} - -export function createAlbumQuery(id: string) { - return createQuery({ - queryKey: qk.album(id), - queryFn: () => api.get(`/api/albums/${id}`), - }); -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/api/queries.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts -git commit -m "feat(web): add TanStack Query helpers for library endpoints - -qk.{artists,artist,album} key generators and create*Query wrappers -with shared page size, sort-aware keys, and infinite-query plumbing -for /api/artists." -``` - ---- - -## Task 6: `ArtistRow` component - -**Files:** -- Create: `web/src/lib/components/ArtistRow.svelte` -- Create: `web/src/lib/components/ArtistRow.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/ArtistRow.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import ArtistRow from './ArtistRow.svelte'; -import type { ArtistRef } from '$lib/api/types'; - -const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; - -describe('ArtistRow', () => { - test('renders initial, name, album count inside a link to /artists/:id', () => { - render(ArtistRow, { props: { artist } }); - - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/artists/abc'); - expect(link).toHaveTextContent('alice'); - expect(link).toHaveTextContent('12 albums'); - expect(link).toHaveTextContent('A'); // initial letter - }); - - test('singular "1 album" when album_count is 1', () => { - render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); - expect(screen.getByRole('link')).toHaveTextContent('1 album'); - }); - - test('empty name still renders a safe initial', () => { - render(ArtistRow, { props: { artist: { ...artist, name: '' } } }); - // No crash; initial container present but empty or a placeholder char. - expect(screen.getByRole('link')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/ArtistRow.svelte`** - -```svelte - - - - - {artist.name} - {countLabel} - - -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/ArtistRow.svelte web/src/lib/components/ArtistRow.test.ts -git commit -m "feat(web): add ArtistRow component - -One row in the artists list: avatar initial, name, album count, -chevron. Entire row is an for click + keyboard activation." -``` - ---- - -## Task 7: `AlbumCard` component - -**Files:** -- Create: `web/src/lib/components/AlbumCard.svelte` -- Create: `web/src/lib/components/AlbumCard.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/AlbumCard.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import AlbumCard from './AlbumCard.svelte'; -import type { AlbumRef } from '$lib/api/types'; -import { FALLBACK_COVER } from '$lib/media/covers'; - -const album: AlbumRef = { - id: 'xyz', - title: 'Kind of Blue', - artist_id: 'm-davis', - artist_name: 'Miles Davis', - year: 1959, - track_count: 5, - duration_sec: 2630, - cover_url: '/api/albums/xyz/cover', -}; - -describe('AlbumCard', () => { - test('renders cover, title, year inside a link to /albums/:id', () => { - render(AlbumCard, { props: { album } }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/albums/xyz'); - expect(link).toHaveTextContent('Kind of Blue'); - expect(link).toHaveTextContent('1959'); - - const img = screen.getByRole('img') as HTMLImageElement; - expect(img.src).toContain('/api/albums/xyz/cover'); - }); - - test('year is omitted when not present', () => { - render(AlbumCard, { props: { album: { ...album, year: undefined } } }); - // No crash; no year text. - expect(screen.queryByText(/1959/)).not.toBeInTheDocument(); - }); - - test('broken cover falls back to FALLBACK_COVER data URL', async () => { - render(AlbumCard, { props: { album } }); - const img = screen.getByRole('img') as HTMLImageElement; - await fireEvent.error(img); - expect(img.src).toBe(FALLBACK_COVER); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/AlbumCard.svelte`** - -```svelte - - - - -
{album.title}
- {#if album.year} -
{album.year}
- {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts -git commit -m "feat(web): add AlbumCard component - -Grid card used on the artist detail page. Cover image, title, year. -Broken covers swap to FALLBACK_COVER via onerror." -``` - ---- - -## Task 8: `TrackRow` component - -**Files:** -- Create: `web/src/lib/components/TrackRow.svelte` -- Create: `web/src/lib/components/TrackRow.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/TrackRow.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import TrackRow from './TrackRow.svelte'; -import type { TrackRef } from '$lib/api/types'; - -const track: TrackRef = { - id: 't1', - title: 'So What', - album_id: 'xyz', - album_title: 'Kind of Blue', - artist_id: 'm-davis', - artist_name: 'Miles Davis', - track_number: 1, - disc_number: 1, - duration_sec: 545, - stream_url: '/api/tracks/t1/stream', -}; - -describe('TrackRow', () => { - test('renders track number, title, formatted duration', () => { - render(TrackRow, { props: { track } }); - expect(screen.getByText('1')).toBeInTheDocument(); - expect(screen.getByText('So What')).toBeInTheDocument(); - expect(screen.getByText('9:05')).toBeInTheDocument(); - }); - - test('track number falls back to dash when missing', () => { - render(TrackRow, { props: { track: { ...track, track_number: undefined } } }); - expect(screen.getByText('—')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/TrackRow.svelte`** - -```svelte - - -
- - {track.track_number ?? '—'} - - {track.title} - {formatDuration(track.duration_sec)} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts -git commit -m "feat(web): add TrackRow component - -Non-interactive album track row with number, title, duration. -Player plan will add click-to-play." -``` - ---- - -## Task 9: `LibrarySkeleton` component - -**Files:** -- Create: `web/src/lib/components/LibrarySkeleton.svelte` -- Create: `web/src/lib/components/LibrarySkeleton.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/LibrarySkeleton.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import LibrarySkeleton from './LibrarySkeleton.svelte'; - -describe('LibrarySkeleton', () => { - test('variant="list" renders count placeholder rows', () => { - render(LibrarySkeleton, { props: { variant: 'list', count: 5 } }); - const container = screen.getByTestId('skeleton-list'); - expect(container.querySelectorAll('[data-skeleton-row]').length).toBe(5); - }); - - test('variant="grid" renders count placeholder cards', () => { - render(LibrarySkeleton, { props: { variant: 'grid', count: 6 } }); - const container = screen.getByTestId('skeleton-grid'); - expect(container.querySelectorAll('[data-skeleton-card]').length).toBe(6); - }); - - test('variant="album" renders a hero placeholder with cover block + bars', () => { - render(LibrarySkeleton, { props: { variant: 'album' } }); - expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); - expect(screen.getByTestId('skeleton-album-cover')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/LibrarySkeleton.svelte`** - -```svelte - - -{#if variant === 'list'} -
- {#each Array.from({ length: count }) as _, i (i)} -
- - - -
- {/each} -
-{:else if variant === 'grid'} -
- {#each Array.from({ length: count }) as _, i (i)} -
-
-
-
-
- {/each} -
-{:else} -
-
-
-
-
-
-
-
-
-
- {#each Array.from({ length: 10 }) as _, i (i)} -
- {/each} -
-
-{/if} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/LibrarySkeleton.svelte web/src/lib/components/LibrarySkeleton.test.ts -git commit -m "feat(web): add LibrarySkeleton with list/grid/album variants - -Shimmer placeholders that mirror the layout of each library page so -the real content slides in without shifting." -``` - ---- - -## Task 10: `ApiErrorBanner` component - -**Files:** -- Create: `web/src/lib/components/ApiErrorBanner.svelte` -- Create: `web/src/lib/components/ApiErrorBanner.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/ApiErrorBanner.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import ApiErrorBanner from './ApiErrorBanner.svelte'; - -describe('ApiErrorBanner', () => { - test('renders error.message when present', () => { - render(ApiErrorBanner, { - props: { - error: { code: 'server_error', message: 'database down', status: 500 }, - onRetry: () => {} - } - }); - expect(screen.getByRole('alert')).toHaveTextContent('database down'); - }); - - test('falls back to generic text when error lacks message', () => { - render(ApiErrorBanner, { props: { error: undefined, onRetry: () => {} } }); - expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i); - }); - - test('clicking the button calls onRetry', async () => { - const onRetry = vi.fn(); - render(ApiErrorBanner, { - props: { - error: { code: 'server_error', message: 'x', status: 500 }, - onRetry - } - }); - await fireEvent.click(screen.getByRole('button', { name: /try again/i })); - expect(onRetry).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/ApiErrorBanner.svelte`** - -```svelte - - - -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/ApiErrorBanner.svelte web/src/lib/components/ApiErrorBanner.test.ts -git commit -m "feat(web): add ApiErrorBanner retry-capable error card" -``` - ---- - -## Task 11: Artists list page at `/` - -**Files:** -- Modify: `web/src/routes/+page.svelte` -- Create: `web/src/test-utils/query.ts` -- Create: `web/src/routes/artists.test.ts` - -- [ ] **Step 1: Create `web/src/test-utils/query.ts`** - -```ts -import type { Page } from '$lib/api/types'; - -// Synthetic infinite-query object shape matching what the SPA reads from -// @tanstack/svelte-query. Enough surface area for component tests; -// real behavior is covered by the TanStack library's own tests. -export function mockInfiniteQuery(opts: { - pages?: Page[]; - isPending?: boolean; - isError?: boolean; - error?: unknown; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - fetchNextPage?: () => void; - refetch?: () => void; -} = {}) { - return { - data: { pages: opts.pages ?? [] }, - isPending: opts.isPending ?? false, - isError: opts.isError ?? false, - error: opts.error, - hasNextPage: opts.hasNextPage ?? false, - isFetchingNextPage: opts.isFetchingNextPage ?? false, - fetchNextPage: opts.fetchNextPage ?? (() => {}), - refetch: opts.refetch ?? (() => {}) - }; -} - -export function mockQuery(opts: { - data?: T; - isPending?: boolean; - isError?: boolean; - error?: unknown; - refetch?: () => void; -} = {}) { - return { - data: opts.data, - isPending: opts.isPending ?? false, - isError: opts.isError ?? false, - error: opts.error, - refetch: opts.refetch ?? (() => {}) - }; -} -``` - -- [ ] **Step 2: Write the failing page test** - -Create `web/src/routes/artists.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockInfiniteQuery } from '../test-utils/query'; -import type { ArtistRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { artists: (sort: string) => ['artists', { sort }] }, - createArtistsQuery: vi.fn() -})); - -import ArtistsPage from './+page.svelte'; -import { createArtistsQuery } from '$lib/api/queries'; -import { goto } from '$app/navigation'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/'); -}); - -describe('artists list page', () => { - test('renders a row per artist', () => { - const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; - const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) - ); - render(ArtistsPage); - expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); - expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); - }); - - test('renders the total count from the first page', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 1247)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); - }); - - test('Load more button calls fetchNextPage when hasNextPage', async () => { - const fetchNextPage = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(ArtistsPage); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('"End of library" when hasNextPage is false and at least one page loaded', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/end of library/i)).toBeInTheDocument(); - }); - - test('empty total renders empty-library message', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); - }); - - test('changing the sort dropdown calls goto with ?sort=newest', async () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; - await fireEvent.change(select, { target: { value: 'newest' } }); - expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); - }); - - test('pending state renders the list skeleton after the useDelayed window', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ isPending: true }) - ); - vi.useFakeTimers(); - try { - render(ArtistsPage); - vi.advanceTimersByTime(200); - flushSync(); // flush the $effect that reads delayed.value - expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); - - test('error state renders the error banner with retry', async () => { - const refetch = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - isError: true, - error: { code: 'server_error', message: 'db down', status: 500 }, - refetch - }) - ); - render(ArtistsPage); - expect(screen.getByRole('alert')).toHaveTextContent('db down'); - await fireEvent.click(screen.getByRole('button', { name: /try again/i })); - expect(refetch).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 3: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/artists.test.ts` -Expected: FAIL — page still renders the placeholder, assertions don't match. - -- [ ] **Step 4: Replace `web/src/routes/+page.svelte`** - -```svelte - - -
-
-
-

Library

- {#if !query.isPending && !query.isError} -

- {total} {total === 1 ? 'artist' : 'artists'} -

- {/if} -
- -
- - {#if query.isError} - - {:else if showSkeleton.value && artists.length === 0} - - {:else if !query.isPending && total === 0} -

- No artists yet — scan a library folder via the server's config. -

- {:else} -
- {#each artists as a (a.id)} - - {/each} -
- - {#if query.hasNextPage} - - {:else if artists.length > 0} -

End of library

- {/if} - {/if} -
-``` - -- [ ] **Step 5: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/artists.test.ts` -Expected: PASS — 8 tests. - -- [ ] **Step 6: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 7: Commit** - -```bash -git add web/src/routes/+page.svelte web/src/routes/artists.test.ts web/src/test-utils/query.ts -git commit -m "feat(web): replace / placeholder with real artists list - -Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load -more pagination, delayed skeleton, and shared error banner. Also -introduces the test-utils/query.ts helpers for subsequent page tests." -``` - ---- - -## Task 12: Artist detail page at `/artists/:id` - -**Files:** -- Create: `web/src/routes/artists/[id]/+page.svelte` -- Create: `web/src/routes/artists/[id]/artist.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/artists/[id]/artist.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockQuery } from '../../../test-utils/query'; -import type { ArtistDetail, AlbumRef } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); - -vi.mock('$app/state', () => ({ - page: { - get params() { return state.pageParams; }, - get url() { return new URL('http://localhost/artists/' + state.pageParams.id); } - } -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { artist: (id: string) => ['artist', id] }, - createArtistQuery: vi.fn() -})); - -import ArtistPage from './+page.svelte'; -import { createArtistQuery } from '$lib/api/queries'; - -function album(id: string, title: string, year?: number): AlbumRef { - return { - id, title, - artist_id: 'abc', artist_name: 'Alice', - year, track_count: 10, duration_sec: 2400, - cover_url: `/api/albums/${id}/cover` - }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageParams = { id: 'abc' }; -}); - -describe('artist detail page', () => { - test('renders artist name, subtitle, and one AlbumCard per album', () => { - const detail: ArtistDetail = { - id: 'abc', name: 'Alice', album_count: 2, - albums: [album('a1', 'First', 2020), album('a2', 'Second')] - }; - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(ArtistPage); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice'); - expect(screen.getByText(/2 albums/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1'); - expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2'); - }); - - test('back link points to Library', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } - })); - render(ArtistPage); - const back = screen.getByRole('link', { name: /library/i }); - expect(back).toHaveAttribute('href', '/'); - }); - - test('404 renders non-retryable "Artist not found" with back link', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'not_found', message: 'nope', status: 404 } - })); - render(ArtistPage); - expect(screen.getByText(/artist not found/i)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); - }); - - test('non-404 error renders the retry banner', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'server_error', message: 'boom', status: 500 } - })); - render(ArtistPage); - expect(screen.getByRole('alert')).toHaveTextContent('boom'); - expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); - }); - - test('pending (after delay) renders the grid skeleton', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); - vi.useFakeTimers(); - try { - render(ArtistPage); - vi.advanceTimersByTime(200); - flushSync(); - expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/routes/artists/[id]/+page.svelte`** - -```svelte - - -
- ← Library - - {#if notFound} - - {:else if query.isError} - - {:else if showSkeleton.value && !query.data} - - {:else if query.data} - {@const detail = query.data} -
-

{detail.name}

-

- {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} -

-
- - {#if detail.albums.length === 0} -

This artist has no albums in the library.

- {:else} -
- {#each detail.albums as album (album.id)} - - {/each} -
- {/if} - {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/artists/[id]/+page.svelte web/src/routes/artists/[id]/artist.test.ts -git commit -m "feat(web): add artist detail page at /artists/:id - -Renders the artist name + album count and a responsive AlbumCard grid -from the nested ArtistDetail response. 404 surfaces a distinct -non-retryable 'not found' state; other errors share the retry banner." -``` - ---- - -## Task 13: Album detail page at `/albums/:id` - -**Files:** -- Create: `web/src/routes/albums/[id]/+page.svelte` -- Create: `web/src/routes/albums/[id]/album.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/albums/[id]/album.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockQuery } from '../../../test-utils/query'; -import type { AlbumDetail, TrackRef } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record })); - -vi.mock('$app/state', () => ({ - page: { - get params() { return state.pageParams; }, - get url() { return new URL('http://localhost/albums/' + state.pageParams.id); } - } -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { album: (id: string) => ['album', id] }, - createAlbumQuery: vi.fn() -})); - -import AlbumPage from './+page.svelte'; -import { createAlbumQuery } from '$lib/api/queries'; - -function track(id: string, title: string, number: number, dur: number): TrackRef { - return { - id, title, - album_id: 'xyz', album_title: 'Kind of Blue', - artist_id: 'md', artist_name: 'Miles Davis', - track_number: number, disc_number: 1, - duration_sec: dur, - stream_url: `/api/tracks/${id}/stream` - }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageParams = { id: 'xyz' }; -}); - -describe('album detail page', () => { - test('renders hero metadata + one TrackRow per track', () => { - const detail: AlbumDetail = { - id: 'xyz', title: 'Kind of Blue', - artist_id: 'md', artist_name: 'Miles Davis', - year: 1959, track_count: 2, duration_sec: 544 + 565, - cover_url: '/api/albums/xyz/cover', - tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] - }; - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(AlbumPage); - - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue'); - expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); - expect(screen.getByText(/1959/)).toBeInTheDocument(); - expect(screen.getByText(/2 tracks/i)).toBeInTheDocument(); - - expect(screen.getByText('So What')).toBeInTheDocument(); - expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument(); - - const img = screen.getByRole('img') as HTMLImageElement; - expect(img.src).toContain('/api/albums/xyz/cover'); - }); - - test('back link points to /artists/:artistId with the artist name', () => { - const detail: AlbumDetail = { - id: 'xyz', title: 'T', - artist_id: 'md', artist_name: 'Miles Davis', - track_count: 0, duration_sec: 0, - cover_url: '/api/albums/xyz/cover', - tracks: [] - }; - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(AlbumPage); - const back = screen.getByRole('link', { name: /Miles Davis/ }); - expect(back).toHaveAttribute('href', '/artists/md'); - }); - - test('404 renders non-retryable "Album not found" with back link', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'not_found', message: 'nope', status: 404 } - })); - render(AlbumPage); - expect(screen.getByText(/album not found/i)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); - }); - - test('non-404 error renders the retry banner', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'server_error', message: 'boom', status: 500 } - })); - render(AlbumPage); - expect(screen.getByRole('alert')).toHaveTextContent('boom'); - expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); - }); - - test('pending (after delay) renders the album skeleton', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); - vi.useFakeTimers(); - try { - render(AlbumPage); - vi.advanceTimersByTime(200); - flushSync(); - expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/routes/albums/[id]/+page.svelte`** - -```svelte - - -
- {#if notFound} - - {:else if query.isError} - - {:else if showSkeleton.value && !query.data} - - {:else if query.data} - {@const album = query.data} - - ← {album.artist_name} - - -
- -
-

{album.title}

-

- {album.artist_name} -

- {#if album.year} -

{album.year}

- {/if} -

- {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} - · {formatDuration(album.duration_sec)} -

-
-
- - {#if album.tracks.length === 0} -

This album has no tracks.

- {:else} -
- {#each album.tracks as track (track.id)} - - {/each} -
- {/if} - {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/albums/[id]/+page.svelte web/src/routes/albums/[id]/album.test.ts -git commit -m "feat(web): add album detail page at /albums/:id - -Hero with cover + title + linked artist + metadata; TrackRow list -below. Back link targets the artist page (not Library) so drilling -up feels correct. 404 renders a distinct non-retryable state." -``` - ---- - -## Task 14: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (sanity — backend untouched)** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 3: Web check + full tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0 errors / 0 warnings; all vitest files pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:library-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:library-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual check** - -Bring up the stack: - -```bash -docker compose up --build -d -``` - -In a browser: - -1. Sign in with the seeded admin user. -2. `/` renders the artists list with counts and "Load more" if more than 50 artists exist. -3. Change sort dropdown → URL shows `?sort=newest`, list reorders. -4. Click an artist → albums grid renders with covers (some may show the fallback glyph — that's expected for albums without embedded cover art). -5. Click an album → hero + track list. Duration format looks right (`3:42` for short, `1:02:03` for long). -6. Click the back link on the album page → returns to the artist (not to `/`). -7. Visit `/artists/00000000-0000-0000-0000-000000000000` → "Artist not found" card. -8. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found" card. -9. Refresh `/artists/` → Shell renders without login flash; data refetches and renders. - -Tear down: - -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage check:** -- API types → Task 1 -- `queries.ts` helpers + query-key convention → Task 5 -- Cover URL + fallback → Task 3 -- Duration formatter → Task 2 -- Delayed skeleton helper → Task 4 -- ArtistRow / AlbumCard / TrackRow / LibrarySkeleton / ApiErrorBanner → Tasks 6–10 -- Test-utils mocks → Task 11 (introduced alongside the first page that needs them) -- Artists list page (replace `/` placeholder) → Task 11 -- Artist detail page → Task 12 -- Album detail page → Task 13 -- Final verification + branch finish → Task 14 - -All spec sections map to tasks. - -**Type consistency:** -- `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` — defined in Task 1, used identically in Tasks 5–13. -- `ArtistSort` — exported from `queries.ts` (Task 5), consumed in Task 11. -- `ApiError` — re-imported from `$lib/api/client` (auth plan) in Tasks 10, 12, 13. -- `createArtistsQuery` / `createArtistQuery` / `createAlbumQuery` signatures consistent between Task 5 and consuming tasks. -- `qk.artists(sort)` / `qk.artist(id)` / `qk.album(id)` — mocked-out in page tests exactly as defined. -- `FALLBACK_COVER` — defined in Task 3, used in Tasks 7 (AlbumCard) and 13 (album page hero). -- `formatDuration(seconds)` — defined in Task 2, used in Task 8 (TrackRow test and component) and Task 13. -- `useDelayed(getValue, delayMs?)` — defined in Task 4, used in Tasks 11, 12, 13. - -**Filename hazards addressed:** No page test is named `+page.test.ts` (auth plan's lesson). Route-colocated tests use `artist.test.ts` / `album.test.ts`; the artists list test lives at `src/routes/artists.test.ts` (outside the `+` route-walker). diff --git a/docs/superpowers/plans/2026-04-24-web-ui-player.md b/docs/superpowers/plans/2026-04-24-web-ui-player.md deleted file mode 100644 index 32caee1e..00000000 --- a/docs/superpowers/plans/2026-04-24-web-ui-player.md +++ /dev/null @@ -1,1698 +0,0 @@ -# Web UI Player 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 queue-based audio playback in the SPA — click a track on an album, the rest of the album queues, the bottom bar shows progress + controls, MediaSession populates OS media UI, and shuffle/repeat/volume all work. - -**Architecture:** A single Svelte 5 rune store in `lib/player/store.svelte.ts` holds all player state. A single `
- -
-

Albums

- {#if albumsTotal === 0} -

No liked albums yet.

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

Tracks

- {#if tracksTotal === 0} -

No liked tracks yet.

- {:else} -
- {#each tracks as t, i (t.id)} - - {/each} -
- {#if tracksQuery?.hasNextPage} - - {/if} - {/if} -
- -``` - -- [ ] **Step 4: Run tests, confirm pass** - -Run: `cd web && npm test -- 'src/routes/library/liked/liked.test.ts'` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/library/liked/+page.svelte web/src/routes/library/liked/liked.test.ts -git commit -m "feat(web): add /library/liked page with three sections - -Each section is its own infinite query against /api/likes/{type}. -Empty sections show 'No liked X yet' (gated on total === 0). Reuses -ArtistRow / AlbumCard / TrackRow for rendering — same components as -the search overflow pages." -``` - ---- - -## Task 9: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (with DB)** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Go lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 3: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; all vitest files pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:m2-likes-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m2-likes-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533`: - -1. Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled. -2. Verify in psql: - ```bash - docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \ - -c "SELECT user_id, track_id FROM general_likes;" - ``` -3. Click heart on an album card → row in `general_likes_albums`. -4. Click heart on an artist row → row in `general_likes_artists`. -5. Click filled heart again → row gone in each case. -6. Open Feishin/Symfonium pointed at the same instance. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled. -7. Unstar from Subsonic. Web SPA's heart empties on next refetch. -8. Visit `/library/liked` via the new "Liked" sidebar link. Three sections render with the items above. -9. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- Schema (3 tables) → Task 1. -- sqlc queries → Task 1. -- Native API endpoints (7) → Task 2. -- Subsonic star/unstar with validate-all-first atomicity → Task 3. -- Subsonic getStarred/getStarred2 with cap=500 → Task 4. -- Web types + qk keys + likes.ts factory + mutations + rollback → Task 5. -- LikeButton component with stop-propagation → Task 6. -- Wire heart into TrackRow / AlbumCard / ArtistRow / PlayerBar → Task 7. -- "Liked" nav entry → Task 7. -- /library/liked page → Task 8. -- End-to-end manual + branch finish → Task 9. - -**Type consistency:** -- `EntityKind = 'track' | 'album' | 'artist'` — same in TS factory and component prop. -- `LikedIdsResponse = { track_ids, album_ids, artist_ids }` — server JSON keys match TS keys (snake_case). -- `qk.likedIds()`, `qk.likedTracks()` etc. — same names everywhere they're consumed. -- `likeEntity(client, kind, id)` / `unlikeEntity(client, kind, id)` — same signature in factory + component + tests. -- pgtype.UUID throughout server-side; string at HTTP boundaries via `uuidToString`. - -**Filename hazards:** all new test files use plain `.test.ts` (no rune syntax in the test bodies — just imports of components that use runes). Route test files use non-`+`-prefixed names (`liked.test.ts`). - -**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command shows expected output. - -**ArtistRow refactor risk:** changing `
` to `
` with positioned overlay link is a surface change. The existing artist tests (in route pages — `src/routes/+page.svelte` etc.) use `screen.getByRole('link')` and `getByText` which both still work. Tests re-run as part of Task 7 Step 7. diff --git a/docs/superpowers/plans/2026-04-27-m3-shuffle.md b/docs/superpowers/plans/2026-04-27-m3-shuffle.md deleted file mode 100644 index 652bb836..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-shuffle.md +++ /dev/null @@ -1,1588 +0,0 @@ -# M3 Weighted Shuffle v1 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:** 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. - -**Architecture:** New `internal/recommendation` package with three layers — pure scoring function (`score.go`), pure orchestration (`shuffle.go`), and DB-backed candidate loader (`candidates.go`). The HTTP handler at `internal/api/radio.go` becomes a thin shim that validates the seed, calls the loader, calls the orchestrator, prepends the seed, and returns JSON. Stats (`is_liked`, `play_count`, `skip_count`, `last_played_at`) are computed in one SQL query joining `tracks` ↔ `general_likes` ↔ aggregated `play_events`. No new schema — all inputs from existing tables. - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes (the existing `playRadio` action already calls `/api/radio?seed_track=…` and consumes `RadioResponse`). - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-shuffle-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/recommendation/score.go` | Pure `Score(inputs, weights, now, rng) → float64` + `recencyDecay` + `skipRatio` helpers. | -| `internal/recommendation/score_test.go` | Boundary cases for every term, cold-start, jitter determinism. | -| `internal/recommendation/shuffle.go` | Pure `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. | -| `internal/recommendation/shuffle_test.go` | Liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder, limit. | -| `internal/recommendation/candidates.go` | `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` — wraps the sqlc query, projects rows to `Candidate` (Track + ScoringInputs). | -| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. | -| `internal/db/queries/recommendation.sql` | One sqlc query: `LoadRadioCandidates`. | -| `internal/db/dbq/recommendation.sql.go` | Generated bindings. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/api/radio.go` | Replace stub. New flow: validate, load candidates, shuffle, prepend seed, return JSON. | -| `internal/api/radio_test.go` | Replace existing 5 stub tests with the v1 weighted-shuffle scenarios. | -| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` with weights + radio size + recently-played-hours. | -| `internal/api/api.go` | Mount signature gains `cfg config.RecommendationConfig`; handlers struct gains `recCfg` field. | -| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. | - -**No web changes.** The existing `playRadio(seedTrackId)` already consumes `{ tracks: TrackRef[] }`. - ---- - -## Task 1: Pure scoring function - -**Files:** -- Create: `internal/recommendation/score.go` -- Create: `internal/recommendation/score_test.go` - -Pure Go, no DB. The function takes its inputs explicitly and an injectable RNG so tests pin jitter to deterministic values. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/score_test.go`: - -```go -package recommendation - -import ( - "math" - "math/rand" - "testing" - "time" -) - -func defaultWeights() ScoringWeights { - return ScoringWeights{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - } -} - -func fixedRNG(v float64) func() float64 { - return func() float64 { return v } -} - -func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) { - in := ScoringInputs{} - now := time.Now().UTC() - got := Score(in, defaultWeights(), now, fixedRNG(0.5)) - // rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0 - // expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0 - want := 2.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_LikeBoost(t *testing.T) { - notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(liked-notLiked-2.0) > 1e-9 { - t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked) - } -} - -func TestScore_RecencyRamp_15Days(t *testing.T) { - now := time.Now().UTC() - played := now.Add(-15 * 24 * time.Hour) - got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) - // recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5 - want := 1.5 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) { - now := time.Now().UTC() - played := now.Add(-60 * 24 * time.Hour) - got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) - want := 2.0 // base + capped recency (1.0) - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SkipRatio(t *testing.T) { - // 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5 - in := ScoringInputs{PlayCount: 4, SkipCount: 2} - got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) - // recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0; - // for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above - // explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5. - want := 1.5 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) { - in := ScoringInputs{PlayCount: 0, SkipCount: 0} - got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) - want := 2.0 // base + recency, no skip penalty - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_JitterBounds(t *testing.T) { - r := rand.New(rand.NewSource(42)) - w := defaultWeights() - now := time.Now().UTC() - mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0 - for i := 0; i < 1000; i++ { - got := Score(ScoringInputs{}, w, now, r.Float64) - if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 { - t.Fatalf("score %v outside jitter band [%v, %v]", - got, mid-w.JitterMagnitude, mid+w.JitterMagnitude) - } - } -} - -func TestScore_Determinism(t *testing.T) { - in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3} - w := defaultWeights() - now := time.Now().UTC() - a := Score(in, w, now, fixedRNG(0.7)) - b := Score(in, w, now, fixedRNG(0.7)) - if a != b { - t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b) - } -} - -func TestRecencyDecay_NilIsOne(t *testing.T) { - if got := recencyDecay(nil, time.Now()); got != 1.0 { - t.Errorf("recencyDecay(nil) = %v, want 1.0", got) - } -} - -func TestRecencyDecay_Clamping(t *testing.T) { - now := time.Now().UTC() - cases := []struct { - ageDays float64 - want float64 - }{ - {0, 0.0}, - {15, 0.5}, - {30, 1.0}, - {45, 1.0}, - } - for _, c := range cases { - t.Run("", func(t *testing.T) { - past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour))) - got := recencyDecay(&past, now) - if math.Abs(got-c.want) > 1e-9 { - t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want) - } - }) - } -} - -func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) { - if got := skipRatio(0, 0); got != 0.0 { - t.Errorf("skipRatio(0,0) = %v, want 0.0", got) - } -} - -func TestSkipRatio_Half(t *testing.T) { - if got := skipRatio(4, 2); got != 0.5 { - t.Errorf("skipRatio(4,2) = %v, want 0.5", got) - } -} -``` - -- [ ] **Step 2: Run tests, confirm fail** - -Run: `go test ./internal/recommendation -v` -Expected: FAIL — package or types undefined. - -- [ ] **Step 3: Implement `internal/recommendation/score.go`** - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -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 returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Run tests, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 11 tests. - -- [ ] **Step 5: Lint** - -Run: `golangci-lint run ./internal/recommendation/...` -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add internal/recommendation/score.go internal/recommendation/score_test.go -git commit -m "feat(recommendation): add pure Score function with recency + skip + jitter - -Implements spec §6 weighted-shuffle scoring without the -contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB -dependency; injectable RNG for deterministic tests. Coverage 100% -on score.go via the boundary tests." -``` - ---- - -## Task 2: Pure shuffle orchestration - -**Files:** -- Create: `internal/recommendation/shuffle.go` -- Create: `internal/recommendation/shuffle_test.go` - -`Shuffle` composes `Score` over a candidate set, sorts descending, truncates to `limit`. Pure. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/shuffle_test.go`: - -```go -package recommendation - -import ( - "math/rand" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func cand(id string, in ScoringInputs) Candidate { - t := dbq.Track{Title: id} - _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded - return Candidate{Track: t, Inputs: in} -} - -func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { - cs := []Candidate{ - cand("000000000001", ScoringInputs{IsGeneralLiked: false}), - cand("000000000002", ScoringInputs{IsGeneralLiked: true}), - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if out[0].Track.Title != "000000000002" { - t.Errorf("liked track did not rank first: %+v", out) - } -} - -func TestShuffle_HighSkipRanksLast(t *testing.T) { - cs := []Candidate{ - cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 - cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 - cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { - t.Errorf("skip-ratio ordering broken: %v", titles(out)) - } -} - -func TestShuffle_LimitTruncates(t *testing.T) { - cs := make([]Candidate, 100) - for i := range cs { - cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if len(out) != 10 { - t.Errorf("len = %d, want 10", len(out)) - } -} - -func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { - // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. - r := rand.New(rand.NewSource(42)) - for i := 0; i < 500; i++ { - cs := []Candidate{ - cand("000000000001", ScoringInputs{IsGeneralLiked: false}), - cand("000000000002", ScoringInputs{IsGeneralLiked: true}), - } - out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10) - if out[0].Track.Title != "000000000002" { - t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) - } - } -} - -func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { - out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if len(out) != 0 { - t.Errorf("len = %d, want 0", len(out)) - } -} - -func titles(cs []Candidate) []string { - out := make([]string, 0, len(cs)) - for _, c := range cs { - out = append(out, c.Track.Title) - } - return out -} - -// pgtype-uuid placeholder: Candidate doesn't need a real UUID for these -// pure tests; the Track.Title field is the human-readable handle. Track ID -// is left as zero pgtype.UUID and never compared. -var _ pgtype.UUID -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `go test ./internal/recommendation -run TestShuffle -v` -Expected: FAIL — `Candidate`, `Shuffle` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/shuffle.go`** - -```go -package recommendation - -import ( - "sort" - "time" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Candidate pairs a track with the inputs needed to score it. -type Candidate struct { - Track dbq.Track - Inputs ScoringInputs -} - -// Shuffle scores each candidate, sorts descending by score, and returns -// the top `limit` candidates. limit <= 0 returns nil; nil input returns -// nil. Pure — no IO, no global state beyond the rng callback. -func Shuffle( - candidates []Candidate, - weights ScoringWeights, - now time.Time, - rng func() float64, - limit int, -) []Candidate { - if len(candidates) == 0 || limit <= 0 { - return nil - } - scored := make([]struct { - c Candidate - score float64 - }, len(candidates)) - for i, c := range candidates { - scored[i].c = c - scored[i].score = Score(c.Inputs, weights, now, rng) - } - sort.Slice(scored, func(i, j int) bool { - return scored[i].score > scored[j].score - }) - if limit > len(scored) { - limit = len(scored) - } - out := make([]Candidate, limit) - for i := 0; i < limit; i++ { - out[i] = scored[i].c - } - return out -} -``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 11 score tests + 5 shuffle tests = 16 total. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/shuffle.go internal/recommendation/shuffle_test.go -git commit -m "feat(recommendation): add Shuffle orchestrator - -Composes Score over a candidate slice, sorts descending, truncates. -Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors -are stable across RNG seeds (jitter band is smaller than LikeBoost -and SkipPenalty)." -``` - ---- - -## Task 3: SQL query + sqlc generation - -**Files:** -- Create: `internal/db/queries/recommendation.sql` -- Generated: `internal/db/dbq/recommendation.sql.go` - -One query that returns all eligible tracks with their stats joined. Excludes the seed and recently-played tracks. - -- [ ] **Step 1: Write the query** - -Create `internal/db/queries/recommendation.sql`: - -```sql --- name: LoadRadioCandidates :many --- Returns all tracks except the seed and any played by the user within --- the last $3 hours, joined with the stats needed for scoring: --- is_liked — boolean from general_likes for this user --- last_played_at — max(play_events.started_at) for this user/track --- play_count — count of play_events for this user/track --- skip_count — play_events where was_skipped=true -SELECT - sqlc.embed(t), - (l.user_id IS NOT NULL) AS is_liked, - pe.last_played_at, - pe.play_count, - pe.skip_count -FROM tracks t -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 -WHERE t.id <> $2 - AND NOT EXISTS ( - SELECT 1 FROM play_events - WHERE user_id = $1 AND track_id = t.id - AND started_at > now() - $3 * interval '1 hour' - ); -``` - -- [ ] **Step 2: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: writes `internal/db/dbq/recommendation.sql.go` with `LoadRadioCandidatesParams` (UserID, TrackID for seed, third param for hours) and `LoadRadioCandidatesRow` containing `Track`, `IsLiked`, `LastPlayedAt`, `PlayCount`, `SkipCount`. - -- [ ] **Step 3: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/queries/recommendation.sql internal/db/dbq/recommendation.sql.go internal/db/dbq/models.go -git commit -m "feat(db): add LoadRadioCandidates query - -Single SELECT that joins tracks with general_likes (for is_liked) and -an aggregated LATERAL subquery on play_events (for last_played_at, -play_count, skip_count). Excludes seed + tracks played in the last -N hours. Drives the M3 weighted shuffle scoring." -``` - ---- - -## Task 4: Candidate loader - -**Files:** -- Create: `internal/recommendation/candidates.go` -- Create: `internal/recommendation/candidates_test.go` - -Wraps the sqlc query, projects rows to `[]Candidate`. Live-DB tests verify the join + exclusion logic. - -- [ ] **Step 1: Write failing tests** - -Create `internal/recommendation/candidates_test.go`: - -```go -package recommendation - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func testPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool -} - -type fixture struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - tracks []dbq.Track // tracks[0] is the canonical seed -} - -func newFixture(t *testing.T, n int) fixture { - t.Helper() - pool := testPool(t) - q := dbq.New(pool) - u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID}) - tracks := make([]dbq.Track, 0, n) - for i := 0; i < n; i++ { - tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: string(rune('A' + i)), - AlbumID: al.ID, - ArtistID: a.ID, - FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac", - DurationMs: 120_000, - }) - if err != nil { - t.Fatalf("track[%d]: %v", i, err) - } - tracks = append(tracks, tr) - } - return fixture{pool: pool, q: q, user: u.ID, tracks: tracks} -} - -func TestLoadCandidates_ExcludesSeed(t *testing.T) { - f := newFixture(t, 5) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 4 { - t.Fatalf("len = %d, want 4 (5 minus seed)", len(got)) - } - for _, c := range got { - if c.Track.ID == f.tracks[0].ID { - t.Errorf("seed appeared in candidate set") - } - } -} - -func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { - f := newFixture(t, 3) - // Seed = tracks[0]. Other tracks are tracks[1], tracks[2]. - // Insert a play_session and a play_event for tracks[1] 30 minutes ago. - now := time.Now().UTC() - thirtyMinAgo := now.Add(-30 * time.Minute) - sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ - UserID: f.user, - StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, - ClientID: nil, - }) - _, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, - TrackID: f.tracks[1].ID, - SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, - ClientID: nil, - }) - - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - // Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played). - if len(got) != 1 { - t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got)) - } - if got[0].Track.ID != f.tracks[2].ID { - t.Errorf("expected tracks[2], got %v", got[0].Track.Title) - } -} - -func TestLoadCandidates_StatJoin(t *testing.T) { - f := newFixture(t, 2) - // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. - if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { - t.Fatalf("like: %v", err) - } - twoH := time.Now().UTC().Add(-2 * time.Hour) - sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ - UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, - }) - // First play: completed (was_skipped=false). - pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, - }) - dur := int32(120_000) - ratio := 1.0 - _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ - ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true}, - DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false, - }) - // Second play: skipped (was_skipped=true). - pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true}, - }) - dur2 := int32(5_000) - ratio2 := 0.04 - _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ - ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true}, - DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, - }) - - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - c := got[0] - if !c.Inputs.IsGeneralLiked { - t.Errorf("IsGeneralLiked = false, want true") - } - if c.Inputs.PlayCount != 2 { - t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount) - } - if c.Inputs.SkipCount != 1 { - t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount) - } - if c.Inputs.LastPlayedAt == nil { - t.Errorf("LastPlayedAt = nil, want non-nil") - } -} - -func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { - f := newFixture(t, 2) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - if got[0].Inputs.LastPlayedAt != nil { - t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt) - } - if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 { - t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs) - } -} - -func TestLoadCandidates_CrossUserIsolation(t *testing.T) { - f := newFixture(t, 2) - bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, - }) - // Alice likes tracks[1]; Bob shouldn't see it. - _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) - - got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - for _, c := range got { - if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked { - t.Errorf("bob sees alice's like on track %v", c.Track.Title) - } - } -} - -func trackTitles(cs []Candidate) []string { - out := make([]string, 0, len(cs)) - for _, c := range cs { - out = append(out, c.Track.Title) - } - return out -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/recommendation -run TestLoadCandidates -v -``` -Expected: FAIL — `LoadCandidates` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/candidates.go`** - -```go -package recommendation - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LoadCandidates returns all candidate tracks for the given user, excluding -// the seed and any track played within the last `recentlyPlayedHours`. -// Each Candidate includes the per-(user,track) stats the scoring function -// needs. -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lastPlayed *pgtype.Timestamptz - if r.LastPlayedAt.Valid { - ts := r.LastPlayedAt - lastPlayed = &ts - } - var lastPlayedTime *([]byte) // placeholder to satisfy structure if needed - _ = lastPlayedTime // appease unused warning - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - }, - }) - _ = lastPlayed - } - return out, nil -} -``` - -Note: clean up the duplicated `lastPlayed` / `lastPlayedTime` shims — they're scaffolding from drafting. Final form: - -```go -package recommendation - -import ( - "context" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - }, - }) - } - return out, nil -} -``` - -Note on the sqlc-generated `Column3` field name: sqlc names unnamed parameters `Column1`, `Column2`, etc. when there's no obvious mapping. If the generated field has a different name (e.g. `Hours`), adjust the call site to match. Run sqlc generate first and inspect `internal/db/dbq/recommendation.sql.go` to confirm. - -- [ ] **Step 4: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/recommendation -v -``` -Expected: PASS — 16 prior tests + 5 new candidate tests = 21 total. - -- [ ] **Step 5: Lint** - -Run: `golangci-lint run ./internal/recommendation/...` -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go -git commit -m "feat(recommendation): add LoadCandidates DB-backed loader - -Wraps the LoadRadioCandidates sqlc query, projects rows to -[]Candidate. Live-DB tests verify seed exclusion, recently-played -exclusion, stat-join correctness (likes + plays + skips + -last_played_at), and cross-user isolation." -``` - ---- - -## Task 5: RecommendationConfig - -**Files:** -- Modify: `internal/config/config.go` -- Modify: `config.example.yaml` - -Adds operator-tunable weights to YAML / env. Defaults match the spec. - -- [ ] **Step 1: Add the struct + field + defaults** - -In `internal/config/config.go`, add `Recommendation RecommendationConfig` to the `Config` struct (alongside `Events`): - -```go -type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - Log LogConfig `yaml:"log"` - Auth AuthConfig `yaml:"auth"` - Library LibraryConfig `yaml:"library"` - Subsonic SubsonicConfig `yaml:"subsonic"` - Events EventsConfig `yaml:"events"` - Recommendation RecommendationConfig `yaml:"recommendation"` -} -``` - -Add the struct definition (after `EventsConfig`): - -```go -// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). -// All weights are operator-tunable; defaults match the spec recommendations. -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"` - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -Update `Default()` to seed the defaults: - -```go -func Default() Config { - return Config{ - Server: ServerConfig{Address: ":4533"}, - Database: DatabaseConfig{URL: ""}, - Log: LogConfig{Level: "info", Format: "text"}, - Events: EventsConfig{ - SessionTimeoutMinutes: 30, - SkipMaxCompletionRatio: 0.5, - SkipMaxDurationPlayedMs: 30000, - }, - Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, - }, - } -} -``` - -- [ ] **Step 2: Update `config.example.yaml`** - -Append after the `events:` section: - -```yaml - -recommendation: - # Base score every candidate gets before adjustments. Spec §6. - base_weight: 1.0 - # Bonus for tracks the user has liked (general_likes). - like_boost: 2.0 - # Multiplier on recency_decay (1.0 for tracks ≥ 30 days stale, 0 for fresh). - recency_weight: 1.0 - # Penalty multiplier on skip_ratio (skips/plays); 1.0 = full penalty. - skip_penalty: 1.0 - # ± random jitter applied to every candidate; breaks ties without dominating. - jitter_magnitude: 0.1 - # Hard-suppression window: tracks played within this many hours never appear. - recently_played_hours: 1 - # Default radio size when ?limit= is not specified. - radio_size: 50 - # Maximum allowed ?limit= (server caps to this regardless). - radio_size_max: 200 -``` - -- [ ] **Step 3: Run config tests** - -Run: `go test ./internal/config -v` -Expected: PASS — existing tests still pass with the new struct defaulting correctly. - -- [ ] **Step 4: Commit** - -```bash -git add internal/config/config.go config.example.yaml -git commit -m "feat(config): add recommendation section (weighted shuffle weights) - -BaseWeight, LikeBoost, RecencyWeight, SkipPenalty, JitterMagnitude, -RecentlyPlayedHours, RadioSize, RadioSizeMax. Defaults match spec §6." -``` - ---- - -## Task 6: Radio handler rewrite - -**Files:** -- Modify: `internal/api/radio.go` (replaces stub) -- Modify: `internal/api/radio_test.go` (replaces stub tests) -- Modify: `internal/api/api.go` (Mount signature gains config arg; handlers struct gains recCfg field) -- Modify: `internal/api/auth_test.go` (testHandlers seeds a default RecommendationConfig) -- Modify: `internal/api/library_test.go` (newLibraryRouter doesn't need radio routing changes; no-op verification) -- Modify: `internal/server/server.go` + `cmd/minstrel/main.go` (pass cfg through) - -Replaces the stub with the v1 weighted-shuffle implementation. - -- [ ] **Step 1: Update `handlers` struct + Mount in `internal/api/api.go`** - -```go -package api - -import ( - "log/slog" - "math/rand" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/config" - "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" -) - -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { - h := &handlers{ - pool: pool, - logger: logger, - events: events, - recCfg: recCfg, - // Seeded once at server start; per-request shuffle reads h.rng.Float64. - // Concurrent calls are fine — math/rand.Rand is safe under contention via - // a mutex internally (Go 1.21+); for stricter isolation switch to math/rand/v2. - } - rng := rand.New(rand.NewSource(rand.Int63())) - h.rng = rng.Float64 - - // ... existing route registrations unchanged ... - r.Route("/api", func(api chi.Router) { - api.Post("/auth/login", h.handleLogin) - api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) - authed.Post("/auth/logout", h.handleLogout) - authed.Get("/me", h.handleGetMe) - - authed.Get("/artists", h.handleListArtists) - authed.Get("/artists/{id}", h.handleGetArtist) - authed.Get("/albums/{id}", h.handleGetAlbum) - authed.Get("/albums/{id}/cover", h.handleGetCover) - authed.Get("/tracks/{id}", h.handleGetTrack) - authed.Get("/tracks/{id}/stream", h.handleGetStream) - authed.Get("/search", h.handleSearch) - authed.Get("/radio", h.handleRadio) - authed.Post("/events", h.handleEvents) - authed.Post("/likes/tracks/{id}", h.handleLikeTrack) - authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) - authed.Post("/likes/albums/{id}", h.handleLikeAlbum) - authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum) - authed.Post("/likes/artists/{id}", h.handleLikeArtist) - authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist) - authed.Get("/likes/tracks", h.handleListLikedTracks) - authed.Get("/likes/albums", h.handleListLikedAlbums) - authed.Get("/likes/artists", h.handleListLikedArtists) - authed.Get("/likes/ids", h.handleGetLikedIDs) - }) - }) -} - -type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 -} -``` - -(Note: `math/rand`'s top-level `Float64` is goroutine-safe, but the new-instance pattern with explicit seed is cleaner. Stay with `func() float64` so tests can inject deterministic values.) - -- [ ] **Step 2: Replace `internal/api/radio.go`** - -```go -package api - -import ( - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// RadioResponse is the body of GET /api/radio. -type RadioResponse struct { - Tracks []TrackRef `json:"tracks"` -} - -// handleRadio implements GET /api/radio?seed_track=&limit=. -// -// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. -func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { - user, ok := authUserFromContext(r) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) - if raw == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") - return - } - seedID, ok := parseUUID(raw) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") - return - } - limit := h.recCfg.RadioSize - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - if limit > h.recCfg.RadioSizeMax { - limit = h.recCfg.RadioSizeMax - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), seedID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") - return - } - h.logger.Error("api: get radio seed track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - album, err := q.GetAlbumByID(r.Context(), track.AlbumID) - if err != nil { - h.logger.Error("api: get radio seed album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), track.ArtistID) - if err != nil { - h.logger.Error("api: get radio seed artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) - 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, - } - picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) - - out := make([]TrackRef, 0, len(picks)+1) - out = append(out, trackRefFrom(track, album.Title, artist.Name)) - for _, p := range picks { - // Resolve album/artist names per pick. Reuse the existing resolver. - al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) - if err != nil { - h.logger.Error("api: radio: resolve album", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) - if err != nil { - h.logger.Error("api: radio: resolve artist", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) - } - writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) -} - -// authUserFromContext is a thin shim around auth.UserFromContext that tests -// can override. It exists so testHandlers can inject a user via the same -// context key RequireUser populates. -func authUserFromContext(r *http.Request) (dbq.User, bool) { - return userFromContext(r) -} -``` - -`userFromContext` is the existing helper used by the events handler — reuse it; if it's named differently in your tree, follow that pattern. The shim layer (`authUserFromContext`) is unnecessary if the existing handler pattern just calls `auth.UserFromContext(r.Context())` directly. Use whatever the rest of the package uses; consistency over novelty. - -- [ ] **Step 3: Update `testHandlers` in `internal/api/auth_test.go`** - -Find the existing `testHandlers` function. Update the construction: - -```go -import ( - // ... existing imports ... - "git.fabledsword.com/bvandeusen/minstrel/internal/config" -) - -func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { - // ... existing setup unchanged up to the return ... - w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, - } - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} - return h, pool -} -``` - -The fixed-RNG (`return 0.5`) makes tests deterministic — jitter contribution is always 0. - -- [ ] **Step 4: Replace `internal/api/radio_test.go`** - -Replace the existing 5 stub tests with new ones: - -```go -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil) - req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) - w := httptest.NewRecorder() - h.handleRadio(w, req) - return w -} - -func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) != 1 { - t.Fatalf("len = %d, want 1", len(resp.Tracks)) - } - if resp.Tracks[0].Title != "Seed" { - t.Errorf("seed not first: %v", resp.Tracks[0].Title) - } -} - -func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - for i := 2; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) != 6 { - t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks)) - } - if resp.Tracks[0].Title != "Seed" { - t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title) - } -} - -func TestHandleRadio_UnknownSeedIs404(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000") - if w.Code != http.StatusNotFound { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_BadSeedIs400(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "seed_track=not-a-uuid") - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_MissingSeedIs400(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "") - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_BadLimitIs400(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0") - if w.Code != http.StatusBadRequest { - t.Errorf("limit=0 status = %d", w.Code) - } - w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1") - if w.Code != http.StatusBadRequest { - t.Errorf("limit=-1 status = %d", w.Code) - } -} - -func TestHandleRadio_LimitClampedToMax(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - for i := 2; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - // We only have 6 tracks; clamped limit (max 200) returns all 6. - if len(resp.Tracks) != 6 { - t.Errorf("len = %d, want 6", len(resp.Tracks)) - } -} -``` - -- [ ] **Step 5: Update Mount call sites** - -In `internal/server/server.go`: - -```go -api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) -``` - -The `Server` struct gains `RecommendationCfg config.RecommendationConfig`; constructor `New(...)` gains the new arg. Find `cmd/minstrel/main.go`'s `server.New(...)` call and pass `cfg.Recommendation`. - -In `internal/api/library_test.go`'s `TestRoutesRegisteredInMount`: - -```go -Mount(r, h.pool, h.logger, w, config.RecommendationConfig{ - RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, -}) -``` - -In `internal/server/server_test.go`'s `New(...)` calls (multiple sites), append `config.RecommendationConfig{}` after the `subsonic.Config{}` arg. Use the existing `replace_all` approach if there are several: - -```go -New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) -``` - -- [ ] **Step 6: Run tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/radio.go internal/api/radio_test.go internal/api/api.go \ - internal/api/auth_test.go internal/api/library_test.go \ - internal/server/server.go internal/server/server_test.go \ - cmd/minstrel/main.go -git commit -m "feat(api): rewrite /api/radio with weighted shuffle v1 - -Validates seed_track + optional limit (default cfg.Recommendation.RadioSize, -clamped to RadioSizeMax). Calls recommendation.LoadCandidates + -recommendation.Shuffle. Prepends seed to result. Server seeds a -math/rand source at startup; handlers package threads that as a -func() float64 so tests inject deterministic RNGs. - -Mount + server.New gain a RecommendationConfig parameter." -``` - ---- - -## Task 7: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Coverage on recommendation package** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -coverprofile=/tmp/rec.cover ./internal/recommendation/... -go tool cover -func=/tmp/rec.cover | tail -3 -``` -Expected: total coverage ≥ 70%. - -- [ ] **Step 3: Lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 4: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; tests pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Docker build smoke** - -Run: `docker build -t minstrel:m3-shuffle-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-shuffle-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 6: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533` (sign in with the dev admin creds): - -1. Play 3 tracks all the way through (build `play_count` history). -2. Skip a 4th track within 10 seconds (`skip_count = 1` for that track). -3. Like 2 tracks via the heart button. -4. Click the radio button on a 5th track (or trigger via search → click 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. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 7: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- Pure scoring function with all 5 terms (base, like boost, recency, skip penalty, jitter) → Task 1. -- Shuffle composition (sort + truncate) → Task 2. -- Candidate loader excluding seed + recently-played → Tasks 3–4. -- Stat join (is_liked + last_played_at + play_count + skip_count) → Tasks 3–4. -- Operator-tunable weights → Task 5. -- Endpoint contract (seed first, limit default 50 / max 200, errors unchanged) → Task 6. -- Cross-user isolation → Task 4 + Task 6. -- Cold-start / never-played handling → Task 1 + Task 6. -- ≥ 70% coverage on the recommendation package → Task 7. -- End-to-end manual → Task 7. - -**Type consistency:** -- `ScoringInputs`, `ScoringWeights`, `Candidate` — same names everywhere they're consumed. -- `Score(...) → float64`, `Shuffle(...) → []Candidate`, `LoadCandidates(...) → []Candidate, error` — signatures stable. -- `recCfg config.RecommendationConfig` and `rng func() float64` — same on the handlers struct, in tests, in Mount. -- `pgtype.UUID` server-side, `string` at HTTP boundaries via `uuidToString`. - -**Filename hazards:** none — all new files under `internal/recommendation/`. The route-test handling for radio is colocated with the other api tests (no `+`-prefix concerns). - -**Placeholder scan:** no TBD/TODO/later markers. The `Column3` parameter naming caveat in Task 4 is documented inline (sqlc names unnamed positional params `Column1`, `Column2`, etc., adjust per generated code). - -**Performance note:** Task 6's per-pick album/artist resolve is N round-trips for the picks. For `RadioSize=50` that's 100 round-trips. Acceptable at v1 scale; matches the pattern used by `resolveTrackRefs` elsewhere. M4 / future task can batch via `IN (...)` + map-by-id if telemetry shows it matters. diff --git a/docs/superpowers/plans/2026-04-27-m3-similarity.md b/docs/superpowers/plans/2026-04-27-m3-similarity.md deleted file mode 100644 index b892be85..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-similarity.md +++ /dev/null @@ -1,1414 +0,0 @@ -# M3 Session Similarity + contextual_match_score 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:** Add the `contextual_match_score` term to the recommendation scoring formula by computing weighted-Jaccard similarity between the user's current session vector and each candidate track's stored `contextual_likes` session vectors. Closes M3. - -**Architecture:** New pure `Similarity` + `ContextualMatchScore` functions in `internal/recommendation/similarity.go` — set Jaccard on tags + artists, weighted 0.7/0.3, hardcoded for v1. `Score()` gains `ContextualMatchScore` input + `ContextWeight` weight. `LoadCandidates` accepts a `currentVector` and bulk-fetches the user's active `contextual_likes` once, mapping by `track_id` and computing per-candidate max similarity. `internal/api/radio.go` reads the user's most recent open session's `session_vector_at_play` via a new `GetCurrentSessionVectorForUser` query and threads it through. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-similarity-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/recommendation/similarity.go` | Pure: `SimilarityWeights` struct, `DefaultSimilarityWeights`, `Similarity(a, b SessionVector, w SimilarityWeights) float64`, `ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64`. | -| `internal/recommendation/similarity_test.go` | Pure unit tests (table-driven). | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/recommendation/score.go` | `ScoringInputs` gains `ContextualMatchScore float64`. `ScoringWeights` gains `ContextWeight float64`. `Score()` adds `+ in.ContextualMatchScore * w.ContextWeight`. | -| `internal/recommendation/score_test.go` | Three new tests for the new term. | -| `internal/recommendation/candidates.go` | `LoadCandidates` signature gains `currentVector SessionVector` parameter. Body bulk-fetches user's contextual_likes, groups by track_id, populates per-candidate `ContextualMatchScore`. | -| `internal/recommendation/candidates_test.go` | Six new tests for contextual scoring. | -| `internal/db/queries/contextual_likes.sql` | Add `ListActiveContextualLikesForUser :many`. | -| `internal/db/queries/events.sql` | Add `GetCurrentSessionVectorForUser :one`. | -| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. | -| `internal/db/dbq/events.sql.go` | Generated bindings. | -| `internal/config/config.go` | `RecommendationConfig` gains `ContextWeight float64` (yaml `context_weight`, default `2.0`). | -| `internal/api/radio.go` | Fetch current session vector before `LoadCandidates`, build `ContextWeight` into `ScoringWeights`, thread `currentVector` into `LoadCandidates`. | -| `internal/api/auth_test.go` | `recCfg` test-helper builds `ContextWeight: 2.0`. | -| `internal/api/radio_test.go` | One new end-to-end test: contextual ranking. | - -**No web changes.** - ---- - -## Task 1: Pure `Similarity` function (Jaccard + axis weights) - -**Files:** -- Create: `internal/recommendation/similarity.go` -- Create: `internal/recommendation/similarity_test.go` - -- [ ] **Step 1: Write the failing tests for `Similarity`** - -Create `internal/recommendation/similarity_test.go`: - -```go -package recommendation - -import ( - "math" - "testing" -) - -func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } - -func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) { - v := SessionVector{ - Artists: []string{"a1", "a2"}, - Tags: map[string]int{"rock": 2, "indie": 1}, - } - got := Similarity(v, v, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("Similarity(v,v) = %v, want 1.0", got) - } -} - -func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) { - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("disjoint = %v, want 0.0", got) - } -} - -func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) { - // Shared tags fully (Jaccard=1), no shared artists (Jaccard=0). - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}} - got := Similarity(a, b, DefaultSimilarityWeights) - // Expected: 0.7 * 1.0 + 0.3 * 0.0 = 0.7 - if !approxEq(got, 0.7) { - t.Errorf("tags-only = %v, want 0.7", got) - } -} - -func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) { - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - // Expected: 0.7 * 0.0 + 0.3 * 1.0 = 0.3 - if !approxEq(got, 0.3) { - t.Errorf("artists-only = %v, want 0.3", got) - } -} - -func TestSimilarity_EitherSeed_Returns0(t *testing.T) { - v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) { - t.Errorf("v vs seed = %v, want 0.0", got) - } - if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) { - t.Errorf("seed vs v = %v, want 0.0", got) - } -} - -func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) { - a := SessionVector{} - b := SessionVector{} - got := Similarity(a, b, DefaultSimilarityWeights) - if math.IsNaN(got) || !approxEq(got, 0.0) { - t.Errorf("empty = %v, want 0.0 (not NaN)", got) - } -} - -func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) { - // a has tags but no artists; b has artists but no tags. - a := SessionVector{Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a1"}} - got := Similarity(a, b, DefaultSimilarityWeights) - // tags axis: A={rock}, B={} → union={rock}, intersect={} → 0 - // artists axis: A={}, B={a1} → union={a1}, intersect={} → 0 - if !approxEq(got, 0.0) { - t.Errorf("one-axis-each = %v, want 0.0", got) - } -} - -func TestSimilarity_PartialTagsOverlap(t *testing.T) { - // Tags A={rock,indie}, B={rock,jazz}: intersect=1, union=3, J=1/3 - // Artists fully shared: J=1 - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - want := 0.7*(1.0/3.0) + 0.3*1.0 - if !approxEq(got, want) { - t.Errorf("partial = %v, want %v", got, want) - } -} - -func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) { - // Same tag keysets, different counts → set-Jaccard collapses to 1.0. - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}} - got := Similarity(a, b, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("set-collapse = %v, want 1.0", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run Similarity -v` - -Expected: FAIL with "undefined: Similarity" / "undefined: DefaultSimilarityWeights". - -- [ ] **Step 3: Write minimal `Similarity` implementation** - -Create `internal/recommendation/similarity.go`: - -```go -package recommendation - -// SimilarityWeights balances the per-axis contribution to the weighted Jaccard -// score. v1 hardcodes the defaults — operators cannot tune via YAML. If -// telemetry justifies it, expose under recommendation.similarity.* later. -type SimilarityWeights struct { - TagsWeight float64 - ArtistsWeight float64 -} - -// DefaultSimilarityWeights is the v1 axis balance per the M3 design. -// Tags carry more signal than artists because a session's "vibe" tracks -// genre more directly than artist identity (a session can mix artists -// within a genre but rarely mixes genres). -var DefaultSimilarityWeights = SimilarityWeights{ - TagsWeight: 0.7, - ArtistsWeight: 0.3, -} - -// Similarity returns weighted-Jaccard similarity in [0, 1] between two -// session vectors. Returns 0 if either input is Seed=true (low-confidence -// vectors don't contribute to scoring). -func Similarity(a, b SessionVector, w SimilarityWeights) float64 { - if a.Seed || b.Seed { - return 0.0 - } - tagJ := setJaccardKeys(a.Tags, b.Tags) - artistJ := setJaccardSlice(a.Artists, b.Artists) - return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight -} - -// setJaccardKeys collapses two map keysets to sets and returns -// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). -func setJaccardKeys(a, b map[string]int) float64 { - if len(a) == 0 && len(b) == 0 { - return 0.0 - } - intersect := 0 - for k := range a { - if _, ok := b[k]; ok { - intersect++ - } - } - union := len(a) + len(b) - intersect - if union == 0 { - return 0.0 - } - return float64(intersect) / float64(union) -} - -// setJaccardSlice deduplicates each input slice into a set and returns -// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). -func setJaccardSlice(a, b []string) float64 { - if len(a) == 0 && len(b) == 0 { - return 0.0 - } - aset := make(map[string]struct{}, len(a)) - for _, x := range a { - aset[x] = struct{}{} - } - bset := make(map[string]struct{}, len(b)) - for _, x := range b { - bset[x] = struct{}{} - } - intersect := 0 - for k := range aset { - if _, ok := bset[k]; ok { - intersect++ - } - } - union := len(aset) + len(bset) - intersect - if union == 0 { - return 0.0 - } - return float64(intersect) / float64(union) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run Similarity -v` - -Expected: PASS for all 9 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go -git commit -m "feat(recommendation): add pure Similarity function with weighted Jaccard" -``` - ---- - -## Task 2: `ContextualMatchScore` convenience function - -**Files:** -- Modify: `internal/recommendation/similarity.go` (append) -- Modify: `internal/recommendation/similarity_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/similarity_test.go`: - -```go -func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) { - current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - got := ContextualMatchScore(current, nil, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("no likes = %v, want 0.0", got) - } - got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("empty likes = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) { - current := SessionVector{Seed: true} - likes := []SessionVector{ - {Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("current seed = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) { - current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("all-seed likes = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_TakesMax(t *testing.T) { - // Three likes: full match, partial match, mismatch. Expect full match (1.0). - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, // 1.0 - {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, // 0.7 - {Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, // 0.0 - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("takes-max = %v, want 1.0", got) - } -} - -func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) { - // One Seed=true match (would be 1.0 if not filtered) + one partial match. - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, - {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - // Seed=true filtered out → only partial match counts → 0.7 - if !approxEq(got, 0.7) { - t.Errorf("filter-then-max = %v, want 0.7", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` - -Expected: FAIL with "undefined: ContextualMatchScore". - -- [ ] **Step 3: Append `ContextualMatchScore` to `similarity.go`** - -Append to `internal/recommendation/similarity.go`: - -```go -// ContextualMatchScore returns the maximum Similarity between the current -// session vector and any non-seed entry in likes. Returns 0 when: -// - current.Seed is true (no meaningful current context) -// - likes is empty after filtering out Seed=true entries -// -// The "max" semantics means a single strong contextual match dominates -// over many weak ones — we want to surface the track because it was liked -// in *some* matching context, not because it was vaguely-liked in many. -func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 { - if current.Seed { - return 0.0 - } - best := 0.0 - for _, l := range likes { - if l.Seed { - continue - } - s := Similarity(current, l, w) - if s > best { - best = s - } - } - return best -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` - -Expected: PASS for all 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go -git commit -m "feat(recommendation): add ContextualMatchScore (max over non-seed likes)" -``` - ---- - -## Task 3: Extend `Score` with `ContextualMatchScore` + `ContextWeight` - -**Files:** -- Modify: `internal/recommendation/score.go` -- Modify: `internal/recommendation/score_test.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/score_test.go`: - -```go -func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.ContextWeight = 2.0 - in := ScoringInputs{ContextualMatchScore: 1.0} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0 - want := 4.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.ContextWeight = 2.0 - in := ScoringInputs{ContextualMatchScore: 0.5} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 + contextual 1.0 = 3.0 - want := 3.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { - wWithCtx := defaultWeights() - wWithCtx.ContextWeight = 2.0 - withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5)) - withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(withCtx-withoutCtx) > 1e-9 { - t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run Score -v` - -Expected: FAIL — `ScoringInputs` has no `ContextualMatchScore`, `ScoringWeights` has no `ContextWeight`. - -- [ ] **Step 3: Extend `score.go`** - -Modify `internal/recommendation/score.go`. Replace the file contents with: - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// ContextualMatchScore is in [0, 1] — max similarity between the user's -// current session vector and any non-seed contextual_like row for this -// track. Set by LoadCandidates after a bulk fetch. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true - ContextualMatchScore float64 // [0, 1]; 0 when no signal -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + contextual_match_score * ContextWeight -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -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 += in.ContextualMatchScore * w.ContextWeight - s += (rng()*2 - 1) * w.JitterMagnitude - return s -} - -// recencyDecay returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run Score -v` - -Expected: PASS for all existing Score tests + 3 new contextual tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/score.go internal/recommendation/score_test.go -git commit -m "feat(recommendation): extend Score with ContextualMatchScore + ContextWeight" -``` - ---- - -## Task 4: New sqlc queries for current vector + contextual likes lookup - -**Files:** -- Modify: `internal/db/queries/contextual_likes.sql` -- Modify: `internal/db/queries/events.sql` -- Generated: `internal/db/dbq/contextual_likes.sql.go` -- Generated: `internal/db/dbq/events.sql.go` - -- [ ] **Step 1: Add `ListActiveContextualLikesForUser` query** - -Append to `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 is bounded by the user's actual like-while- --- playing history — typically tens to low hundreds. Used by the engine to --- compute contextual_match_score for the candidate pool. -SELECT track_id, session_vector -FROM contextual_likes -WHERE user_id = $1 - AND deleted_at IS NULL - AND session_vector IS NOT NULL; -``` - -- [ ] **Step 2: Add `GetCurrentSessionVectorForUser` query** - -Append to `internal/db/queries/events.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. --- Joined with play_sessions so closed sessions don't leak stale vectors. -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; -``` - -- [ ] **Step 3: Run sqlc generate** - -Run: `make generate` (or `cd internal/db && sqlc generate` if the Makefile target differs). - -Expected: `internal/db/dbq/contextual_likes.sql.go` gains `ListActiveContextualLikesForUser` method; `internal/db/dbq/events.sql.go` gains `GetCurrentSessionVectorForUser`. - -- [ ] **Step 4: Verify compile** - -Run: `go build ./...` - -Expected: clean compile (no test-side changes yet, just generated bindings). - -- [ ] **Step 5: Commit** - -```bash -git add internal/db/queries/contextual_likes.sql internal/db/queries/events.sql internal/db/dbq/ -git commit -m "feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser)" -``` - ---- - -## Task 5: Extend `LoadCandidates` to compute per-candidate contextual scores - -**Files:** -- Modify: `internal/recommendation/candidates.go` -- Modify: `internal/recommendation/candidates_test.go` - -- [ ] **Step 1: Write failing tests for `LoadCandidates` contextual behavior** - -Append to `internal/recommendation/candidates_test.go`: - -```go -import ( - "context" - "encoding/json" - "testing" - "time" -) - -// helperInsertContextualLike inserts a contextual_like row with the given -// session_vector marshaled to JSON. Returns nothing — the test asserts via -// LoadCandidates output. Bypasses playevents.CaptureContextualLikeIfPlaying -// because we want full control over the stored vector for these unit tests. -func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) { - t.Helper() - raw, err := json.Marshal(vec) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, - f.user, trackID, raw); err != nil { - t.Fatalf("insert: %v", err) - } -} - -func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) { - f := newFixture(t, 5) - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - if err != nil { - t.Fatalf("load: %v", err) - } - var found bool - for _, c := range got { - if c.Track.ID == target.ID { - found = true - if c.Inputs.ContextualMatchScore < 0.99 { - t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore) - } - } else { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore) - } - } - } - if !found { - t.Error("target track missing from candidate list") - } -} - -func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - // Three likes on the same track: weak, strong, medium. Expect strong. - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}, - }) - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, - }) - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}, - }) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 { - t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - // Soft-delete the row. - if _, err := f.pool.Exec(context.Background(), - `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil { - t.Fatalf("soft-delete: %v", err) - } - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - // Only a Seed=true contextual_like exists. - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, - }) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - - currentSeed := SessionVector{Seed: true} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} -``` - -Then update existing test call sites (`TestLoadCandidates_ExcludesSeed` and any other extant calls in this file): each `LoadCandidates(...)` call gets a sixth argument. For tests that don't care about contextual scoring, pass `SessionVector{Seed: true}` — that short-circuits the term to 0 and matches v1 behavior. - -Replace existing call patterns: - -```go -got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) -``` - -with: - -```go -got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) -``` - -(There are 5 such call sites in the existing `candidates_test.go`. Update them all.) - -Update the `import` block at the top to add `"encoding/json"` if not present. - -- [ ] **Step 2: Run tests to verify the new ones fail and existing ones still compile** - -Run: `go test ./internal/recommendation/ -run LoadCandidates -v` - -Expected: existing tests fail to compile because `LoadCandidates` only takes 5 args. After updating call sites, they pass; new tests fail with "too few arguments" or "ContextualMatchScore not set". - -- [ ] **Step 3: Update `LoadCandidates` signature + body** - -Replace `internal/recommendation/candidates.go` contents: - -```go -package recommendation - -import ( - "context" - "encoding/json" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LoadCandidates fetches the candidate pool for radio scoring. Combines -// the existing track+stats query with a one-shot bulk fetch of the user's -// active contextual_likes, mapping each candidate to its max similarity -// against currentVector. Pass currentVector with Seed=true to short-circuit -// the contextual term to 0 (cold-start path). -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, - currentVector SessionVector, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - - likes, err := loadContextualLikesByTrack(ctx, q, userID) - if err != nil { - return nil, err - } - - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - ContextualMatchScore: ctxScore, - }, - }) - } - return out, nil -} - -// loadContextualLikesByTrack fetches the user's active contextual_likes in -// one query and groups them by track_id. Rows whose session_vector fails -// to unmarshal are skipped with no error (don't poison scoring over one -// bad row); the SQL query already filters NULL vectors. -func loadContextualLikesByTrack( - ctx context.Context, - q *dbq.Queries, - userID pgtype.UUID, -) (map[pgtype.UUID][]SessionVector, error) { - rows, err := q.ListActiveContextualLikesForUser(ctx, userID) - if err != nil { - return nil, err - } - out := make(map[pgtype.UUID][]SessionVector, len(rows)) - for _, r := range rows { - var v SessionVector - if err := json.Unmarshal(r.SessionVector, &v); err != nil { - continue - } - out[r.TrackID] = append(out[r.TrackID], v) - } - return out, nil -} -``` - -- [ ] **Step 4: Run tests to verify all pass** - -Run: `go test ./internal/recommendation/ -v` - -Expected: PASS for all existing tests + 6 new contextual tests. - -- [ ] **Step 5: Verify other callers compile** - -Run: `go build ./...` - -Expected: `internal/api/radio.go` fails — it still calls `LoadCandidates` with 5 args. We fix that in Task 6. - -- [ ] **Step 6: Commit (allow temporary build break — fixed in Task 6)** - -```bash -git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go -git commit -m "feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore" -``` - -Note: this commit leaves `internal/api/radio.go` non-compiling. Task 6 restores green. Don't push the branch in this state — finish Task 6 first. - ---- - -## Task 6: Config + radio handler wiring - -**Files:** -- Modify: `internal/config/config.go` -- Modify: `internal/api/radio.go` -- Modify: `internal/api/auth_test.go` - -- [ ] **Step 1: Add `ContextWeight` to `RecommendationConfig`** - -In `internal/config/config.go`, modify the `RecommendationConfig` struct: - -```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"` - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -In the same file, modify `Default()`'s `Recommendation` block to include the new field: - -```go -Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, -}, -``` - -- [ ] **Step 2: Update `radio.go` to fetch current vector and pass it through** - -Replace `internal/api/radio.go` contents: - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// RadioResponse is the body of GET /api/radio. -type RadioResponse struct { - Tracks []TrackRef `json:"tracks"` -} - -// handleRadio implements GET /api/radio?seed_track=&limit=. -// -// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. The -// scoring formula folds in contextual_match_score using the user's current -// session vector (read from the most recent open play_event). -func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) - if raw == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") - return - } - seedID, ok := parseUUID(raw) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") - return - } - limit := h.recCfg.RadioSize - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - if limit > h.recCfg.RadioSizeMax { - limit = h.recCfg.RadioSizeMax - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), seedID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") - return - } - h.logger.Error("api: get radio seed track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - album, err := q.GetAlbumByID(r.Context(), track.AlbumID) - if err != nil { - h.logger.Error("api: get radio seed album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), track.ArtistID) - if err != nil { - h.logger.Error("api: get radio seed artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - - currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - - 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, - } - picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) - - out := make([]TrackRef, 0, len(picks)+1) - out = append(out, trackRefFrom(track, album.Title, artist.Name)) - for _, p := range picks { - al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) - if err != nil { - h.logger.Error("api: radio: resolve album", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) - if err != nil { - h.logger.Error("api: radio: resolve artist", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) - } - writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) -} - -// loadCurrentSessionVector returns the user's most recent active session -// vector, or a Seed=true sentinel if none exists / the column is NULL / -// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore -// to 0 so the contextual term contributes nothing in cold-start cases. -func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { - raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) - if err != nil { - // pgx.ErrNoRows is the common path: no active session yet. - if !errors.Is(err, pgx.ErrNoRows) { - logger.Warn("api: radio: load current session vector", "err", err) - } - return recommendation.SessionVector{Seed: true} - } - if len(raw) == 0 { - return recommendation.SessionVector{Seed: true} - } - var v recommendation.SessionVector - if jerr := json.Unmarshal(raw, &v); jerr != nil { - logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) - return recommendation.SessionVector{Seed: true} - } - return v -} -``` - -Add the missing imports at the top: `"log/slog"` and `"github.com/jackc/pgx/v5/pgtype"`. - -- [ ] **Step 3: Update `auth_test.go` test helper to include `ContextWeight`** - -In `internal/api/auth_test.go`, modify the `recCfg` definition inside `testHandlers`: - -```go -recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, -} -``` - -- [ ] **Step 4: Run full build + existing tests** - -Run: `go build ./...` - -Expected: clean compile. - -Run: `go test ./internal/api/ ./internal/recommendation/ ./internal/config/ -v -short` - -Expected: PASS. The `-short` flag lets unit tests run; integration tests need `MINSTREL_TEST_DATABASE_URL`. - -If `MINSTREL_TEST_DATABASE_URL` is set, drop `-short` and verify integration tests pass: - -Run: `go test ./internal/api/ ./internal/recommendation/ -v` - -Expected: PASS. All existing radio tests still pass — `currentVec` defaults to `Seed=true` for users with no plays, so behavior is identical to v1. - -- [ ] **Step 5: Commit** - -```bash -git add internal/config/config.go internal/api/radio.go internal/api/auth_test.go -git commit -m "feat(api): radio handler reads current session vector + threads ContextWeight" -``` - ---- - -## Task 7: End-to-end test — contextual match boosts ranking - -**Files:** -- Modify: `internal/api/radio_test.go` - -- [ ] **Step 1: Write the failing end-to-end test** - -Append to `internal/api/radio_test.go`: - -```go -func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - - // Two artists in distinct genres, so we can construct a "rock vibe" session - // and a contextual match. - rockArtist := seedArtist(t, pool, "RockArtist") - rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020) - jazzArtist := seedArtist(t, pool, "JazzArtist") - jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020) - - // Seed track is unrelated to both (don't want it to dominate scoring). - popArtist := seedArtist(t, pool, "PopArtist") - popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020) - seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000) - - // Target: a rock track. Control: a jazz track. Both will be scored. - target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock") - control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz") - - // Build the user's "rock vibe" current context: insert an open play_session - // with a play_event whose session_vector_at_play matches the rock vibe. - rockVec := recommendation.SessionVector{ - Artists: []string{toUUIDString(rockArtist.ID)}, - Tags: map[string]int{"rock": 3}, - } - rockVecJSON, _ := json.Marshal(rockVec) - insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON) - - // Insert a contextual_like on the target track whose stored vector matches - // the rock vibe. (Direct DB insert — we want full control over the vector - // for this test, not whatever playevents.CaptureContextualLikeIfPlaying - // would produce.) - if _, err := pool.Exec(context.Background(), - `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, - user.ID, target.ID, rockVecJSON); err != nil { - t.Fatalf("insert contextual_like: %v", err) - } - - // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0) - // means rankings are reproducible for this test. - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var resp RadioResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - - // Find the indexes of target and control in the response. - targetIdx, controlIdx := -1, -1 - for i, tr := range resp.Tracks { - if tr.Title == "Target" { - targetIdx = i - } - if tr.Title == "Control" { - controlIdx = i - } - } - if targetIdx == -1 || controlIdx == -1 { - t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks) - } - if targetIdx >= controlIdx { - t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx) - } -} -``` - -- [ ] **Step 2: Add the helper `seedTrackWithGenre` and `insertOpenSessionWithVector`** - -These don't exist yet. Add them to `internal/api/radio_test.go` (or to whichever helpers file the existing `seedTrack` lives in — keep them adjacent): - -```go -func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNo, durationMs int, genre string) dbq.Track { - t.Helper() - q := dbq.New(pool) - tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: albumID, - ArtistID: artistID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: int32(durationMs), - Genre: &genre, - }) - if err != nil { - t.Fatalf("UpsertTrack: %v", err) - } - return tr -} - -// insertOpenSessionWithVector creates a play_session with ended_at NULL and -// inserts a play_event in it whose session_vector_at_play is the given JSON. -// Used to simulate "user is mid-listen" for the radio handler's current-vector -// lookup. The play_event itself doesn't reference any of the test tracks. -func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { - t.Helper() - // Create a placeholder track so the play_event has a valid track_id. - q := dbq.New(pool) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, - }) - ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, - FilePath: "/tmp/placeholder.flac", DurationMs: 100_000, - }) - if err != nil { - t.Fatalf("placeholder track: %v", err) - } - // Insert open session. - var sessionID pgtype.UUID - if err := pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - userID).Scan(&sessionID); err != nil { - t.Fatalf("insert session: %v", err) - } - // Insert play_event with the given session_vector_at_play. - if _, err := pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) - VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, - userID, ph.ID, sessionID, vectorJSON); err != nil { - t.Fatalf("insert play_event: %v", err) - } -} - -// toUUIDString converts a pgtype.UUID to its canonical hex string. Mirrors -// what BuildSessionVector emits, so the test's session_vector matches what -// the engine would produce in production. -func toUUIDString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - return u.String() -} -``` - -Add the missing imports at the top of `radio_test.go`: `"github.com/jackc/pgx/v5/pgtype"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"`. (Some may already exist.) - -Also: the `truncateLibrary` helper used by other tests probably truncates `play_events, play_sessions, sessions, users, tracks, albums, artists, general_likes`. We need it to also include `contextual_likes`. Find the helper (probably in `auth_test.go` or `radio_test.go`) and add `contextual_likes` to its TRUNCATE list: - -```go -func truncateLibrary(t *testing.T, pool *pgxpool.Pool) { - t.Helper() - if _, err := pool.Exec(context.Background(), - "TRUNCATE contextual_likes, general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } -} -``` - -(If `truncateLibrary` already exists, add `contextual_likes` to its TRUNCATE list. If it doesn't exist as a named helper, find the inline TRUNCATE in `testHandlers` and update that.) - -- [ ] **Step 3: Run the new test** - -Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ -run TestHandleRadio_ContextualMatch_BoostsRankingOverControl -v` - -Expected: PASS. Target track appears at a lower index than Control in the response. - -- [ ] **Step 4: Run the full integration suite** - -Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ ./internal/recommendation/ -v` - -Expected: PASS. Existing tests unaffected; new contextual test passes. - -- [ ] **Step 5: Commit** - -```bash -git add internal/api/radio_test.go internal/api/auth_test.go -git commit -m "test(api): end-to-end contextual ranking test for /api/radio" -``` - -(Only commit `auth_test.go` here if you didn't already in Task 6 — if `truncateLibrary` lives there and got modified, include it.) - ---- - -## Task 8: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite** - -Run: `go test -short -race ./...` - -Expected: PASS. All packages, no race conditions. - -- [ ] **Step 2: Full integration suite (with DB)** - -Run: `MINSTREL_TEST_DATABASE_URL= go test -race ./...` - -Expected: PASS. - -- [ ] **Step 3: Lint** - -Run: `golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check** - -Run: `go test -short -coverprofile=cover.out ./internal/recommendation/ ./internal/playevents/ && go tool cover -func=cover.out | tail -1` - -Expected: combined coverage ≥ 70% (target). With pure functions in similarity.go heavily tested, we expect to land at 80%+. - -- [ ] **Step 5: Web verification (sanity)** - -Run: `cd web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0, all vitest tests pass, build succeeds. (No web changes in this slice, so this should be clean.) - -- [ ] **Step 6: Docker smoke (if present locally)** - -Run: `make docker-build && make docker-smoke` (or whatever the project's smoke-test target is). - -Expected: container builds; smoke check (`/healthz`, `/api/auth/login`) returns expected codes. - -- [ ] **Step 7: Manual end-to-end verification** - -Start the server. As an authenticated user: - -1. Play a few tracks of one genre (e.g., 3+ rock tracks) via the web UI to populate a session vector. -2. Like one of those tracks (creates a contextual_like with the rock-vibe vector). -3. Wait for the recently-played-hours window to clear, OR pick tracks not yet recently played. -4. Click "Play radio" from a different track. -5. Inspect: the previously-liked rock track should appear in the radio queue with a noticeably-likely-to-rank-high position. Compare against a jazz track that the user has NEVER liked — it should be ranked lower. - -Verification is qualitative for v1; the integration test in Task 7 is the deterministic guarantee. - -- [ ] **Step 8: Update Fable task #342** - -Set status to `in_progress` (after PR opens). After PR merges, mark `done` with the closing summary in the body. The task tracking should mention: - -- Closes the M3 milestone -- All three v1 components shipped: weighted shuffle (#340), session vectors (#341), contextual matching (this slice) -- Coverage targets met -- Backwards-compat: `/api/radio` API shape unchanged - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR (no bundling with future M3.5 / M4 work). Once merged, M3 is closed. - ---- - -## Self-Review - -**Spec coverage:** - -- §3.1 (similarity.go) → Tasks 1, 2 ✓ -- §3.2 (ListActiveContextualLikesForUser) → Task 4 ✓ -- §3.3 (GetCurrentSessionVectorForUser) → Task 4 ✓ -- §3.4 (Score extension) → Task 3 ✓ -- §3.5 (LoadCandidates extension) → Task 5 ✓ -- §3.6 (radio.go wiring) → Task 6 ✓ -- §3.7 (config) → Task 6 ✓ -- §4 (request flow) → Tasks 5+6 (composition) ✓ -- §5 (cold-start) → Task 5 covers Seed/empty paths; Task 6 covers no-session/NULL paths ✓ -- §6.1 (similarity_test) → Task 1 ✓ -- §6.2 (score_test) → Task 3 ✓ -- §6.3 (candidates_test) → Task 5 ✓ -- §6.4 (radio_test) → Task 7 ✓ -- §6.5 (coverage gate) → Task 8 ✓ -- §7 (backwards compat) → preserved by zero-value semantics in Task 3 + cold-start handling in Tasks 5-6 ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. - -**Type consistency:** Verified — `SessionVector`, `Similarity`, `ContextualMatchScore`, `ScoringInputs.ContextualMatchScore`, `ScoringWeights.ContextWeight`, `RecommendationConfig.ContextWeight`, `LoadCandidates`'s sixth parameter `currentVector SessionVector`, `loadContextualLikesByTrack` return type `map[pgtype.UUID][]SessionVector`, `loadCurrentSessionVector` return type `recommendation.SessionVector` — all consistent across tasks. diff --git a/docs/superpowers/plans/2026-04-27-m3-vectors.md b/docs/superpowers/plans/2026-04-27-m3-vectors.md deleted file mode 100644 index a84b2ae7..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-vectors.md +++ /dev/null @@ -1,1491 +0,0 @@ -# M3 Session Vectors + Contextual Likes 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:** Add the two write paths that produce contextual data for the recommendation engine: (1) compute and persist a session vector at every play_started; (2) snapshot that vector into `contextual_likes` whenever a user likes a track during an active play_event. Soft-delete contextual_likes on unlike. Backend-only — no UI surface. - -**Architecture:** New migration `0007_contextual_likes` creates the missing table (it was referenced in 0005's comments but never CREATE'd). New pure function `BuildSessionVector(priorTracks)` produces the JSONB shape from spec §6. `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` extend in-transaction to compute and UPDATE the vector after inserting the play_event. `internal/api/likes.go` and `internal/subsonic/star.go` extend their like/unlike paths via two shared helpers in `internal/playevents` (so both surfaces capture/soft-delete uniformly). - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-vectors-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | `contextual_likes` table + partial index (active rows) + GIN index (session_vector). | -| `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 []dbq.Track) SessionVector` pure function. | -| `internal/recommendation/sessionvector_test.go` | Pure unit tests for the function (boundary cases). | -| `internal/playevents/contextual_likes.go` | Two shared helpers: `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` and `SoftDeleteContextualLikes(ctx, q, userID, trackID)`. Imported by both `internal/api` and `internal/subsonic`. | -| `internal/playevents/contextual_likes_test.go` | Live-DB tests for the helpers. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` and `UpdatePlayEventVector(id, vector) :exec`. | -| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` (returns int64 affected count). | -| `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → marshal JSON → UpdatePlayEventVector. Extracted into a shared private helper. | -| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated, session-scope isolation). | -| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, call `playevents.CaptureContextualLikeIfPlaying`. | -| `internal/api/likes.go::handleUnlikeTrack` | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. | -| `internal/api/likes_test.go` | Five new tests (capture during open play, no capture without play, no duplicate on idempotent re-like, soft-delete on unlike, history accumulation). | -| `internal/subsonic/star.go::handleStar` (track branch) | After `LikeTrack`, capture rows count and call `playevents.CaptureContextualLikeIfPlaying`. | -| `internal/subsonic/star.go::handleUnstar` (track branch) | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. | -| `internal/subsonic/star_test.go` | One new test verifying contextual capture through the Subsonic path. | - -**No web changes.** - ---- - -## Task 1: Migration 0007 + contextual_likes sqlc queries - -**Files:** -- Create: `internal/db/migrations/0007_contextual_likes.up.sql` -- Create: `internal/db/migrations/0007_contextual_likes.down.sql` -- Create: `internal/db/queries/contextual_likes.sql` -- Generated: `internal/db/dbq/contextual_likes.sql.go` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0007_contextual_likes.up.sql`: - -```sql --- contextual_likes captures a per-session-context snapshot at the time of --- each like. The recommendation engine in M3 sub-plan #3 uses these rows --- 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 (deleted_at populated) 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). --- --- Note: the M2 events migration (0005) commented that contextual_likes --- "ships nullable now" but the actual CREATE TABLE was missed. This slice --- ships the table for the first time. - -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 -); - --- Hot path for both the engine's lookups and the soft-delete UPDATE. -CREATE INDEX contextual_likes_active_idx - ON contextual_likes (user_id, track_id) - WHERE deleted_at IS NULL; - --- Vector similarity queries from M3 sub-plan #3 will use this. -CREATE INDEX contextual_likes_vector_idx - ON contextual_likes USING gin (session_vector); -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0007_contextual_likes.down.sql`: - -```sql -DROP TABLE IF EXISTS contextual_likes; -``` - -- [ ] **Step 3: Write sqlc queries** - -Create `internal/db/queries/contextual_likes.sql`: - -```sql --- name: InsertContextualLike :exec -INSERT INTO contextual_likes (user_id, track_id, session_vector, session_id) -VALUES ($1, $2, $3, $4); - --- name: SoftDeleteContextualLikesForUserTrack :exec --- Marks all currently-active rows for (user, track) as deleted. Idempotent — --- already-deleted rows aren't re-touched. -UPDATE contextual_likes -SET deleted_at = now() -WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL; -``` - -- [ ] **Step 4: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: writes `internal/db/dbq/contextual_likes.sql.go` with `InsertContextualLikeParams { UserID pgtype.UUID; TrackID pgtype.UUID; SessionVector []byte; SessionID pgtype.UUID }` and `SoftDeleteContextualLikesForUserTrackParams { UserID pgtype.UUID; TrackID pgtype.UUID }`. Also adds a `ContextualLike` model type to `models.go`. - -- [ ] **Step 5: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 6: Migration smoke** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/db -run TestMigrate -v -``` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/db/migrations/0007_contextual_likes.up.sql \ - internal/db/migrations/0007_contextual_likes.down.sql \ - internal/db/queries/contextual_likes.sql \ - internal/db/dbq/contextual_likes.sql.go \ - internal/db/dbq/models.go -git commit -m "feat(db): add contextual_likes table (M3 session-context capture) - -Table was referenced in migration 0005's comment but never created — -this slice fills the gap. Soft-delete via deleted_at column; hot-path -partial index on active rows; GIN index on session_vector for -M3 sub-plan #3's similarity queries." -``` - ---- - -## Task 2: Vector capture queries in events.sql - -**Files:** -- Modify: `internal/db/queries/events.sql` (append two new queries) -- Generated: `internal/db/dbq/events.sql.go` (regenerated) -- Modify: `internal/db/queries/likes.sql` (`LikeTrack` becomes `:execrows`) - -The vector capture in `RecordPlayStarted` needs (a) a query that lists the prior tracks in a session and (b) a query that updates `play_events.session_vector_at_play`. Bundle the `LikeTrack` `:execrows` change here too — same generated-bindings cycle. - -- [ ] **Step 1: Append `ListRecentSessionTracks` and `UpdatePlayEventVector` to `internal/db/queries/events.sql`** - -```sql --- name: ListRecentSessionTracks :many --- Returns up to $3 tracks in session $1 whose play_event started before --- $2, ordered newest-first. Used by playevents.RecordPlayStarted to --- build the session_vector for the just-inserted play_event. -SELECT t.* FROM tracks t -JOIN play_events pe ON pe.track_id = t.id -WHERE pe.session_id = $1 - AND pe.started_at < $2 -ORDER BY pe.started_at DESC -LIMIT $3; - --- name: UpdatePlayEventVector :exec --- Used right after InsertPlayEvent to populate session_vector_at_play --- once the vector has been computed. -UPDATE play_events -SET session_vector_at_play = $2 -WHERE id = $1; -``` - -- [ ] **Step 2: Change `LikeTrack` to `:execrows` in `internal/db/queries/likes.sql`** - -Find the existing `-- name: LikeTrack :exec` block. Replace the annotation: - -```sql --- name: LikeTrack :execrows -INSERT INTO general_likes (user_id, track_id) -VALUES ($1, $2) -ON CONFLICT (user_id, track_id) DO NOTHING; -``` - -(The SQL body is unchanged; only the `:exec` → `:execrows` annotation changes.) - -- [ ] **Step 3: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: -- `internal/db/dbq/events.sql.go` gains `ListRecentSessionTracks` and `UpdatePlayEventVector` functions, plus their param structs. -- `internal/db/dbq/likes.sql.go` — `LikeTrack` signature changes from `(ctx, params) error` to `(ctx, params) (int64, error)`. Build will break at every existing call site. - -- [ ] **Step 4: Update existing `LikeTrack` callers to ignore the count** - -Two sites need updating right now to keep the build green; the next tasks will use the count meaningfully. - -In `internal/api/likes.go`: -```go -// Before: -if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { -// After: -if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { -``` - -In `internal/subsonic/star.go`: -```go -// Before: -if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { -// After: -if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { -``` - -- [ ] **Step 5: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 6: Run existing test suite** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. Existing like tests still work because the behavior is unchanged at this stage; only the function signature evolved. - -- [ ] **Step 7: Commit** - -```bash -git add internal/db/queries/events.sql internal/db/queries/likes.sql \ - internal/db/dbq/events.sql.go internal/db/dbq/likes.sql.go \ - internal/api/likes.go internal/subsonic/star.go -git commit -m "feat(db): add session-vector capture queries; LikeTrack returns row count - -ListRecentSessionTracks + UpdatePlayEventVector for the vector capture -path inside RecordPlayStarted. LikeTrack switches to :execrows so the -contextual-likes capture can detect insert vs already-exists. Existing -callers updated to ignore the count for now; later tasks consume it." -``` - ---- - -## Task 3: Pure `BuildSessionVector` function - -**Files:** -- Create: `internal/recommendation/sessionvector.go` -- Create: `internal/recommendation/sessionvector_test.go` - -Pure function, no DB. Produces the JSONB-serializable struct from a slice of `dbq.Track`. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/sessionvector_test.go`: - -```go -package recommendation - -import ( - "encoding/json" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func track(artistID pgtype.UUID, genre string) dbq.Track { - t := dbq.Track{ - ArtistID: artistID, - } - if genre != "" { - g := genre - t.Genre = &g - } - return t -} - -func uuid(s string) pgtype.UUID { - var u pgtype.UUID - _ = u.Scan(s) - return u -} - -func TestBuildSessionVector_Empty_IsSeed(t *testing.T) { - v := BuildSessionVector(nil) - if !v.Seed { - t.Errorf("Seed = false, want true for empty input") - } - if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 { - t.Errorf("non-empty collections: %+v", v) - } -} - -func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - tr := track(a1, "rock") - tr.ID = uuid("22222222-2222-2222-2222-222222222222") - v := BuildSessionVector([]dbq.Track{tr}) - if !v.Seed { - t.Errorf("1 track: Seed=false, want true (< 3)") - } - if len(v.Artists) != 1 { - t.Errorf("Artists: %+v", v.Artists) - } - if v.Tags["rock"] != 1 { - t.Errorf("Tags: %+v", v.Tags) - } - if len(v.RecentTrackIDs) != 1 { - t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs) - } -} - -func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")}) - if !v.Seed { - t.Errorf("2 tracks: Seed=false, want true (< 3)") - } -} - -func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), track(a1, "rock"), track(a1, "rock"), - }) - if v.Seed { - t.Errorf("3 tracks: Seed=true, want false") - } - // All same artist → 1 entry. - if len(v.Artists) != 1 { - t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists)) - } - // All same genre → count 3. - if v.Tags["rock"] != 3 { - t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"]) - } -} - -func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - a2 := uuid("22222222-2222-2222-2222-222222222222") - a3 := uuid("33333333-3333-3333-3333-333333333333") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), - track(a2, "jazz"), - track(a3, "rock"), - track(a1, "experimental"), - }) - // 4 tracks → not seed. - if v.Seed { - t.Errorf("Seed=true, want false") - } - // Distinct artists: 3 (a1 dedup'd). - if len(v.Artists) != 3 { - t.Errorf("Artists len = %d, want 3", len(v.Artists)) - } - // Tag counts. - if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 { - t.Errorf("Tags = %+v", v.Tags) - } - if len(v.RecentTrackIDs) != 4 { - t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs)) - } -} - -func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, ""), // empty genre via the helper (nil pointer) - track(a1, "rock"), - track(a1, ""), - }) - if v.Tags["rock"] != 1 { - t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"]) - } - if _, exists := v.Tags[""]; exists { - t.Errorf("Tags has empty-string entry: %+v", v.Tags) - } -} - -func TestBuildSessionVector_JSONRoundTrip(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"), - }) - data, err := json.Marshal(v) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var got SessionVector - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got.Seed != v.Seed { - t.Errorf("Seed: %v != %v", got.Seed, v.Seed) - } - if got.Tags["rock"] != v.Tags["rock"] { - t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags) - } - if len(got.Artists) != len(v.Artists) { - t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists)) - } - if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) { - t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs)) - } -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `go test ./internal/recommendation -run TestBuildSessionVector -v` -Expected: FAIL — `SessionVector`, `BuildSessionVector` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/sessionvector.go`** - -```go -package recommendation - -import ( - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// SessionVector is the JSONB-serializable shape of a play_event's -// session_vector_at_play column. Spec §6 "Session vector". Sub-plan #3 -// will read these to compute contextual_match_score in the radio scoring. -type SessionVector struct { - Seed bool `json:"seed"` - Artists []string `json:"artists"` - Tags map[string]int `json:"tags"` - RecentTrackIDs []string `json:"recent_track_ids"` -} - -// BuildSessionVector aggregates the prior tracks into a session vector. -// Pure: no DB, no time, no randomness. Caller is responsible for ordering -// (oldest first / newest last) and for limiting to the desired N. -// -// Rules: -// - Seed = len(priorTracks) < 3. -// - Artists deduplicated, ordered by first appearance. -// - Tags is a bag-of-counts over tracks.genre. Empty/nil genres do not contribute. -// - RecentTrackIDs preserves input order (newest last). -func BuildSessionVector(priorTracks []dbq.Track) SessionVector { - v := SessionVector{ - Seed: len(priorTracks) < 3, - Artists: []string{}, - Tags: map[string]int{}, - RecentTrackIDs: []string{}, - } - seen := map[string]bool{} - for _, t := range priorTracks { - artistID := uuidString(t.ArtistID) - if !seen[artistID] { - seen[artistID] = true - v.Artists = append(v.Artists, artistID) - } - if t.Genre != nil && *t.Genre != "" { - v.Tags[*t.Genre]++ - } - v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) - } - return v -} - -// uuidString formats a pgtype.UUID as the canonical hex form. -func uuidString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - b := u.Bytes - return formatUUIDBytes(b) -} - -func formatUUIDBytes(b [16]byte) string { - const hexdigits = "0123456789abcdef" - out := make([]byte, 36) - pos := 0 - for i, by := range b { - switch i { - case 4, 6, 8, 10: - out[pos] = '-' - pos++ - } - out[pos] = hexdigits[by>>4] - out[pos+1] = hexdigits[by&0x0f] - pos += 2 - } - return string(out) -} -``` - -The `pgtype.UUID` import is needed: - -```go -import ( - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "github.com/jackc/pgx/v5/pgtype" -) -``` - -(Adjust the imports to actually compile — the formatter import is `pgtype`. Note: `pgtype.UUID` already has a `String()` method on pgx/v5; we could call `u.String()` directly. The hand-rolled formatter is shown for clarity; using `u.String()` is preferred — replace the body of `uuidString` with `return u.String()`.) - -Final clean form (pick this): - -```go -package recommendation - -import ( - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -type SessionVector struct { - Seed bool `json:"seed"` - Artists []string `json:"artists"` - Tags map[string]int `json:"tags"` - RecentTrackIDs []string `json:"recent_track_ids"` -} - -func BuildSessionVector(priorTracks []dbq.Track) SessionVector { - v := SessionVector{ - Seed: len(priorTracks) < 3, - Artists: []string{}, - Tags: map[string]int{}, - RecentTrackIDs: []string{}, - } - seen := map[string]bool{} - for _, t := range priorTracks { - artistID := uuidString(t.ArtistID) - if !seen[artistID] { - seen[artistID] = true - v.Artists = append(v.Artists, artistID) - } - if t.Genre != nil && *t.Genre != "" { - v.Tags[*t.Genre]++ - } - v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) - } - return v -} - -func uuidString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - return u.String() -} -``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 16 prior tests + 7 new = 23 total. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/sessionvector.go internal/recommendation/sessionvector_test.go -git commit -m "feat(recommendation): add BuildSessionVector pure function - -Pure aggregation per spec §6: Seed flag (true when prior < 3), -deduplicated Artists ordered by first appearance, Tags bag-of-counts -from tracks.genre (empty genres skipped), RecentTrackIDs preserving -input order. JSON round-trip verified." -``` - ---- - -## Task 4: Vector capture in playevents.Writer - -**Files:** -- Modify: `internal/playevents/writer.go::RecordPlayStarted` -- Modify: `internal/playevents/writer.go::RecordSyntheticCompletedPlay` -- Modify: `internal/playevents/writer_test.go` - -Both record paths share the same vector logic — extract a private helper. Both run inside an existing `pgx.BeginFunc` transaction, so the new SELECT + UPDATE go in the same atomic operation. - -- [ ] **Step 1: Write three failing tests** - -Append to `internal/playevents/writer_test.go`: - -```go -import ( - // existing imports - "encoding/json" -) - -func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) { - f := newFixture(t, 200_000) - now := time.Now().UTC() - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if err != nil { - t.Fatalf("get: %v", err) - } - if got.SessionVectorAtPlay == nil { - t.Fatalf("session_vector_at_play is NULL") - } - var vec map[string]any - if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if seed, _ := vec["seed"].(bool); !seed { - t.Errorf("seed = %v, want true (first play in session)", vec["seed"]) - } - if artists, _ := vec["artists"].([]any); len(artists) != 0 { - t.Errorf("artists not empty: %v", artists) - } -} - -func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) { - f := newFixture(t, 200_000) - // Seed 4 prior plays with the same track (reusing the fixture's single - // track is fine — vector dedup means we'll see 1 artist regardless). - t0 := time.Now().UTC().Add(-1 * time.Hour) - for i := 0; i < 4; i++ { - at := t0.Add(time.Duration(i) * time.Minute) - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - } - // 5th play — should see vector with 4 prior tracks. - at := t0.Add(10 * time.Minute) - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if got.SessionVectorAtPlay == nil { - t.Fatal("session_vector_at_play is NULL") - } - var vec map[string]any - _ = json.Unmarshal(got.SessionVectorAtPlay, &vec) - if seed, _ := vec["seed"].(bool); seed { - t.Errorf("seed = true after 4 prior tracks, want false") - } - recentIDs, _ := vec["recent_track_ids"].([]any) - if len(recentIDs) != 4 { - t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs)) - } -} - -func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) { - f := newFixture(t, 200_000) - // Play 1: at t=0. - t0 := time.Now().UTC().Add(-2 * time.Hour) - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0) - // Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout). - t1 := t0.Add(31 * time.Minute) - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if got.SessionVectorAtPlay == nil { - t.Fatal("vector NULL") - } - var vec map[string]any - _ = json.Unmarshal(got.SessionVectorAtPlay, &vec) - if seed, _ := vec["seed"].(bool); !seed { - t.Errorf("seed = false in fresh session, want true") - } - recentIDs, _ := vec["recent_track_ids"].([]any) - if len(recentIDs) != 0 { - t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs)) - } -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -run TestRecordPlayStarted_PersistsSessionVector -v -``` -Expected: FAIL — `session_vector_at_play` is NULL because vector capture is not yet wired. - -- [ ] **Step 3: Add the private helper + extend `RecordPlayStarted`** - -Open `internal/playevents/writer.go`. Add an import block addition for `encoding/json` and `git.fabledsword.com/bvandeusen/minstrel/internal/recommendation`. Add a private method: - -```go -// captureSessionVector queries the user's prior plays in the given session -// (before `at`), builds the session vector, and UPDATEs the just-inserted -// play_event with it. Runs inside the caller's transaction (q is the -// transaction-bound *dbq.Queries). Errors here propagate — vector capture -// is best-effort, but DB errors should fail the transaction. -func (w *Writer) captureSessionVector( - ctx context.Context, - q *dbq.Queries, - playEventID, sessionID pgtype.UUID, - at time.Time, -) error { - priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{ - SessionID: sessionID, - StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, - Limit: 5, - }) - if err != nil { - return err - } - vec := recommendation.BuildSessionVector(priorTracks) - vecJSON, err := json.Marshal(vec) - if err != nil { - return err - } - return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{ - ID: playEventID, - SessionVectorAtPlay: vecJSON, - }) -} -``` - -The `ListRecentSessionTracksParams` field names are sqlc-generated from the SQL. Check the actual generated names — the SQL uses `$1`, `$2`, `$3` so sqlc may use `Column1/2/3` OR may infer better names from the WHERE clauses. If the generated struct uses `Column1`/`Column2`/`Column3`, adjust the field names accordingly: - -```go -priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{ - SessionID: sessionID, // or Column1 - StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, // or Column2 - Limit: 5, // or Column3 -}) -``` - -(Run sqlc generate first; inspect the actual struct.) - -- [ ] **Step 4: Call the helper from `RecordPlayStarted`** - -In `RecordPlayStarted`, after the `q.InsertPlayEvent` call (around line 80), add: - -```go -ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...}) -if err != nil { - return err -} -out.PlayEventID = ev.ID -out.SessionID = sessionID - -// Capture session vector for the just-inserted row. -if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil { - return err -} -return nil -``` - -- [ ] **Step 5: Call the helper from `RecordSyntheticCompletedPlay`** - -In `RecordSyntheticCompletedPlay`, find the `q.InsertPlayEvent(...)` call. Right before the subsequent `q.UpdatePlayEventEnded(...)`, add a `captureSessionVector` call: - -```go -ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...}) -if err != nil { - return err -} -if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil { - return err -} -// existing UpdatePlayEventEnded follows... -``` - -- [ ] **Step 6: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -v -``` -Expected: PASS — 8 existing + 3 new = 11 tests. - -- [ ] **Step 7: Lint** - -Run: `golangci-lint run ./internal/playevents/...` -Expected: clean. - -- [ ] **Step 8: Commit** - -```bash -git add internal/playevents/writer.go internal/playevents/writer_test.go -git commit -m "feat(playevents): persist session_vector_at_play on every record path - -RecordPlayStarted and RecordSyntheticCompletedPlay both capture the -session vector inside their existing transactions. Single private -helper queries the prior 5 tracks, builds the vector, UPDATEs the -just-inserted play_event. Tests verify the seed flag boundary and -session-scope isolation." -``` - ---- - -## Task 5: Contextual-likes helpers + api handler updates - -**Files:** -- Create: `internal/playevents/contextual_likes.go` -- Create: `internal/playevents/contextual_likes_test.go` -- Modify: `internal/api/likes.go::handleLikeTrack` and `handleUnlikeTrack` -- Modify: `internal/api/likes_test.go` - -Two helpers in `internal/playevents` so both `internal/api` and `internal/subsonic` use the same code path. The api handlers wire them in here; the subsonic handlers in Task 6. - -- [ ] **Step 1: Write the helper tests** - -Create `internal/playevents/contextual_likes_test.go`: - -```go -package playevents - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// fixtureWithSecondTrack extends the existing fixture with a second track -// the test uses as the "track being liked while track 1 plays". -type fixtureWithSecondTrack struct { - fixture - track2 pgtype.UUID -} - -func newFixtureWithSecondTrack(t *testing.T, durationMs int32) fixtureWithSecondTrack { - f := newFixture(t, durationMs) - q := dbq.New(f.pool) - tr2, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Other", AlbumID: f.q.GetAlbumByID, // placeholder — use the same album - FilePath: "/tmp/track-2.flac", DurationMs: durationMs, - }) - _ = err // for sketching; the real implementation should look up an existing album/artist from f - _ = tr2 - return fixtureWithSecondTrack{fixture: f} -} - -func TestCaptureContextualLikeIfPlaying_NoOpenEvent_NoOp(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - q := dbq.New(f.pool) - if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil { - t.Fatalf("err: %v", err) - } - var count int - _ = f.pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1", f.user).Scan(&count) - if count != 0 { - t.Errorf("count = %d, want 0 (no open play_event)", count) - } -} - -func TestCaptureContextualLikeIfPlaying_OpenEventWithVector_Inserts(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - // Start a play (creates an open play_event with a populated vector). - at := time.Now().UTC() - _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - // Like the same track — captures contextual signal. - q := dbq.New(f.pool) - if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil { - t.Fatalf("Capture: %v", err) - } - var rawVec []byte - if err := f.pool.QueryRow(context.Background(), - "SELECT session_vector FROM contextual_likes WHERE user_id=$1 AND track_id=$2", - f.user, f.track).Scan(&rawVec); err != nil { - t.Fatalf("query: %v", err) - } - var vec map[string]any - _ = json.Unmarshal(rawVec, &vec) - if vec == nil { - t.Errorf("vector empty: %s", rawVec) - } -} - -func TestSoftDeleteContextualLikes_MarksActiveRowsDeleted(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - at := time.Now().UTC() - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - q := dbq.New(f.pool) - _ = CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger) - // Now soft-delete. - if err := SoftDeleteContextualLikes(context.Background(), q, f.user, f.track); err != nil { - t.Fatalf("SoftDelete: %v", err) - } - var deletedAt pgtype.Timestamptz - if err := f.pool.QueryRow(context.Background(), - "SELECT deleted_at FROM contextual_likes WHERE user_id=$1 AND track_id=$2", - f.user, f.track).Scan(&deletedAt); err != nil { - t.Fatalf("query: %v", err) - } - if !deletedAt.Valid { - t.Errorf("deleted_at not set after SoftDelete") - } -} -``` - -The fixture-extension code above has a placeholder (`fixtureWithSecondTrack` is sketched). For these tests we don't actually need a second track — using `f.track` for both the "playing" and "liked" sides is fine because contextual_likes is keyed on (user, track) and the test is verifying capture, not cross-track behavior. Drop the `fixtureWithSecondTrack` helper; use `f.track` directly. - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -run TestCaptureContextualLikeIfPlaying -v -``` -Expected: FAIL — `CaptureContextualLikeIfPlaying`, `SoftDeleteContextualLikes` undefined. - -- [ ] **Step 3: Implement `internal/playevents/contextual_likes.go`** - -```go -package playevents - -import ( - "context" - "errors" - "log/slog" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// CaptureContextualLikeIfPlaying snapshots the user's currently-playing -// session context into a new contextual_likes row. No-op if no open -// play_event exists or its session_vector is NULL. Failures are logged -// but never returned to the caller — contextual capture is best-effort -// and must not break the like response. -func CaptureContextualLikeIfPlaying( - ctx context.Context, - q *dbq.Queries, - userID, trackID pgtype.UUID, - logger *slog.Logger, -) error { - event, err := q.GetOpenPlayEventForUser(ctx, userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil // no play in progress; nothing to capture - } - logger.Error("playevents: get open play_event", "err", err) - return nil // best-effort - } - if event.SessionVectorAtPlay == nil { - return nil // event predates this slice; no vector to snapshot - } - if err := q.InsertContextualLike(ctx, dbq.InsertContextualLikeParams{ - UserID: userID, - TrackID: trackID, - SessionVector: event.SessionVectorAtPlay, - SessionID: event.SessionID, - }); err != nil { - logger.Error("playevents: insert contextual_like", "err", err) - return nil - } - return nil -} - -// SoftDeleteContextualLikes marks all currently-active contextual_likes -// rows for (user, track) as deleted. Idempotent. -func SoftDeleteContextualLikes( - ctx context.Context, - q *dbq.Queries, - userID, trackID pgtype.UUID, -) error { - return q.SoftDeleteContextualLikesForUserTrack(ctx, dbq.SoftDeleteContextualLikesForUserTrackParams{ - UserID: userID, - TrackID: trackID, - }) -} -``` - -Note: the `InsertContextualLikeParams` field names follow sqlc's naming — `UserID`, `TrackID`, `SessionVector`, `SessionID`. If sqlc generates different names (e.g. positional `Column1`...), adjust per the actual generated code. - -- [ ] **Step 4: Run helper tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -v -``` -Expected: PASS — 11 prior tests + 3 helper tests = 14 total. - -- [ ] **Step 5: Update `internal/api/likes.go::handleLikeTrack`** - -```go -func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - if _, err := q.GetTrackByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - } - h.logger.Error("api: like track lookup", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}) - if err != nil { - h.logger.Error("api: like track insert", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - if rows == 1 { - _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) - } - w.WriteHeader(http.StatusNoContent) -} -``` - -Add the `playevents` import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`. - -- [ ] **Step 6: Update `internal/api/likes.go::handleUnlikeTrack`** - -```go -func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { - h.logger.Error("api: unlike track", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "delete failed") - return - } - if err := playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, id); err != nil { - h.logger.Error("api: soft-delete contextual_likes", "err", err) - // Don't fail the response — soft-delete is best-effort. - } - w.WriteHeader(http.StatusNoContent) -} -``` - -- [ ] **Step 7: Add five new tests to `internal/api/likes_test.go`** - -Append: - -```go -func TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "Now Playing", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - - // Simulate a play_started: insert via the writer. - w := callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - if w.Code != http.StatusOK { - t.Fatalf("play_started failed: %d %s", w.Code, w.Body.String()) - } - // Like the OTHER track. With an open play_event for `playing`, - // the contextual_likes row should reference `other` and the playing - // track's session vector. - if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent { - t.Fatalf("like: %d", r.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1", count) - } -} - -func TestLikeTrack_NoOpenPlayEvent_NoContextualLike(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000) - - if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)); r.Code != http.StatusNoContent { - t.Fatalf("like: %d", r.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 0 { - t.Errorf("contextual_likes count = %d, want 0 (no open play)", count) - } -} - -func TestLikeTrack_RepeatedLike_NoDuplicate(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "Playing", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - - // Open play. - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - // First like — captures. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - // Second like — :execrows=0, no contextual write. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1 (no duplicate from idempotent re-like)", count) - } -} - -func TestUnlikeTrack_SoftDeletesContextualLikes(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000) - - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - if r := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent { - t.Fatalf("unlike: %d", r.Code) - } - // general_likes deleted. - var glCount int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&glCount) - if glCount != 0 { - t.Errorf("general_likes count = %d, want 0", glCount) - } - // contextual_likes row exists but deleted_at is set. - var deletedAtValid bool - _ = pool.QueryRow(context.Background(), - "SELECT deleted_at IS NOT NULL FROM contextual_likes WHERE user_id=$1 AND track_id=$2 LIMIT 1", - user.ID, other.ID).Scan(&deletedAtValid) - if !deletedAtValid { - t.Errorf("deleted_at not set after unlike") - } -} - -func TestLikeUnlikeRelike_HistoryAccumulates(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000) - - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - _ = callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)) - // Re-like in same session — new row should be inserted. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - - var total, active int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&total) - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2 AND deleted_at IS NULL", - user.ID, other.ID).Scan(&active) - if total != 2 { - t.Errorf("total = %d, want 2 (history accumulates)", total) - } - if active != 1 { - t.Errorf("active = %d, want 1", active) - } -} -``` - -- [ ] **Step 8: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/api -v -``` -Expected: PASS, including the 5 new tests. - -- [ ] **Step 9: Lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 10: Commit** - -```bash -git add internal/playevents/contextual_likes.go internal/playevents/contextual_likes_test.go \ - internal/api/likes.go internal/api/likes_test.go -git commit -m "feat(api): capture contextual_likes on like during open play_event - -Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying, -SoftDeleteContextualLikes) called by both api and subsonic surfaces. -Like inserts a new contextual_likes row when LikeTrack actually -inserted (rows=1) AND there's an open play_event with a vector. -Unlike soft-deletes via deleted_at. Idempotent in both directions. - -Subsonic wiring lands in the next commit." -``` - ---- - -## Task 6: Subsonic handler updates + verification test - -**Files:** -- Modify: `internal/subsonic/star.go::handleStar` (track branch) -- Modify: `internal/subsonic/star.go::handleUnstar` (track branch) -- Modify: `internal/subsonic/star_test.go` - -- [ ] **Step 1: Update `handleStar` track branch** - -In `internal/subsonic/star.go`, find the section that calls `q.LikeTrack(...)`. Replace: - -```go -if trackID.Valid { - if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { - WriteFail(w, r, ErrGeneric, "Could not star track") - return - } -} -``` - -with: - -```go -if trackID.Valid { - rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) - if err != nil { - WriteFail(w, r, ErrGeneric, "Could not star track") - return - } - if rows == 1 { - _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger) - } -} -``` - -The `mediaHandlers` struct currently has fields `pool *pgxpool.Pool` and `events *playevents.Writer`. It does NOT have a `logger`. Two ways to handle: -- Option A: add a `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` and the `subsonic.Mount` call site (`internal/subsonic/subsonic.go`) to pass it through. -- Option B: pass a no-op logger inline: `slog.New(slog.NewTextHandler(io.Discard, nil))`. - -Use Option A (cleanest). Add the field; thread the logger through `Mount`. The `subsonic.Mount` signature already takes a `*slog.Logger` per existing code. - -Add the import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`. - -- [ ] **Step 2: Update `handleUnstar` track branch** - -```go -if t, ok := parseUUID(params.Get("id")); ok { - _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) - _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t) -} -``` - -- [ ] **Step 3: Update `mediaHandlers` struct + factory + caller** - -Add `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` constructor. Update `internal/subsonic/subsonic.go::Mount`'s call site: - -```go -m := newMediaHandlers(pool, events, logger) -``` - -(`logger` is already a parameter to `Mount`.) - -- [ ] **Step 4: Add verification test in `star_test.go`** - -Append: - -```go -func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) { - pool, user, track1, _, _ := testStarPool(t) - // Add a second track so star refers to a different track than the one playing. - q := dbq.New(pool) - a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"}) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID}) - track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000, - }) - - m := newStarHandlers(pool) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - m.logger = logger - - // Start now-playing for track1 (creates open play_event with vector). - q1 := url.Values{} - q1.Set("id", uuidToID(track1.ID)) - q1.Set("submission", "false") - scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil) - scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user) - scrobResp := httptest.NewRecorder() - m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx)) - if scrobResp.Code != http.StatusOK { - t.Fatalf("scrobble: %d", scrobResp.Code) - } - - // Star track2. - q2 := url.Values{} - q2.Set("id", uuidToID(track2.ID)) - starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil) - starCtx := context.WithValue(starReq.Context(), userCtxKey, user) - starResp := httptest.NewRecorder() - m.handleStar(starResp, starReq.WithContext(starCtx)) - if starResp.Code != http.StatusOK { - t.Fatalf("star: %d", starResp.Code) - } - - // Verify contextual_likes row. - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1", count) - } -} -``` - -The `newStarHandlers` helper currently doesn't set a logger; the `mediaHandlers` factory needs to be updated so the test passes the logger. Adjust the test fixture to construct `mediaHandlers` with a real (or io.Discard) logger. - -- [ ] **Step 5: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/subsonic -v -``` -Expected: PASS — existing scrobble + star tests + 1 new test. - -- [ ] **Step 6: Full Go suite + lint** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go -git commit -m "feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar - -handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying -when the underlying LikeTrack actually inserted a row. handleUnstar -calls SoftDeleteContextualLikes after every track-id unstar. Same -helpers as the api surface — single source of truth. - -mediaHandlers struct gains a logger field threaded through Mount." -``` - ---- - -## Task 7: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (with DB)** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Coverage on recommendation + playevents** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -coverprofile=/tmp/m3v.cover ./internal/recommendation/... ./internal/playevents/... -go tool cover -func=/tmp/m3v.cover | tail -3 -``` -Expected: total ≥ 70% per the M3 milestone description (sub-plan #1 already pushed `recommendation` to 95.5%; this slice keeps it high while bringing in `playevents` improvements). - -- [ ] **Step 3: Go lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 4: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; tests pass; build emits `web/build/`. (No web changes in this slice; this is a regression check.) - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Docker build smoke** - -Run: `docker build -t minstrel:m3-vectors-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-vectors-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 6: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533` (sign in with the dev admin creds): - -1. 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 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`. -7. From Feishin: send a scrobble with `submission=false` for track A, then star track B. Verify contextual_likes captured the Subsonic-driven event. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 7: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- New `contextual_likes` table + indexes → Task 1. -- Pure session-vector function → Task 3. -- Vector capture inside RecordPlayStarted + RecordSyntheticCompletedPlay → Task 4. -- LikeTrack `:execrows` change → Task 2. -- Contextual capture on like (api) → Task 5. -- Soft-delete on unlike (api) → Task 5. -- Same on subsonic → Task 6. -- ListRecentSessionTracks + UpdatePlayEventVector queries → Task 2. -- Cross-session isolation (vector scoped to current session) → Task 4 third test. -- Edge cases (no-op without play, no duplicate on idempotent re-like, history on relike) → Task 5 tests. -- Subsonic verification → Task 6 test. -- ≥70% coverage maintained → Task 7 step 2. - -**Type consistency:** -- `SessionVector { Seed, Artists, Tags, RecentTrackIDs }` — same struct used in `BuildSessionVector` (Task 3) + serialized as JSON in `play_events.session_vector_at_play` (Task 4) + read back from `contextual_likes.session_vector` (Task 5). -- `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` — same signature in helpers (Task 5) + api callers (Task 5) + subsonic callers (Task 6). -- `SoftDeleteContextualLikes(ctx, q, userID, trackID)` — same signature in helpers + both call sites. -- `pgtype.UUID` server-side throughout; JSON serialization via `u.String()`. - -**Filename hazards:** none. Only Go files; no SvelteKit route walker concerns. - -**Placeholder scan:** no TBD/TODO/later markers. Each step has complete code; the sqlc-generated-name caveats (Column1/Column2 vs UserID/TrackID) are documented inline so engineers know to verify after `sqlc generate`. - -**Performance note:** the new SELECT + UPDATE inside `RecordPlayStarted` adds ~1-2 ms per play event. Negligible at v1 scale. The `contextual_likes` writes happen at human click rates — also negligible. diff --git a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md b/docs/superpowers/plans/2026-04-28-m4a-scrobble.md deleted file mode 100644 index 15d6d92a..00000000 --- a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md +++ /dev/null @@ -1,2523 +0,0 @@ -# M4a — ListenBrainz Outbound Scrobble Worker 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:** Send the user's qualifying plays (≥240s OR ≥50% completion) to ListenBrainz with batching + exponential-backoff retry. Per-user token + enabled flag in the SPA's new `/settings` page. Backed by a `scrobble_queue` table that the worker drains every 30s. - -**Architecture:** New `internal/scrobble/` package tree: `listenbrainz/` for the pure HTTP client (typed errors for auth/permanent/transient/retry-after), and the parent package for `Qualifies`, `MaybeEnqueue`, and the `Worker` goroutine. `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay` gain a post-commit best-effort enqueue call. New API endpoints `GET/PUT /api/me/listenbrainz` and a minimal `/settings` page in the SPA. - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 + TanStack Query. New SQL migration; no breaking changes to existing schema. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4a-scrobble-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0008_scrobble.up.sql` + `.down.sql` | `users.listenbrainz_token`, `users.listenbrainz_enabled`, `scrobble_queue` table + partial index. | -| `internal/db/queries/users.sql` (modify) | Add `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig`. | -| `internal/db/queries/scrobble.sql` | New: `EnqueueScrobble :execrows`, `ListPendingScrobbles`, `MarkScrobbleSent`, `MarkScrobbleFailed`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `GetLastScrobbledForUser`. | -| `internal/db/dbq/scrobble.sql.go` | Generated bindings. | -| `internal/scrobble/listenbrainz/client.go` | Pure HTTP client. `Client`, `Listen`, `Track` types; `SubmitListens` method; typed errors `ErrAuth`, `ErrPermanent`, `ErrTransient`, `*RetryAfterError`. | -| `internal/scrobble/listenbrainz/client_test.go` | `httptest.Server`-driven tests for each error mapping + payload shape. | -| `internal/scrobble/threshold.go` | Pure `Qualifies(durationPlayedMs int, completionRatio float64) bool`. | -| `internal/scrobble/threshold_test.go` | Boundary tests. | -| `internal/scrobble/queue.go` | `MaybeEnqueue(ctx, q, playEventID)`. Reads user config + threshold; idempotent INSERT. | -| `internal/scrobble/queue_test.go` | Live-DB integration tests. | -| `internal/scrobble/worker.go` | `Worker` struct, `Run` loop, `tickOnce`, `backoffDelay` helper. | -| `internal/scrobble/worker_test.go` | Pure tests for `backoffDelay`. | -| `internal/scrobble/worker_integration_test.go` | Live-DB integration: full ticker pipeline against an httptest LB. | -| `internal/api/listenbrainz.go` | `handleGetListenBrainz`, `handlePutListenBrainz`. | -| `internal/api/listenbrainz_test.go` | Live-DB HTTP tests. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/db/dbq/users.sql.go` + `models.go` | Generated additions for the new user columns. | -| `internal/playevents/writer.go::RecordPlayEnded` | After `pgx.BeginFunc` returns nil, call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID)`; log + swallow errors. | -| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same pattern. | -| `internal/playevents/writer_test.go` | Three new tests covering enqueue: qualifying play → row inserted, sub-threshold → no row, user-disabled → no row. | -| `internal/api/api.go::Mount` | Add `authed.Get("/me/listenbrainz", h.handleGetListenBrainz)` and `authed.Put("/me/listenbrainz", h.handlePutListenBrainz)`. | -| `internal/api/auth_test.go` | testHandlers helper unchanged (no new struct fields). | -| `cmd/minstrel/main.go` | Construct `scrobble.Worker` after pool open; `go worker.Run(ctx)` after server starts. | - -**Frontend files:** - -| File | Responsibility | -|---|---| -| `web/src/lib/api/client.ts` (modify) | Add `api.put(path, body)` helper. | -| `web/src/lib/api/listenbrainz.ts` | New: `getListenBrainzStatus()`, `setListenBrainzToken(token)`, `setListenBrainzEnabled(enabled)` + `LBStatus` type. | -| `web/src/routes/settings/+page.svelte` | The Settings page, single ListenBrainz section. | -| `web/src/routes/settings/settings.test.ts` | Vitest coverage for the page. | -| `web/src/lib/components/Shell.svelte` | Add `{ href: '/settings', label: 'Settings' }` to `navItems`. | -| `web/src/lib/components/Shell.test.ts` (modify) | Update nav assertions if the test enumerates items. | - ---- - -## Task 1: Migration 0008 — schema - -**Files:** -- Create: `internal/db/migrations/0008_scrobble.up.sql` -- Create: `internal/db/migrations/0008_scrobble.down.sql` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0008_scrobble.up.sql`: - -```sql --- M4a: outbound ListenBrainz scrobble worker. --- Per-user LB config (plaintext token; users rotate via /settings if leaked). --- scrobble_queue is a work list, not a log: successfully-sent rows are --- DELETEd by the worker (the canonical record stays in play_events.scrobbled_at --- via M2's column). Only `pending` and `failed` states exist. - -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) -); - --- Hot path for the worker's per-tick query. -CREATE INDEX scrobble_queue_pending_idx - ON scrobble_queue (next_attempt_at) - WHERE status = 'pending'; -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0008_scrobble.down.sql`: - -```sql -DROP TABLE IF EXISTS scrobble_queue; -ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_enabled; -ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_token; -``` - -- [ ] **Step 3: Verify the migration applies cleanly** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -short -race ./internal/db/` - -Expected: PASS. The migration test (which applies all migrations against a temp schema) finds version 8. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/migrations/0008_scrobble.up.sql internal/db/migrations/0008_scrobble.down.sql -git commit -m "feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table)" -``` - ---- - -## Task 2: sqlc queries — users LB config + scrobble_queue - -**Files:** -- Modify: `internal/db/queries/users.sql` -- Create: `internal/db/queries/scrobble.sql` -- Generated: `internal/db/dbq/users.sql.go`, `internal/db/dbq/scrobble.sql.go`, `internal/db/dbq/models.go` - -- [ ] **Step 1: Add user-LB-config queries** - -Append to `internal/db/queries/users.sql`: - -```sql --- name: SetListenBrainzToken :exec -UPDATE users -SET listenbrainz_token = $2, - listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END -WHERE id = $1; - --- name: SetListenBrainzEnabled :exec -UPDATE users -SET listenbrainz_enabled = $2 -WHERE id = $1; - --- name: GetListenBrainzConfig :one --- Returns the user's LB token + enabled flag and the most recent --- play_events.scrobbled_at for last-scrobbled-at status. -SELECT - u.listenbrainz_token, - u.listenbrainz_enabled, - (SELECT MAX(pe.scrobbled_at) - FROM play_events pe - WHERE pe.user_id = u.id) AS last_scrobbled_at -FROM users u -WHERE u.id = $1; -``` - -- [ ] **Step 2: Add scrobble_queue queries** - -Create `internal/db/queries/scrobble.sql`: - -```sql --- name: EnqueueScrobble :execrows --- Idempotent: if a row exists for this play_event_id, ON CONFLICT does nothing --- and the affected-rows count is 0. Caller (MaybeEnqueue) doesn't treat 0 as --- an error. -INSERT INTO scrobble_queue (user_id, play_event_id, status) -VALUES ($1, $2, 'pending') -ON CONFLICT (play_event_id) DO NOTHING; - --- name: ListPendingScrobbles :many --- Worker pulls up to N pending rows whose next_attempt_at has passed. --- Joins play_events + tracks/albums/artists + users so the worker can --- assemble the LB Listen payload in one round-trip. -SELECT - sq.id AS queue_id, - sq.user_id AS user_id, - sq.play_event_id AS play_event_id, - sq.attempts AS attempts, - u.listenbrainz_token AS lb_token, - u.listenbrainz_enabled AS lb_enabled, - pe.started_at AS started_at, - t.title AS track_title, - t.duration_ms AS track_duration_ms, - t.mbid AS track_mbid, - al.title AS album_title, - al.mbid AS album_mbid, - ar.name AS artist_name, - ar.mbid AS artist_mbid -FROM scrobble_queue sq -JOIN users u ON u.id = sq.user_id -JOIN play_events pe ON pe.id = sq.play_event_id -JOIN tracks t ON t.id = pe.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -WHERE sq.status = 'pending' - AND sq.next_attempt_at <= now() -ORDER BY sq.next_attempt_at -LIMIT $1; - --- name: MarkScrobbleSent :exec --- Successful submit. Delete the queue row AND stamp play_events.scrobbled_at --- in one statement via a CTE so partial failure is impossible. -WITH del AS ( - DELETE FROM scrobble_queue - WHERE id = $1 - RETURNING play_event_id -) -UPDATE play_events -SET scrobbled_at = now() -WHERE id = (SELECT play_event_id FROM del); - --- name: RescheduleScrobble :exec --- After a transient failure: increment attempts, schedule next attempt, --- record the error. -UPDATE scrobble_queue -SET attempts = attempts + 1, - next_attempt_at = $2, - last_error = $3 -WHERE id = $1; - --- name: MarkScrobbleRetryAfter :exec --- After a 429: schedule next attempt per LB's Retry-After header, but do NOT --- increment attempts (the server told us to wait, not that we failed). -UPDATE scrobble_queue -SET next_attempt_at = $2 -WHERE id = $1; - --- name: MarkScrobbleFailed :exec --- After a permanent failure (4xx, exhausted retries, auth) — mark failed --- and keep the row for diagnostics. -UPDATE scrobble_queue -SET status = 'failed', - last_error = $2 -WHERE id = $1; -``` - -- [ ] **Step 3: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: docker pulls sqlc 1.27.0, regenerates `internal/db/dbq/`. New methods appear on `*Queries`: -- `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig` -- `EnqueueScrobble`, `ListPendingScrobbles`, `MarkScrobbleSent`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `MarkScrobbleFailed` - -`models.go` gains the two new `User` fields: `ListenbrainzToken *string` and `ListenbrainzEnabled bool`. - -- [ ] **Step 4: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. No callers yet — generated bindings just need to compile. - -- [ ] **Step 5: Commit** - -```bash -git add internal/db/queries/ internal/db/dbq/ -git commit -m "feat(db): add ListenBrainz user-config and scrobble_queue queries" -``` - ---- - -## Task 3: ListenBrainz HTTP client (pure) - -**Files:** -- Create: `internal/scrobble/listenbrainz/client.go` -- Create: `internal/scrobble/listenbrainz/client_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/listenbrainz/client_test.go`: - -```go -package listenbrainz - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) { - srv := httptest.NewServer(handler) - return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv -} - -func TestClient_SubmitListens_Success(t *testing.T) { - var seenPath, seenAuth, seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenPath = r.URL.Path - seenAuth = r.Header.Get("Authorization") - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}`)) - }) - defer srv.Close() - - listens := []Listen{{ - ListenedAt: 1700000000, - Track: Track{ - ArtistName: "Miles Davis", - TrackName: "So What", - ReleaseName: "Kind of Blue", - DurationMs: 545000, - }, - }} - if err := c.SubmitListens(context.Background(), "tk", listens); err != nil { - t.Fatalf("err: %v", err) - } - if seenPath != "/1/submit-listens" { - t.Errorf("path = %q, want /1/submit-listens", seenPath) - } - if seenAuth != "Token tk" { - t.Errorf("auth = %q, want %q", seenAuth, "Token tk") - } - if !strings.Contains(seenBody, `"track_name":"So What"`) { - t.Errorf("body missing track_name: %s", seenBody) - } - if !strings.Contains(seenBody, `"artist_name":"Miles Davis"`) { - t.Errorf("body missing artist_name: %s", seenBody) - } -} - -func TestClient_SubmitListens_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "bad", []Listen{{}}) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SubmitListens_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SubmitListens_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SubmitListens_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 60*time.Second { - t.Errorf("Wait = %v, want 60s", ra.Wait) - } -} - -func TestClient_SubmitListens_NetworkFailure_ReturnsErrTransient(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", HTTP: &http.Client{Timeout: 100 * time.Millisecond}} - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SubmitListens_PayloadTypeImportForBatch(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - {ListenedAt: 2, Track: Track{ArtistName: "B", TrackName: "Y"}}, - }) - if !strings.Contains(seenBody, `"listen_type":"import"`) { - t.Errorf("batch body missing listen_type=import: %s", seenBody) - } -} - -func TestClient_SubmitListens_PayloadTypeSingleForOne(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - }) - if !strings.Contains(seenBody, `"listen_type":"single"`) { - t.Errorf("single body missing listen_type=single: %s", seenBody) - } -} - -func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - }) - if strings.Contains(seenBody, "recording_mbid") { - t.Errorf("body should omit empty recording_mbid: %s", seenBody) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` - -Expected: FAIL with "no Go files" or "undefined: Client". - -- [ ] **Step 3: Write the client** - -Create `internal/scrobble/listenbrainz/client.go`: - -```go -// Package listenbrainz is the pure HTTP client for the ListenBrainz -// submit-listens endpoint. No DB, no worker plumbing — just types and one -// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient, -// *RetryAfterError) so callers can branch on response semantics. -package listenbrainz - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "time" -) - -const defaultBaseURL = "https://api.listenbrainz.org" - -// Listen is one row sent to ListenBrainz. -type Listen struct { - ListenedAt int64 // unix seconds - Track Track -} - -// Track is the per-listen metadata. Optional fields are omitted from the -// payload when zero-valued. -type Track struct { - ArtistName string - TrackName string - ReleaseName string - DurationMs int - RecordingMBID string - ArtistMBIDs []string - ReleaseMBID string -} - -// Client posts to the LB submit-listens endpoint. -type Client struct { - BaseURL string - HTTP *http.Client -} - -// NewClient returns a default-configured client (LB production base URL, -// 30s timeout). -func NewClient() *Client { - return &Client{ - BaseURL: defaultBaseURL, - HTTP: &http.Client{Timeout: 30 * time.Second}, - } -} - -// Sentinel errors. The worker branches on these to decide retry vs fail. -var ( - ErrAuth = errors.New("listenbrainz: auth rejected") - ErrPermanent = errors.New("listenbrainz: permanent error") - ErrTransient = errors.New("listenbrainz: transient error") -) - -// RetryAfterError carries the LB-supplied wait duration for 429 responses. -type RetryAfterError struct{ Wait time.Duration } - -func (e *RetryAfterError) Error() string { - return fmt.Sprintf("listenbrainz: retry after %v", e.Wait) -} - -// SubmitListens posts the given listens. payload_type is "single" for one -// listen, "import" for batches. -func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error { - if len(listens) == 0 { - return nil - } - - listenType := "import" - if len(listens) == 1 { - listenType = "single" - } - - body, err := json.Marshal(buildPayload(listenType, listens)) - if err != nil { - return fmt.Errorf("listenbrainz: marshal: %w", err) - } - - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("listenbrainz: build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Token "+token) - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - return nil - case resp.StatusCode == http.StatusUnauthorized: - return ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - wait := parseRetryAfter(resp.Header.Get("Retry-After")) - return &RetryAfterError{Wait: wait} - case resp.StatusCode >= 500: - return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} - -func parseRetryAfter(header string) time.Duration { - if header == "" { - return 60 * time.Second // sensible default - } - if secs, err := strconv.Atoi(header); err == nil { - return time.Duration(secs) * time.Second - } - if t, err := http.ParseTime(header); err == nil { - d := time.Until(t) - if d < 0 { - return 60 * time.Second - } - return d - } - return 60 * time.Second -} - -// payload mirrors the LB submit-listens body. Optional MBID fields use -// `omitempty` so empty strings/slices don't appear in the JSON. -type payload struct { - ListenType string `json:"listen_type"` - Payload []payloadEntry `json:"payload"` -} - -type payloadEntry struct { - ListenedAt int64 `json:"listened_at"` - TrackMetadata trackMetadata `json:"track_metadata"` -} - -type trackMetadata struct { - ArtistName string `json:"artist_name"` - TrackName string `json:"track_name"` - ReleaseName string `json:"release_name,omitempty"` - AdditionalInfo additionalInfo `json:"additional_info,omitempty"` -} - -type additionalInfo struct { - DurationMs int `json:"duration_ms,omitempty"` - RecordingMBID string `json:"recording_mbid,omitempty"` - ArtistMBIDs []string `json:"artist_mbids,omitempty"` - ReleaseMBID string `json:"release_mbid,omitempty"` -} - -func buildPayload(listenType string, listens []Listen) payload { - entries := make([]payloadEntry, 0, len(listens)) - for _, l := range listens { - entries = append(entries, payloadEntry{ - ListenedAt: l.ListenedAt, - TrackMetadata: trackMetadata{ - ArtistName: l.Track.ArtistName, - TrackName: l.Track.TrackName, - ReleaseName: l.Track.ReleaseName, - AdditionalInfo: additionalInfo{ - DurationMs: l.Track.DurationMs, - RecordingMBID: l.Track.RecordingMBID, - ArtistMBIDs: l.Track.ArtistMBIDs, - ReleaseMBID: l.Track.ReleaseMBID, - }, - }, - }) - } - return payload{ListenType: listenType, Payload: entries} -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` - -Expected: PASS for all 9 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/listenbrainz/ -git commit -m "feat(scrobble): add pure ListenBrainz HTTP client with typed errors" -``` - ---- - -## Task 4: Threshold (pure) - -**Files:** -- Create: `internal/scrobble/threshold.go` -- Create: `internal/scrobble/threshold_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/threshold_test.go`: - -```go -package scrobble - -import "testing" - -func TestQualifies_NeverPlayed(t *testing.T) { - if Qualifies(0, 0) { - t.Error("0/0 should not qualify") - } -} - -func TestQualifies_AtLeast240Seconds(t *testing.T) { - if !Qualifies(240_000, 0.0) { - t.Error("240_000 ms played should qualify regardless of ratio") - } -} - -func TestQualifies_AtLeastHalfRatio(t *testing.T) { - if !Qualifies(0, 0.5) { - t.Error("ratio=0.5 should qualify regardless of duration") - } -} - -func TestQualifies_BelowBoth(t *testing.T) { - if Qualifies(120_000, 0.49) { - t.Error("120s + 49% should not qualify") - } -} - -func TestQualifies_BoundaryJustUnder(t *testing.T) { - if Qualifies(239_999, 0.4999) { - t.Error("just-under both thresholds should not qualify") - } -} - -func TestQualifies_BoundaryExactly(t *testing.T) { - if !Qualifies(240_000, 0.4999) { - t.Error("exactly 240s should qualify") - } - if !Qualifies(239_999, 0.5) { - t.Error("exactly 50% should qualify") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` - -Expected: FAIL with "no Go files" or "undefined: Qualifies". - -- [ ] **Step 3: Write the threshold function** - -Create `internal/scrobble/threshold.go`: - -```go -// Package scrobble owns the outbound-scrobble pipeline: threshold detection, -// the queue write path, and the worker goroutine. The HTTP client is in -// internal/scrobble/listenbrainz; this package consumes it. -package scrobble - -// Qualifies returns true if a closed play_event meets ListenBrainz's -// recommended scrobble threshold: ≥240 seconds played OR ≥50% of the -// track length played. -// -// The two thresholds are deliberately separate from M2's skip-detection -// thresholds (which serve a different purpose — internal engine signal -// for the scoring formula's skip penalty). -func Qualifies(durationPlayedMs int, completionRatio float64) bool { - return durationPlayedMs >= 240_000 || completionRatio >= 0.5 -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` - -Expected: PASS for all 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/threshold.go internal/scrobble/threshold_test.go -git commit -m "feat(scrobble): add pure Qualifies threshold function" -``` - ---- - -## Task 5: MaybeEnqueue (DB-backed) - -**Files:** -- Create: `internal/scrobble/queue.go` -- Create: `internal/scrobble/queue_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/scrobble/queue_test.go`: - -```go -package scrobble - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) { - t.Helper() - if testing.Short() { - t.Skip("skipping in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool, dbq.New(pool) -} - -type setup struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - playEvent pgtype.UUID -} - -// seed creates: user (with optional LB token + enabled), one artist/album/track, -// one closed play_event with the given duration_played_ms and completion_ratio. -func seed(t *testing.T, opts seedOpts) setup { - t.Helper() - pool, q := testPool(t) - ctx := context.Background() - u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - if opts.lbToken != "" { - if err := q.SetListenBrainzToken(ctx, dbq.SetListenBrainzTokenParams{ID: u.ID, ListenbrainzToken: &opts.lbToken}); err != nil { - t.Fatalf("token: %v", err) - } - } - if opts.lbEnabled { - if err := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ID: u.ID, ListenbrainzEnabled: true}); err != nil { - t.Fatalf("enabled: %v", err) - } - } - a, _ := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - al, _ := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) - tr, _ := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ - Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/y.flac", DurationMs: 300_000, - }) - // Insert play_session + play_event directly with the provided duration/ratio. - var sessionID pgtype.UUID - if err := pool.QueryRow(ctx, - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - u.ID).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - var peID pgtype.UUID - if err := pool.QueryRow(ctx, - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '1 minute', now(), $4, $5, false) RETURNING id`, - u.ID, tr.ID, sessionID, opts.durationMs, opts.ratio).Scan(&peID); err != nil { - t.Fatalf("play_event: %v", err) - } - return setup{pool: pool, q: q, user: u.ID, playEvent: peID} -} - -type seedOpts struct { - lbToken string - lbEnabled bool - durationMs int32 - ratio float64 -} - -func countQueueRows(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func TestMaybeEnqueue_QualifyingPlayWithEnabledUser_InsertsRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 1 { - t.Errorf("rows = %d, want 1", got) - } -} - -func TestMaybeEnqueue_SubThreshold_NoRow(t *testing.T) { - // 100s, 33% — neither threshold met. - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 100_000, ratio: 0.33}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (sub-threshold)", got) - } -} - -func TestMaybeEnqueue_DisabledUser_NoRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: false, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (disabled)", got) - } -} - -func TestMaybeEnqueue_NoToken_NoRow(t *testing.T) { - // Even with enabled=true, an empty token shouldn't enqueue. - s := seed(t, seedOpts{lbToken: "", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (no token)", got) - } -} - -func TestMaybeEnqueue_Idempotent_TwoCallsOneRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - for i := 0; i < 2; i++ { - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue #%d: %v", i, err) - } - } - if got := countQueueRows(t, s); got != 1 { - t.Errorf("rows = %d, want 1 (idempotent)", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` - -Expected: FAIL with "undefined: MaybeEnqueue". - -- [ ] **Step 3: Write the implementation** - -Create `internal/scrobble/queue.go`: - -```go -package scrobble - -import ( - "context" - "errors" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// 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. -// -// 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 { - pe, err := q.GetPlayEventByID(ctx, playEventID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - return err - } - // Only closed events qualify (DurationPlayedMs/CompletionRatio populated). - if pe.DurationPlayedMs == nil || pe.CompletionRatio == nil { - return nil - } - if !Qualifies(int(*pe.DurationPlayedMs), *pe.CompletionRatio) { - return nil - } - cfg, err := q.GetListenBrainzConfig(ctx, pe.UserID) - if err != nil { - return err - } - if !cfg.ListenbrainzEnabled || cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { - return nil - } - _, err = q.EnqueueScrobble(ctx, dbq.EnqueueScrobbleParams{ - UserID: pe.UserID, - PlayEventID: pe.ID, - }) - return err -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` - -Expected: PASS for all 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/queue.go internal/scrobble/queue_test.go -git commit -m "feat(scrobble): add MaybeEnqueue with idempotent insert + threshold gate" -``` - ---- - -## Task 6: Backoff schedule (pure) - -**Files:** -- Create: `internal/scrobble/worker.go` (skeleton with backoffDelay only) -- Create: `internal/scrobble/worker_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/worker_test.go`: - -```go -package scrobble - -import ( - "testing" - "time" -) - -func TestBackoffDelay(t *testing.T) { - cases := []struct { - attempts int - want time.Duration - ok bool - }{ - {1, 1 * time.Minute, true}, - {2, 5 * time.Minute, true}, - {3, 30 * time.Minute, true}, - {4, 2 * time.Hour, true}, - {5, 6 * time.Hour, true}, - {6, 0, false}, // exhausted - {99, 0, false}, // way past max - } - for _, c := range cases { - got, ok := backoffDelay(c.attempts) - if ok != c.ok { - t.Errorf("backoffDelay(%d) ok = %v, want %v", c.attempts, ok, c.ok) - } - if got != c.want { - t.Errorf("backoffDelay(%d) = %v, want %v", c.attempts, got, c.want) - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` - -Expected: FAIL with "undefined: backoffDelay". - -- [ ] **Step 3: Write the worker skeleton with backoffDelay** - -Create `internal/scrobble/worker.go`: - -```go -package scrobble - -import ( - "time" -) - -// backoffSchedule maps the failure count (1-indexed: 1 = first failure -// just happened) to the delay before the next attempt. Per spec §9.6: -// 1m → 5m → 30m → 2h → 6h, then give up. -var backoffSchedule = []time.Duration{ - 1 * time.Minute, - 5 * time.Minute, - 30 * time.Minute, - 2 * time.Hour, - 6 * time.Hour, -} - -const maxAttempts = 5 - -// backoffDelay returns the delay before the next attempt given the value -// of the `attempts` column AFTER the failure has been recorded (1-indexed). -// Returns (_, false) when attempts > maxAttempts (give up — the row should -// be marked failed). -func backoffDelay(attempts int) (time.Duration, bool) { - if attempts < 1 || attempts > maxAttempts { - return 0, false - } - return backoffSchedule[attempts-1], true -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` - -Expected: PASS for all cases. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/worker.go internal/scrobble/worker_test.go -git commit -m "feat(scrobble): add backoffDelay schedule (1m, 5m, 30m, 2h, 6h, give up)" -``` - ---- - -## Task 7: Worker tickOnce (DB-backed) - -**Files:** -- Modify: `internal/scrobble/worker.go` (add Worker struct, NewWorker, tickOnce, Run) -- Create: `internal/scrobble/worker_integration_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/scrobble/worker_integration_test.go`: - -```go -package scrobble - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// helperEnqueue inserts a queue row directly so we don't go through the threshold path. -func helperEnqueue(t *testing.T, s setup) { - t.Helper() - if _, err := s.pool.Exec(context.Background(), - `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, - s.user, s.playEvent); err != nil { - t.Fatalf("enqueue: %v", err) - } -} - -func helperPendingCount(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE status = 'pending' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("pending: %v", err) - } - return n -} - -func helperFailedCount(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE status = 'failed' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("failed: %v", err) - } - return n -} - -func helperPlayEventScrobbledAt(t *testing.T, s setup) bool { - t.Helper() - var v *time.Time - if err := s.pool.QueryRow(context.Background(), - `SELECT scrobbled_at FROM play_events WHERE id = $1`, s.playEvent).Scan(&v); err != nil { - t.Fatalf("scrobbled_at: %v", err) - } - return v != nil -} - -func newTestWorker(s setup, lb *listenbrainz.Client) *Worker { - return NewWorker(s.pool, lb, helperLogger()) -} - -func helperLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } - -func TestWorker_Success_DeletesQueueRowAndStampsScrobbledAt(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if helperPendingCount(t, s) != 0 { - t.Error("queue row should be deleted on success") - } - if helperFailedCount(t, s) != 0 { - t.Error("no failed row expected on success") - } - if !helperPlayEventScrobbledAt(t, s) { - t.Error("play_events.scrobbled_at should be populated on success") - } -} - -func TestWorker_503Once_RowRemainsAttempts1(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - var attempts int - if err := s.pool.QueryRow(context.Background(), - `SELECT attempts FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts); err != nil { - t.Fatalf("attempts: %v", err) - } - if attempts != 1 { - t.Errorf("attempts = %d, want 1 after 503", attempts) - } - if helperPendingCount(t, s) != 1 { - t.Error("row should remain pending after transient failure") - } -} - -func TestWorker_503FiveTimes_MarksFailed(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - // Five failures: each tick marches next_attempt_at forward, so we need to - // reset it between ticks to simulate the time passing. - for i := 0; i < 5; i++ { - if _, err := s.pool.Exec(context.Background(), - `UPDATE scrobble_queue SET next_attempt_at = now() WHERE play_event_id = $1`, s.playEvent); err != nil { - t.Fatalf("reset: %v", err) - } - _ = w.tickOnce(context.Background()) - } - if helperFailedCount(t, s) != 1 { - t.Errorf("expected row marked failed after 5 attempts; pending=%d failed=%d", - helperPendingCount(t, s), helperFailedCount(t, s)) - } - var attempts int - var lastErr *string - _ = s.pool.QueryRow(context.Background(), - `SELECT attempts, last_error FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts, &lastErr) - if attempts != 5 { - t.Errorf("attempts = %d, want 5", attempts) - } - if lastErr == nil || *lastErr == "" { - t.Error("last_error should be populated") - } -} - -func TestWorker_401_MarksFailedAndDisablesUser(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - if helperFailedCount(t, s) != 1 { - t.Error("401 should mark failed immediately") - } - cfg, _ := s.q.GetListenBrainzConfig(context.Background(), s.user) - if cfg.ListenbrainzEnabled { - t.Error("user listenbrainz_enabled should be set to false on auth failure") - } -} - -func TestWorker_429_RespectsRetryAfterWithoutIncrementingAttempts(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "300") - w.WriteHeader(http.StatusTooManyRequests) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - var attempts int - var nextAttemptAt time.Time - _ = s.pool.QueryRow(context.Background(), - `SELECT attempts, next_attempt_at FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent). - Scan(&attempts, &nextAttemptAt) - if attempts != 0 { - t.Errorf("attempts = %d, want 0 (429 should not increment)", attempts) - } - if d := time.Until(nextAttemptAt); d < 250*time.Second || d > 350*time.Second { - t.Errorf("next_attempt_at = +%v, want ~+5m (Retry-After 300)", d) - } -} - -func TestWorker_BatchOfTen_OnePost(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - // Enqueue 10 distinct play_events to test batching. - for i := 0; i < 10; i++ { - var newPE pgtype.UUID - if err := s.pool.QueryRow(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - SELECT user_id, track_id, session_id, now(), now(), duration_played_ms, completion_ratio, was_skipped FROM play_events WHERE id = $1 - RETURNING id`, s.playEvent).Scan(&newPE); err != nil { - t.Fatalf("dup play_event: %v", err) - } - if _, err := s.pool.Exec(context.Background(), - `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, - s.user, newPE); err != nil { - t.Fatalf("enqueue: %v", err) - } - } - var posts atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - posts.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - if posts.Load() != 1 { - t.Errorf("LB POSTs = %d, want 1 (batch)", posts.Load()) - } -} -``` - -Note: this test file imports `io`, `log/slog`, `pgtype` — make sure to add those imports at the top of the file. The exact import block: - -```go -import ( - "context" - "errors" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) -``` - -(The `errors` and `strings` imports may be unused — remove if Go compiler complains.) - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run Worker -v` - -Expected: FAIL with "undefined: NewWorker" and "undefined: tickOnce". - -- [ ] **Step 3: Implement Worker, NewWorker, tickOnce, Run** - -Replace `internal/scrobble/worker.go` contents: - -```go -package scrobble - -import ( - "context" - "errors" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// backoffSchedule maps the failure count (1-indexed) to the delay before -// the next attempt. Per spec §9.6: 1m → 5m → 30m → 2h → 6h, then give up. -var backoffSchedule = []time.Duration{ - 1 * time.Minute, - 5 * time.Minute, - 30 * time.Minute, - 2 * time.Hour, - 6 * time.Hour, -} - -const maxAttempts = 5 - -// backoffDelay returns the delay before the next attempt given the value -// of the `attempts` column AFTER the failure has been recorded (1-indexed). -func backoffDelay(attempts int) (time.Duration, bool) { - if attempts < 1 || attempts > maxAttempts { - return 0, false - } - return backoffSchedule[attempts-1], true -} - -// Worker drains scrobble_queue rows and POSTs them to ListenBrainz. -type Worker struct { - pool *pgxpool.Pool - client *listenbrainz.Client - logger *slog.Logger - tick time.Duration - batch int32 -} - -// NewWorker constructs a worker with production defaults: 30s tick, 50-row -// batch. -func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker { - return &Worker{pool: pool, client: client, logger: logger, tick: 30 * time.Second, batch: 50} -} - -// Run blocks until ctx is cancelled, ticking every w.tick. -func (w *Worker) Run(ctx context.Context) { - t := time.NewTicker(w.tick) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := w.tickOnce(ctx); err != nil { - w.logger.Error("scrobble: tick failed", "err", err) - } - } - } -} - -// tickOnce drains up to w.batch pending rows. Returns the first error -// encountered loading rows; per-row errors are logged and don't abort -// the batch. -func (w *Worker) tickOnce(ctx context.Context) error { - q := dbq.New(w.pool) - rows, err := q.ListPendingScrobbles(ctx, w.batch) - if err != nil { - return err - } - if len(rows) == 0 { - return nil - } - - // Group rows by user (each user has their own token + may produce its - // own batch). - type userBatch struct { - token string - queueIDs []pgtype.UUID - listens []listenbrainz.Listen - userID pgtype.UUID - playIDs []pgtype.UUID - } - batches := map[string]*userBatch{} - for _, r := range rows { - if r.LbToken == nil || *r.LbToken == "" || !r.LbEnabled { - // Defensive: row got into queue but user since disabled. Skip. - continue - } - key := r.UserID.String() - ub := batches[key] - if ub == nil { - ub = &userBatch{token: *r.LbToken, userID: r.UserID} - batches[key] = ub - } - ub.queueIDs = append(ub.queueIDs, r.QueueID) - ub.playIDs = append(ub.playIDs, r.PlayEventID) - l := listenbrainz.Listen{ - ListenedAt: r.StartedAt.Time.Unix(), - Track: listenbrainz.Track{ - ArtistName: r.ArtistName, - TrackName: r.TrackTitle, - ReleaseName: r.AlbumTitle, - DurationMs: int(r.TrackDurationMs), - }, - } - if r.TrackMbid != nil { - l.Track.RecordingMBID = *r.TrackMbid - } - if r.AlbumMbid != nil { - l.Track.ReleaseMBID = *r.AlbumMbid - } - if r.ArtistMbid != nil { - l.Track.ArtistMBIDs = []string{*r.ArtistMbid} - } - ub.listens = append(ub.listens, l) - } - - for _, ub := range batches { - w.processBatch(ctx, q, ub.userID, ub.token, ub.queueIDs, ub.listens) - } - return nil -} - -// processBatch submits one user's pending batch and updates queue rows -// according to the response. -func (w *Worker) processBatch( - ctx context.Context, - q *dbq.Queries, - userID pgtype.UUID, - token string, - queueIDs []pgtype.UUID, - listens []listenbrainz.Listen, -) { - err := w.client.SubmitListens(ctx, token, listens) - now := time.Now().UTC() - - if err == nil { - for _, id := range queueIDs { - if uerr := q.MarkScrobbleSent(ctx, id); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleSent", "queue_id", id, "err", uerr) - } - } - return - } - - // 429: schedule retry per Retry-After, do NOT increment attempts. - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - next := pgtype.Timestamptz{Time: now.Add(ra.Wait), Valid: true} - for _, id := range queueIDs { - if uerr := q.MarkScrobbleRetryAfter(ctx, dbq.MarkScrobbleRetryAfterParams{ - ID: id, NextAttemptAt: next, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleRetryAfter", "err", uerr) - } - } - return - } - - // 401: mark failed AND disable user (defensive — bad token shouldn't keep - // retrying or feed future rows). - if errors.Is(err, listenbrainz.ErrAuth) { - w.logger.Warn("scrobble: auth rejected; disabling user listenbrainz", "user_id", userID) - if uerr := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ - ID: userID, ListenbrainzEnabled: false, - }); uerr != nil { - w.logger.Error("scrobble: SetListenBrainzEnabled", "err", uerr) - } - errStr := err.Error() - for _, id := range queueIDs { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - return - } - - // 4xx: permanent. Mark failed, no retry. - if errors.Is(err, listenbrainz.ErrPermanent) { - errStr := err.Error() - for _, id := range queueIDs { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - return - } - - // Transient (5xx, network): increment attempts, schedule next or give up. - // Each row in the batch shares the same fate since they were submitted - // together — we update each independently using its current attempts+1. - errStr := err.Error() - for _, id := range queueIDs { - // Read current attempts to compute next state. - var attempts int32 - if rerr := w.pool.QueryRow(ctx, - `SELECT attempts FROM scrobble_queue WHERE id = $1`, id).Scan(&attempts); rerr != nil { - w.logger.Error("scrobble: read attempts", "err", rerr) - continue - } - next := attempts + 1 - if delay, ok := backoffDelay(int(next)); ok { - nextAt := pgtype.Timestamptz{Time: now.Add(delay), Valid: true} - if uerr := q.RescheduleScrobble(ctx, dbq.RescheduleScrobbleParams{ - ID: id, NextAttemptAt: nextAt, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: RescheduleScrobble", "err", uerr) - } - } else { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - } -} -``` - -- [ ] **Step 4: Run all scrobble tests** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/... -v` - -Expected: PASS for all worker tests + previously-passing tests in `listenbrainz/`, `threshold_test.go`, `queue_test.go`, `worker_test.go`. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/worker.go internal/scrobble/worker_integration_test.go -git commit -m "feat(scrobble): add Worker with tickOnce, batched submit, retry/fail/auth handling" -``` - ---- - -## Task 8: Hook MaybeEnqueue into playevents.Writer - -**Files:** -- Modify: `internal/playevents/writer.go` -- Modify: `internal/playevents/writer_test.go` (or a sibling file, depending on existing structure) - -- [ ] **Step 1: Write the failing test** - -Append to `internal/playevents/writer_test.go` (or create one if missing): - -```go -func TestRecordPlayEnded_QualifyingPlay_EnqueuesScrobble(t *testing.T) { - if testing.Short() { - t.Skip("integration test") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - pool, q := newTestPool(t, dsn) // assume helper from existing writer_test.go - defer pool.Close() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - - user, track := seedUserAndTrack(t, q, "tester", 300_000) // helper, set duration=300s - - // Enable LB. - tk := "tk" - if err := q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}); err != nil { - t.Fatalf("token: %v", err) - } - if err := q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}); err != nil { - t.Fatalf("enable: %v", err) - } - - // Start + end with 250s played (qualifies). - res, err := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) - if err != nil { - t.Fatalf("start: %v", err) - } - if err := w.RecordPlayEnded(context.Background(), res.PlayEventID, 250_000, time.Now()); err != nil { - t.Fatalf("end: %v", err) - } - - // Allow the post-commit goroutine (if any) to settle. Currently MaybeEnqueue - // is called synchronously after BeginFunc returns, so a sleep isn't needed — - // but if we ever switch to a goroutine, this gives it time. - time.Sleep(50 * time.Millisecond) - - var n int - if err := pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - if n != 1 { - t.Errorf("scrobble_queue rows = %d, want 1", n) - } -} - -func TestRecordPlayEnded_SubThreshold_NoEnqueue(t *testing.T) { - if testing.Short() { - t.Skip("integration test") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - pool, q := newTestPool(t, dsn) - defer pool.Close() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - user, track := seedUserAndTrack(t, q, "tester", 300_000) - - tk := "tk" - _ = q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}) - _ = q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}) - - res, _ := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) - _ = w.RecordPlayEnded(context.Background(), res.PlayEventID, 60_000, time.Now()) // 20%, well below threshold - - var n int - _ = pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n) - if n != 0 { - t.Errorf("expected 0 queue rows for sub-threshold play, got %d", n) - } -} -``` - -If `seedUserAndTrack` and `newTestPool` aren't already in `writer_test.go`, look for the existing fixture functions (the existing tests must use something equivalent) and reuse those. If they're truly absent, write minimal versions following the pattern from `internal/scrobble/queue_test.go::seed`. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -run RecordPlayEnded.Scrobble -v` - -Expected: tests fail because no enqueue hook exists yet. - -- [ ] **Step 3: Add the post-commit enqueue hook** - -In `internal/playevents/writer.go`, modify `RecordPlayEnded` to add the post-commit `MaybeEnqueue` call: - -```go -import ( - // existing imports … - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" -) - -func (w *Writer) RecordPlayEnded( - ctx context.Context, - playEventID pgtype.UUID, - durationPlayedMs int32, - at time.Time, -) error { - if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { - // … existing close logic … - }); err != nil { - return err - } - - // Post-commit best-effort scrobble enqueue. Errors logged + swallowed: - // scrobble delivery is enrichment, not a precondition for canonical play - // history. - if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { - w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) - } - return nil -} -``` - -Apply the **same pattern** to `RecordSyntheticCompletedPlay`: keep the existing `pgx.BeginFunc`, capture the inserted play_event ID, and after the commit call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), evID)`. The existing function already creates and ends the play_event in one transaction; you'll need to surface the ID up out of the closure into a local variable before the post-commit call. - -The exact diff for `RecordSyntheticCompletedPlay`: - -```go -func (w *Writer) RecordSyntheticCompletedPlay( - ctx context.Context, - userID, trackID pgtype.UUID, - clientID string, - at time.Time, -) error { - var playEventID pgtype.UUID - if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { - // … existing logic, but assign ev.ID to playEventID before returning … - // e.g. after `ev, err := q.InsertPlayEvent(...)` succeeds: - // playEventID = ev.ID - }); err != nil { - return err - } - if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { - w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) - } - return nil -} -``` - -(Inspect the current `RecordSyntheticCompletedPlay` body and add `playEventID = ev.ID` immediately after `q.InsertPlayEvent` returns. The function ends the play_event in the same transaction; a single playEventID variable captured pre-commit is correct.) - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -v` - -Expected: PASS for all existing tests + 2 new scrobble-enqueue tests. - -- [ ] **Step 5: Verify Subsonic scrobble path also passes** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/subsonic/ -v` - -Expected: PASS. Pre-existing scrobble integration tests still pass; the post-commit enqueue is a no-op for users without LB enabled. - -- [ ] **Step 6: Commit** - -```bash -git add internal/playevents/writer.go internal/playevents/writer_test.go -git commit -m "feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic" -``` - ---- - -## Task 9: API endpoints — GET/PUT /api/me/listenbrainz - -**Files:** -- Create: `internal/api/listenbrainz.go` -- Create: `internal/api/listenbrainz_test.go` -- Modify: `internal/api/api.go::Mount` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/api/listenbrainz_test.go`: - -```go -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -type lbStatusJSON struct { - Enabled bool `json:"enabled"` - TokenSet bool `json:"token_set"` - LastScrobbledAt *string `json:"last_scrobbled_at"` -} - -func callGetLB(h *handlers, user dbq.User) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/api/me/listenbrainz", nil) - req = req.WithContext(auth.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - h.handleGetListenBrainz(w, req) - return w -} - -func callPutLB(h *handlers, user dbq.User, body any) *httptest.ResponseRecorder { - buf, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPut, "/api/me/listenbrainz", bytes.NewReader(buf)) - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(auth.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - h.handlePutListenBrainz(w, req) - return w -} - -func TestHandleGetListenBrainz_NoTokenInitial(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - w := callGetLB(h, u) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp lbStatusJSON - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.Enabled || resp.TokenSet || resp.LastScrobbledAt != nil { - t.Errorf("initial state wrong: %+v", resp) - } -} - -func TestHandlePutListenBrainz_SetTokenThenGet(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - - put := callPutLB(h, u, map[string]any{"token": "abc"}) - if put.Code != http.StatusOK { - t.Fatalf("PUT status = %d body = %s", put.Code, put.Body.String()) - } - - get := callGetLB(h, u) - var resp lbStatusJSON - _ = json.Unmarshal(get.Body.Bytes(), &resp) - if !resp.TokenSet { - t.Error("TokenSet = false after PUT, want true") - } -} - -func TestHandlePutListenBrainz_ClearTokenForcesEnabledFalse(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - - _ = callPutLB(h, u, map[string]any{"token": "abc"}) - _ = callPutLB(h, u, map[string]any{"enabled": true}) - clear := callPutLB(h, u, map[string]any{"token": ""}) - if clear.Code != http.StatusOK { - t.Fatalf("clear status = %d", clear.Code) - } - - get := callGetLB(h, u) - var resp lbStatusJSON - _ = json.Unmarshal(get.Body.Bytes(), &resp) - if resp.TokenSet { - t.Error("TokenSet = true after clearing") - } - if resp.Enabled { - t.Error("Enabled = true after clearing token, want false (force-cleared)") - } -} - -func TestHandlePutListenBrainz_EnableWithoutToken_400(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - resp := callPutLB(h, u, map[string]any{"enabled": true}) - if resp.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400 (cannot enable without token)", resp.Code) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` - -Expected: FAIL — handlers undefined. - -- [ ] **Step 3: Implement the handlers** - -Create `internal/api/listenbrainz.go`: - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LBStatus is the GET response body and a subset of the PUT response. -type LBStatus struct { - Enabled bool `json:"enabled"` - TokenSet bool `json:"token_set"` - LastScrobbledAt *string `json:"last_scrobbled_at"` -} - -func (h *handlers) handleGetListenBrainz(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - resp, err := buildLBStatus(r, h, user.ID) - if err != nil { - h.logger.Error("api: get listenbrainz", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - writeJSON(w, http.StatusOK, resp) -} - -type putLBBody struct { - Token *string `json:"token,omitempty"` - Enabled *bool `json:"enabled,omitempty"` -} - -func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - var body putLBBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid json") - return - } - - q := dbq.New(h.pool) - if body.Token != nil { - t := *body.Token - var tokenPtr *string - if t != "" { - tokenPtr = &t - } // empty string clears via SetListenBrainzToken's COALESCE handling - if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{ - ID: user.ID, ListenbrainzToken: tokenPtr, - }); err != nil { - h.logger.Error("api: set listenbrainz token", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "update failed") - return - } - } - if body.Enabled != nil && *body.Enabled { - // Enabling requires a non-empty token. - cfg, err := q.GetListenBrainzConfig(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: get listenbrainz config", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "cannot enable without a token") - return - } - } - if body.Enabled != nil { - if err := q.SetListenBrainzEnabled(r.Context(), dbq.SetListenBrainzEnabledParams{ - ID: user.ID, ListenbrainzEnabled: *body.Enabled, - }); err != nil { - h.logger.Error("api: set listenbrainz enabled", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "update failed") - return - } - } - - resp, err := buildLBStatus(r, h, user.ID) - if err != nil { - h.logger.Error("api: put listenbrainz refresh status", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - writeJSON(w, http.StatusOK, resp) -} - -func buildLBStatus(r *http.Request, h *handlers, userID pgtypeUUID) (LBStatus, error) { - q := dbq.New(h.pool) - cfg, err := q.GetListenBrainzConfig(r.Context(), userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return LBStatus{}, nil - } - return LBStatus{}, err - } - out := LBStatus{ - Enabled: cfg.ListenbrainzEnabled, - TokenSet: cfg.ListenbrainzToken != nil && *cfg.ListenbrainzToken != "", - } - if cfg.LastScrobbledAt.Valid { - s := cfg.LastScrobbledAt.Time.UTC().Format(time.RFC3339) - out.LastScrobbledAt = &s - } - return out, nil -} -``` - -Note: `pgtypeUUID` is shorthand here — replace with the actual `pgtype.UUID` import. Add `"github.com/jackc/pgx/v5/pgtype"` and `"time"` to the imports. Update the function signature: `func buildLBStatus(r *http.Request, h *handlers, userID pgtype.UUID) (LBStatus, error)`. - -- [ ] **Step 4: Mount the routes** - -In `internal/api/api.go::Mount`, add inside the `authed.Group(...)` block (after the existing `/me` route): - -```go -authed.Get("/me/listenbrainz", h.handleGetListenBrainz) -authed.Put("/me/listenbrainz", h.handlePutListenBrainz) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` - -Expected: PASS for all 4 tests. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/listenbrainz.go internal/api/listenbrainz_test.go internal/api/api.go -git commit -m "feat(api): add GET/PUT /api/me/listenbrainz endpoints" -``` - ---- - -## Task 10: Boot the worker in main.go - -**Files:** -- Modify: `cmd/minstrel/main.go` - -- [ ] **Step 1: Wire the worker startup** - -In `cmd/minstrel/main.go`, add the imports: - -```go -"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" -"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -``` - -After the `pool` is opened (after `pool, err := db.Open(...)` and the `defer pool.Close()`), and before the HTTP server starts, add: - -```go -// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s, -// drains up to 50 pending rows per tick. Per-user gating happens inside the -// worker (rows from disabled users are skipped). -scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble")) -go scrobbleWorker.Run(ctx) -``` - -- [ ] **Step 2: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 3: Verify the worker actually starts** - -Run the binary briefly and check the logs for the scrobble worker: - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' SMARTMUSIC_LIBRARY_SCAN_PATHS='' SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20` - -Expected: server starts, no panic, worker tick logs (or absence of error logs) demonstrate Run is alive. - -- [ ] **Step 4: Commit** - -```bash -git add cmd/minstrel/main.go -git commit -m "feat(cmd): start scrobble worker alongside HTTP server" -``` - ---- - -## Task 11: Frontend — apiPut + listenbrainz API helpers + /settings page - -**Files:** -- Modify: `web/src/lib/api/client.ts` -- Create: `web/src/lib/api/listenbrainz.ts` -- Create: `web/src/routes/settings/+page.svelte` -- Create: `web/src/routes/settings/settings.test.ts` -- Modify: `web/src/lib/components/Shell.svelte` - -- [ ] **Step 1: Add `api.put` helper** - -In `web/src/lib/api/client.ts`, add a `put` method to the `api` object. The existing object looks like: - -```ts -export const api = { - get: (path: string): Promise => apiFetch(path) as Promise, - post: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, - del: (path: string): Promise => - apiFetch(path, { method: 'DELETE' }) as Promise -}; -``` - -Replace with: - -```ts -export const api = { - get: (path: string): Promise => apiFetch(path) as Promise, - post: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, - put: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, - del: (path: string): Promise => - apiFetch(path, { method: 'DELETE' }) as Promise -}; -``` - -- [ ] **Step 2: Add ListenBrainz API helpers** - -Create `web/src/lib/api/listenbrainz.ts`: - -```ts -import { api } from './client'; - -export type LBStatus = { - enabled: boolean; - token_set: boolean; - last_scrobbled_at: string | null; -}; - -export function getListenBrainzStatus(): Promise { - return api.get('/api/me/listenbrainz'); -} - -export function setListenBrainzToken(token: string): Promise { - return api.put('/api/me/listenbrainz', { token }); -} - -export function setListenBrainzEnabled(enabled: boolean): Promise { - return api.put('/api/me/listenbrainz', { enabled }); -} -``` - -- [ ] **Step 3: Create the Settings page** - -Create `web/src/routes/settings/+page.svelte`: - -```svelte - - -
-

Settings

- -
-

ListenBrainz

- - {#if $status.isPending} -

Loading…

- {:else if $status.isError} -

Couldn't load status.

- {:else if $status.data} -
- {/if} -
-
-``` - -- [ ] **Step 4: Add Settings to the nav** - -In `web/src/lib/components/Shell.svelte`, in the `navItems` array: - -```ts -const navItems = [ - { href: '/', label: 'Library' }, - { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } -]; -``` - -- [ ] **Step 5: Write the failing settings tests** - -Create `web/src/routes/settings/settings.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; -import { createRawSnippet } from 'svelte'; - -vi.mock('$lib/api/listenbrainz', () => ({ - getListenBrainzStatus: vi.fn(), - setListenBrainzToken: vi.fn(), - setListenBrainzEnabled: vi.fn() -})); - -import SettingsPage from './+page.svelte'; -import { - getListenBrainzStatus, - setListenBrainzToken, - setListenBrainzEnabled -} from '$lib/api/listenbrainz'; - -function renderPage() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(QueryClientProvider, { - props: { - client: qc, - children: createRawSnippet(() => ({ render: () => '
' })) - } - }); - // NOTE: The above is a sketch; actual integration into your QueryClientProvider - // wrapping should match the pattern used in /library/liked tests. See - // web/src/routes/library/liked/liked.test.ts for the canonical example. -} -``` - -In practice, follow the pattern used by `web/src/routes/library/liked/liked.test.ts` (which already wraps a route with `QueryClientProvider`) — copy that boilerplate. Replace the test cases with the SearchBox tests below: - -```ts -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('Settings page — ListenBrainz', () => { - test('token-not-set state renders input + Save button', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - renderPage(); - await waitFor(() => expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument()); - expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); - }); - - test('token-set state renders masked + Clear button', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: true, last_scrobbled_at: null - }); - renderPage(); - await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument()); - expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument(); - }); - - test('Save button calls setListenBrainzToken with typed value', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - (setListenBrainzToken as any).mockResolvedValue({ enabled: false, token_set: true, last_scrobbled_at: null }); - renderPage(); - const input = await screen.findByPlaceholderText(/paste your lb token/i); - await fireEvent.input(input, { target: { value: 'mytoken' } }); - await fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => expect(setListenBrainzToken).toHaveBeenCalledWith('mytoken')); - }); - - test('Enabled checkbox is disabled when no token', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - renderPage(); - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeDisabled(); - }); - - test('Last-scrobbled-at renders when present', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: true, - token_set: true, - last_scrobbled_at: '2026-04-28T03:00:00Z' - }); - renderPage(); - await waitFor(() => expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument()); - }); -}); -``` - -- [ ] **Step 6: Run web tests + check + build** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3 && npm test -- --run src/routes/settings 2>&1 | tail -10` - -Expected: type-check clean; 5 settings tests pass. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run 2>&1 | tail -5` - -Expected: full vitest suite passes (175 + 5 new = 180). - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` - -Expected: adapter-static emits `web/build/`. - -- [ ] **Step 7: Commit** - -```bash -git add web/ -git commit -m "feat(web): add /settings page with ListenBrainz section" -``` - ---- - -## Task 12: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 14 packages (now including `internal/scrobble` and `internal/scrobble/listenbrainz`). - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it; not introduced by this PR). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check on new package** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4a.out ./internal/scrobble/... && go tool cover -func=/tmp/cover-m4a.out | tail -5` - -Expected: `internal/scrobble` and `internal/scrobble/listenbrainz` combined coverage ≥ 80%. - -- [ ] **Step 5: Web verification** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; 180 vitest tests pass (was 175); build succeeds. - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4a-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual end-to-end verification** - -Set up: -1. `docker compose up --build -d minstrel` — pick up new code -2. Generate a token at https://listenbrainz.org/profile/ -3. /settings → paste token → Save -4. /settings → toggle "Send my plays to ListenBrainz" -5. Play a track for ≥240 seconds OR finish it -6. Within 30 seconds, check listenbrainz.org/ — listen should appear -7. /settings → "Last scrobbled" timestamp populates - -Sad path: -8. /settings → enter a deliberately bad token → toggle enabled → play a track -9. Within 30s, check `psql -c "SELECT status, last_error FROM scrobble_queue"` — row marked `failed`, error mentions auth -10. /settings refresh → enabled toggle has been auto-cleared - -- [ ] **Step 8: Update Fable task #345** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with summary of what shipped. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4a closes; M4b (similarity ingest) is next. - ---- - -## Self-Review - -**Spec coverage:** -- §3.1 (new packages) → Tasks 3, 4, 5, 6, 7 ✓ -- §3.2 (existing-code hooks) → Task 8 (Writer hooks), Task 9 (api.go Mount), Task 10 (main.go) ✓ -- §4 (database schema) → Tasks 1, 2 ✓ -- §5.1 (LB client) → Task 3 ✓ -- §5.2 (threshold) → Task 4 ✓ -- §5.3 (MaybeEnqueue) → Task 5 ✓ -- §5.4 (Worker, Run, tickOnce, backoffDelay) → Tasks 6, 7 ✓ -- §5.5 (API endpoints) → Task 9 ✓ -- §6 (frontend) → Task 11 ✓ -- §7.1-7.3 (test plan) → embedded across Tasks 3-11 ✓ -- §7.4 (coverage target) → Task 12 step 4 ✓ -- §7.5 (manual verification) → Task 12 step 7 ✓ -- §8 (backwards compat) → preserved by zero-default user columns + new package not affecting existing code ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. - -**Type consistency:** -- `LBStatus` shape (Go) matches `LBStatus` shape (TypeScript) — `enabled`, `token_set`, `last_scrobbled_at`. -- `MaybeEnqueue(ctx, q, playEventID)` signature consistent across Tasks 5 and 8. -- `backoffDelay(attempts int) (time.Duration, bool)` — same in Task 6 and Task 7. -- `Worker.tickOnce(ctx) error` — same in Task 7 and Task 12. -- sqlc query names consistent across Task 2 (definitions) and Tasks 5/7/9 (callers). diff --git a/docs/superpowers/plans/2026-04-28-m4b-similarity.md b/docs/superpowers/plans/2026-04-28-m4b-similarity.md deleted file mode 100644 index d39dbc5e..00000000 --- a/docs/superpowers/plans/2026-04-28-m4b-similarity.md +++ /dev/null @@ -1,1551 +0,0 @@ -# M4b — ListenBrainz Inbound Similarity Ingest 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:** Background worker pulls track-track and artist-artist similarity edges from ListenBrainz's public `/explore/*` endpoints, filters to the local library (top-K=20 per source), stores in two new `track_similarity` + `artist_similarity` tables. Hourly tick, 7-day re-fetch cap per row, passive retry via timer. - -**Architecture:** New `internal/similarity/` package with a `Worker` goroutine paralleling M4a's scrobble worker. Existing `internal/scrobble/listenbrainz/Client` gets two new methods (`SimilarRecordings`, `SimilarArtists`). New migration adds two symmetric tables with multi-source primary keys + score-DESC indexes for M4c's hot-path query. No frontend changes. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes. Runs alongside the M4a scrobble worker. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4b-similarity-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0009_similarity.up.sql` + `.down.sql` | `track_similarity` + `artist_similarity` tables (multi-source PK, score-DESC indexes). | -| `internal/db/queries/similarity.sql` | New: `ListPlayedTracksNeedingSimilarity`, `ListPlayedArtistsNeedingSimilarity`, `GetTracksByMBIDs`, `GetArtistsByMBIDs`, `UpsertTrackSimilarity`, `UpsertArtistSimilarity`. | -| `internal/db/dbq/similarity.sql.go` | Generated bindings. | -| `internal/similarity/worker.go` | `Worker` struct, `NewWorker`, `Run` (hourly ticker), `tickOnce` (drains one batch of tracks + one batch of artists). | -| `internal/similarity/worker_test.go` | Pure unit tests (no DB). | -| `internal/similarity/worker_integration_test.go` | Live-DB + httptest LB end-to-end tests. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/scrobble/listenbrainz/client.go` | Add `SimilarRecording` + `SimilarArtist` types, `SimilarRecordings(ctx, mbid, limit)` + `SimilarArtists(ctx, mbid, limit)` methods. Re-uses the existing `Client` struct, error types, and HTTP timeout pattern from M4a. | -| `internal/scrobble/listenbrainz/client_test.go` | 14 new httptest-driven tests (7 per method) covering success path, error mappings, query-param assertions. | -| `cmd/minstrel/main.go` | Construct `similarity.NewWorker(...)` after the M4a scrobble worker; `go similarityWorker.Run(ctx)`. | - -**No frontend changes.** M4b is invisible until M4c reads the data. - ---- - -## Task 1: Migration 0009 — schema - -**Files:** -- Create: `internal/db/migrations/0009_similarity.up.sql` -- Create: `internal/db/migrations/0009_similarity.down.sql` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0009_similarity.up.sql`: - -```sql --- M4b: ListenBrainz similarity ingest. Two symmetric tables — tracks and --- artists — both keyed by (a_id, b_id, source) so the schema reserves room --- for additional similarity sources (musicbrainz_tag, user_cooccurrence) --- alongside listenbrainz. M4b only writes 'listenbrainz' rows. --- --- The (a_id, score DESC) index satisfies M4c's hot path: "for seed S, give --- me top-N similar tracks/artists descending by score." - -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); -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0009_similarity.down.sql`: - -```sql -DROP TABLE IF EXISTS artist_similarity; -DROP TABLE IF EXISTS track_similarity; -``` - -- [ ] **Step 3: Verify the migration applies cleanly** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race ./internal/db/` - -Expected: PASS. The migration test should now find version 9. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/migrations/0009_similarity.up.sql internal/db/migrations/0009_similarity.down.sql -git commit -m "feat(db): add migration 0009 for similarity (track_similarity + artist_similarity)" -``` - ---- - -## Task 2: sqlc queries - -**Files:** -- Create: `internal/db/queries/similarity.sql` -- Generated: `internal/db/dbq/similarity.sql.go` - -- [ ] **Step 1: Create the queries file** - -Create `internal/db/queries/similarity.sql`: - -```sql --- name: ListPlayedTracksNeedingSimilarity :many --- Tracks with at least one play, an MBID, and no fresh listenbrainz row --- (no row at all, OR fetched_at older than 7 days). Used by the worker --- to find work each tick. Bounded by $1. -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 --- Bulk in-library lookup: maps a slice of MBIDs back to local track IDs. --- Used by the worker to filter LB-returned MBIDs to those actually in the --- user's library. -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 --- Refresh-in-place: if (a, b, source) already exists, update score and --- fetched_at. Idempotent. -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; -``` - -- [ ] **Step 2: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: dockerized sqlc 1.27.0 regenerates `internal/db/dbq/similarity.sql.go` with the 6 new methods. Other `*.sql.go` files may receive trivial version-comment updates from sqlc — stage them all. - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. No callers yet — generated bindings just need to compile. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/queries/similarity.sql internal/db/dbq/ -git commit -m "feat(db): add similarity sqlc queries (list-needing, get-by-mbids, upsert)" -``` - ---- - -## Task 3: LB client `SimilarRecordings` method - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` (append types + method) -- Modify: `internal/scrobble/listenbrainz/client_test.go` (append tests) - -The existing `Client` already has `SubmitListens` from M4a with `BaseURL`, `HTTP`, and the typed errors `ErrAuth`/`ErrPermanent`/`ErrTransient`/`*RetryAfterError`. We add a sibling read-only method. - -LB algorithm parameter: hardcode `session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30` — this is the documented default at the time of writing. If LB changes its default at implementation time, swap to whatever's current per their `/explore/similar-recordings/` docs. - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/scrobble/listenbrainz/client_test.go`: - -```go -func TestClient_SimilarRecordings_Success(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - // Body shape from LB's API docs (simplified for tests). - _, _ = w.Write([]byte(`[ - {"recording_mbid": "11111111-1111-1111-1111-111111111111", "score": 0.95}, - {"recording_mbid": "22222222-2222-2222-2222-222222222222", "score": 0.80} - ]`)) - }) - defer srv.Close() - out, err := c.SimilarRecordings(context.Background(), "aaaa", 100) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(out) != 2 { - t.Fatalf("got %d results, want 2", len(out)) - } - if out[0].MBID != "11111111-1111-1111-1111-111111111111" || out[0].Score != 0.95 { - t.Errorf("first result wrong: %+v", out[0]) - } -} - -func TestClient_SimilarRecordings_AlgorithmParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarRecordings(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "algorithm=") { - t.Errorf("URL missing algorithm param: %q", seenURL) - } -} - -func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarRecordings(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") { - t.Errorf("URL missing count/limit param: %q", seenURL) - } -} - -func TestClient_SimilarRecordings_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SimilarRecordings_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SimilarRecordings_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SimilarRecordings_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "120") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 120*time.Second { - t.Errorf("Wait = %v, want 120s", ra.Wait) - } -} -``` - -Make sure `strings` is in the existing import block at the top of the file (it almost certainly already is from M4a — if not, add it). - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarRecordings -v` - -Expected: FAIL with "undefined: SimilarRecording" / "undefined: SimilarRecordings". - -- [ ] **Step 3: Implement the method** - -Append to `internal/scrobble/listenbrainz/client.go` (no new imports needed beyond what M4a already has — `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `net/http`, `strconv`, `time`): - -```go -// LB algorithm parameter for /explore/similar-recordings/. Hardcoded for v1 -// — matches LB's documented default. If LB changes the default, swap here. -const lbSimilarRecordingsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" - -// LB algorithm parameter for /explore/similar-artists/. -const lbSimilarArtistsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" - -// SimilarRecording is one entry in the /explore/similar-recordings response. -type SimilarRecording struct { - MBID string `json:"recording_mbid"` - Score float64 `json:"score"` -} - -// SimilarRecordings fetches up to `limit` similar recordings for the given -// recording MBID. Public endpoint — no token required. Returns the same -// typed errors as SubmitListens. -func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int) ([]SimilarRecording, error) { - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - url := fmt.Sprintf("%s/1/explore/similar-recordings/%s?algorithm=%s&count=%d", - base, mbid, lbSimilarRecordingsAlgorithm, limit) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("listenbrainz: build similar-recordings request: %w", err) - } - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - var out []SimilarRecording - if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil { - return nil, fmt.Errorf("%w: decode similar-recordings: %v", ErrTransient, derr) - } - return out, nil - case resp.StatusCode == http.StatusUnauthorized: - return nil, ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))} - case resp.StatusCode >= 500: - return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarRecordings -v` - -Expected: PASS for all 7 new tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/scrobble/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/scrobble/listenbrainz/client.go internal/scrobble/listenbrainz/client_test.go -git commit -m "feat(listenbrainz): add SimilarRecordings client method" -``` - ---- - -## Task 4: LB client `SimilarArtists` method - -Symmetric to Task 3. The artist endpoint has the same shape; the response key is `artist_mbid` instead of `recording_mbid`. - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` (append) -- Modify: `internal/scrobble/listenbrainz/client_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/scrobble/listenbrainz/client_test.go`: - -```go -func TestClient_SimilarArtists_Success(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[ - {"artist_mbid": "33333333-3333-3333-3333-333333333333", "score": 0.91}, - {"artist_mbid": "44444444-4444-4444-4444-444444444444", "score": 0.72} - ]`)) - }) - defer srv.Close() - out, err := c.SimilarArtists(context.Background(), "aaaa", 100) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(out) != 2 { - t.Fatalf("got %d, want 2", len(out)) - } - if out[0].MBID != "33333333-3333-3333-3333-333333333333" || out[0].Score != 0.91 { - t.Errorf("first result wrong: %+v", out[0]) - } -} - -func TestClient_SimilarArtists_AlgorithmParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarArtists(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "algorithm=") { - t.Errorf("URL missing algorithm param: %q", seenURL) - } -} - -func TestClient_SimilarArtists_LimitParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarArtists(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") { - t.Errorf("URL missing count/limit param: %q", seenURL) - } -} - -func TestClient_SimilarArtists_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SimilarArtists_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SimilarArtists_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SimilarArtists_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 60*time.Second { - t.Errorf("Wait = %v, want 60s", ra.Wait) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarArtists -v` - -Expected: FAIL with "undefined: SimilarArtists". - -- [ ] **Step 3: Implement the method** - -Append to `internal/scrobble/listenbrainz/client.go`: - -```go -// SimilarArtist is one entry in the /explore/similar-artists response. -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Score float64 `json:"score"` -} - -// SimilarArtists fetches up to `limit` similar artists for the given -// artist MBID. Public endpoint — no token required. Returns the same -// typed errors as SubmitListens. -func (c *Client) SimilarArtists(ctx context.Context, mbid string, limit int) ([]SimilarArtist, error) { - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - url := fmt.Sprintf("%s/1/explore/similar-artists/%s?algorithm=%s&count=%d", - base, mbid, lbSimilarArtistsAlgorithm, limit) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err) - } - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - var out []SimilarArtist - if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil { - return nil, fmt.Errorf("%w: decode similar-artists: %v", ErrTransient, derr) - } - return out, nil - case resp.StatusCode == http.StatusUnauthorized: - return nil, ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))} - case resp.StatusCode >= 500: - return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarArtists -v` - -Expected: PASS for all 7 new tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/scrobble/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/scrobble/listenbrainz/client.go internal/scrobble/listenbrainz/client_test.go -git commit -m "feat(listenbrainz): add SimilarArtists client method" -``` - ---- - -## Task 5: Worker skeleton + pure unit tests - -The Worker package gets skeleton types + `NewWorker` constructor first. Integration tests in Task 6 exercise the full pipeline. - -**Files:** -- Create: `internal/similarity/worker.go` -- Create: `internal/similarity/worker_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/similarity/worker_test.go`: - -```go -package similarity - -import ( - "io" - "log/slog" - "testing" - "time" -) - -func TestNewWorker_DefaultsMatchSpec(t *testing.T) { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWorker(nil, nil, logger) - if w.tick != 1*time.Hour { - t.Errorf("tick = %v, want 1h", w.tick) - } - if w.batch != 5 { - t.Errorf("batch = %d, want 5", w.batch) - } - if w.topK != 20 { - t.Errorf("topK = %d, want 20", w.topK) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/similarity/ -v` - -Expected: FAIL — package doesn't exist yet. - -- [ ] **Step 3: Write the worker skeleton** - -Create `internal/similarity/worker.go`: - -```go -// Package similarity owns the inbound ListenBrainz similarity ingest -// pipeline. A periodic worker queries LB's /explore/similar-recordings -// and /explore/similar-artists endpoints for tracks the user has played, -// filters returned MBIDs to the local library, and stores the top-K -// edges in track_similarity / artist_similarity for M4c's radio -// candidate-pool builder. -package similarity - -import ( - "context" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// Worker drains played-tracks-and-artists needing similarity and POSTs -// the results into track_similarity / artist_similarity. Failures are -// passively retried via the timer (no durable queue table — losing one -// tick's worth of refresh attempts is "1 hour of staleness," fine). -type Worker struct { - pool *pgxpool.Pool - client *listenbrainz.Client - logger *slog.Logger - tick time.Duration - batch int32 - topK int -} - -// NewWorker constructs a worker with production defaults: 1h tick, -// batch=5, topK=20. -func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker { - return &Worker{ - pool: pool, - client: client, - logger: logger, - tick: 1 * time.Hour, - batch: 5, - topK: 20, - } -} - -// Run blocks until ctx is cancelled, ticking every w.tick. -func (w *Worker) Run(ctx context.Context) { - t := time.NewTicker(w.tick) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := w.tickOnce(ctx); err != nil { - w.logger.Error("similarity: tick failed", "err", err) - } - } - } -} - -// tickOnce drains one batch of tracks and one batch of artists. Stub — -// implementation lands in Task 6 along with the integration tests that -// drive it. -func (w *Worker) tickOnce(ctx context.Context) error { - return nil -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/similarity/ -v` - -Expected: PASS for the constructor test. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/similarity/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/similarity/worker.go internal/similarity/worker_test.go -git commit -m "feat(similarity): add Worker skeleton with constructor + Run loop" -``` - ---- - -## Task 6: Worker tickOnce — full pipeline (integration) - -Full implementation of the worker drain path with comprehensive integration tests. - -**Files:** -- Modify: `internal/similarity/worker.go` (replace `tickOnce` stub with real impl) -- Create: `internal/similarity/worker_integration_test.go` - -The test file follows the M4a fixture pattern: a test pool helper that runs migrations, truncates known tables, and returns a `*pgxpool.Pool` + `*dbq.Queries`. Since the M4a `internal/scrobble` package already has a similar helper in `queue_test.go`, copy that shape. - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/similarity/worker_integration_test.go`: - -```go -package similarity - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) { - t.Helper() - if testing.Short() { - t.Skip("skipping in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE artist_similarity, track_similarity, scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool, dbq.New(pool) -} - -type fixture struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - artist dbq.Artist - album dbq.Album -} - -// newFixture creates a user, one artist (with MBID), and one album. -// Tests add tracks via seedTrack which optionally takes an MBID. -func newFixture(t *testing.T) fixture { - t.Helper() - pool, q := testPool(t) - ctx := context.Background() - u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - artistMbid := "aaaaaaaa-1111-1111-1111-111111111111" - a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "X", SortName: "X", Mbid: &artistMbid, - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{ - Title: "X", SortTitle: "X", ArtistID: a.ID, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - return fixture{pool: pool, q: q, user: u.ID, artist: a, album: al} -} - -func seedTrack(t *testing.T, f fixture, title string, mbid *string) dbq.Track { - t.Helper() - tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: f.album.ID, - ArtistID: f.artist.ID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: 200_000, - Mbid: mbid, - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return tr -} - -// markPlayed inserts a closed play_event so the track qualifies as "played" -// for the worker's selection query. -func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) { - t.Helper() - var sessionID pgtype.UUID - if err := f.pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - f.user).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '1 minute', now(), 250000, 0.83, false)`, - f.user, trackID, sessionID); err != nil { - t.Fatalf("play_event: %v", err) - } -} - -func newTestWorker(f fixture, lbBaseURL string) *Worker { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return &Worker{ - pool: f.pool, - client: &listenbrainz.Client{BaseURL: lbBaseURL, HTTP: http.DefaultClient}, - logger: logger, - tick: 1 * time.Hour, - batch: 5, - topK: 20, - } -} - -// stubLB returns an httptest server that responds to similar-recordings and -// similar-artists with the given JSON payloads. Use empty string to simulate -// "endpoint returned []" (LB knows the MBID but has no neighbors). -func stubLB(recordingsBody, artistsBody string, status int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if status != 0 && status != http.StatusOK { - w.WriteHeader(status) - return - } - if strings.Contains(r.URL.Path, "/similar-recordings/") { - _, _ = w.Write([]byte(recordingsBody)) - return - } - if strings.Contains(r.URL.Path, "/similar-artists/") { - _, _ = w.Write([]byte(artistsBody)) - return - } - w.WriteHeader(http.StatusNotFound) - })) -} - -func countTrackSim(t *testing.T, f fixture, a pgtype.UUID) int { - t.Helper() - var n int - if err := f.pool.QueryRow(context.Background(), - `SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, a).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func countArtistSim(t *testing.T, f fixture, a pgtype.UUID) int { - t.Helper() - var n int - if err := f.pool.QueryRow(context.Background(), - `SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1`, a).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func TestTickOnce_NoPlayedTracks_NoOp(t *testing.T) { - f := newFixture(t) - srv := stubLB(`[]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - // no rows inserted anywhere - var n int - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity`).Scan(&n) - if n != 0 { - t.Errorf("track_similarity rows = %d, want 0", n) - } - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity`).Scan(&n) - if n != 0 { - t.Errorf("artist_similarity rows = %d, want 0", n) - } -} - -func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) { - f := newFixture(t) - mbidA := "11111111-1111-1111-1111-111111111111" - mbidB := "22222222-2222-2222-2222-222222222222" - mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library - trackA := seedTrack(t, f, "A", &mbidA) - trackB := seedTrack(t, f, "B", &mbidB) - markPlayed(t, f, trackA.ID) - // LB returns three results when queried for trackA — two in library, one not. - body := `[ - {"recording_mbid": "` + mbidB + `", "score": 0.9}, - {"recording_mbid": "` + mbidC + `", "score": 0.7} - ]` - _ = trackB - srv := stubLB(body, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackA.ID); got != 1 { - t.Errorf("track_similarity rows = %d, want 1 (only mbidB is in-library)", got) - } -} - -func TestTickOnce_TopKEnforced(t *testing.T) { - f := newFixture(t) - mbidSeed := "11111111-1111-1111-1111-111111111111" - seed := seedTrack(t, f, "Seed", &mbidSeed) - markPlayed(t, f, seed.ID) - // Build 25 tracks with MBIDs in library, plus the LB response listing all 25. - type lbRow struct { - MBID string `json:"recording_mbid"` - Score float64 `json:"score"` - } - rows := make([]lbRow, 0, 25) - for i := 0; i < 25; i++ { - mbid := "20000000-0000-0000-0000-" + leftPad(i+1, 12) - _ = seedTrack(t, f, "T"+leftPad(i+1, 2), &mbid) - rows = append(rows, lbRow{MBID: mbid, Score: 1.0 - 0.01*float64(i)}) - } - body, _ := json.Marshal(rows) - srv := stubLB(string(body), `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, seed.ID); got > 20 { - t.Errorf("top-K not enforced: got %d, want ≤ 20", got) - } - if got := countTrackSim(t, f, seed.ID); got != 20 { - t.Errorf("got %d rows, want exactly 20 (all 25 in-library; top 20 by score)", got) - } -} - -func TestTickOnce_RespectsSevenDayCap(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // Manually insert a fresh row so the worker should skip this track. - otherMbid := "55555555-5555-5555-5555-555555555555" - other := seedTrack(t, f, "Other", &otherMbid) - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) - VALUES ($1, $2, 0.5, 'listenbrainz', now())`, trackA.ID, other.ID); err != nil { - t.Fatalf("seed sim: %v", err) - } - // LB stub would return more rows if called; if the worker hits LB the - // test fails (different count after). - beforeCount := countTrackSim(t, f, trackA.ID) - srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.99}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - if got := countTrackSim(t, f, trackA.ID); got != beforeCount { - t.Errorf("worker re-queried fresh track: before=%d after=%d", beforeCount, got) - } -} - -func TestTickOnce_RefreshesStaleRow(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - otherMbid := "55555555-5555-5555-5555-555555555555" - other := seedTrack(t, f, "Other", &otherMbid) - // Insert a stale row from 8 days ago with score 0.5. - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) - VALUES ($1, $2, 0.5, 'listenbrainz', now() - interval '8 days')`, trackA.ID, other.ID); err != nil { - t.Fatalf("seed sim: %v", err) - } - srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.95}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - var score float64 - var fetchedAt time.Time - _ = f.pool.QueryRow(context.Background(), - `SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`, - trackA.ID, other.ID).Scan(&score, &fetchedAt) - if score != 0.95 { - t.Errorf("score = %v, want 0.95 (refreshed)", score) - } - if time.Since(fetchedAt) > time.Minute { - t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt)) - } -} - -func TestTickOnce_429AbortsTick(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - })) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - // no rows updated; fetched_at should not be set anywhere - var n int - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, trackA.ID).Scan(&n) - if n != 0 { - t.Errorf("rows on 429 = %d, want 0 (tick aborted before any inserts)", n) - } -} - -func TestTickOnce_TransientErrorSkipsTrack(t *testing.T) { - f := newFixture(t) - mbidA := "11111111-1111-1111-1111-111111111111" - mbidB := "22222222-2222-2222-2222-222222222222" - otherMbid := "55555555-5555-5555-5555-555555555555" - trackA := seedTrack(t, f, "A", &mbidA) - trackB := seedTrack(t, f, "B", &mbidB) - other := seedTrack(t, f, "Other", &otherMbid) - markPlayed(t, f, trackA.ID) - markPlayed(t, f, trackB.ID) - // Stub: 503 for the first MBID we see, success body for the second. - var seen atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, "/similar-recordings/") { - _, _ = w.Write([]byte(`[]`)) - return - } - n := seen.Add(1) - if n == 1 { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte(`[{"recording_mbid":"` + otherMbid + `","score":0.7}]`)) - })) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - _ = other // referenced via UPSERT - // Exactly one of trackA/trackB should have a row; the other (whichever - // hit 503) should be empty. - a := countTrackSim(t, f, trackA.ID) - b := countTrackSim(t, f, trackB.ID) - if a+b != 1 { - t.Errorf("expected exactly one track to succeed: a=%d b=%d", a, b) - } -} - -func TestTickOnce_FiltersInLibrary(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // LB returns 3 MBIDs none of which are in our library. - body := `[ - {"recording_mbid":"99999999-9999-9999-9999-999999999991","score":0.9}, - {"recording_mbid":"99999999-9999-9999-9999-999999999992","score":0.8}, - {"recording_mbid":"99999999-9999-9999-9999-999999999993","score":0.7} - ]` - srv := stubLB(body, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackA.ID); got != 0 { - t.Errorf("filtered out-of-library: got %d, want 0", got) - } -} - -func TestTickOnce_ArtistPassMirrors(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // Seed a SECOND artist with MBID; LB returns it as similar to f.artist. - bMbid := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - otherArtist, err := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "Other", SortName: "Other", Mbid: &bMbid, - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - body := `[{"artist_mbid":"` + bMbid + `","score":0.85}]` - srv := stubLB(`[]`, body, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - _ = otherArtist - if got := countArtistSim(t, f, f.artist.ID); got != 1 { - t.Errorf("artist_similarity rows = %d, want 1", got) - } -} - -func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) { - f := newFixture(t) - trackNoMbid := seedTrack(t, f, "NoMbid", nil) - markPlayed(t, f, trackNoMbid.ID) - // LB stub would return rows if queried; if worker queries it, the test - // fails (any non-zero rows means we hit LB for a no-MBID track). - srv := stubLB(`[{"recording_mbid":"11111111-1111-1111-1111-111111111111","score":0.9}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackNoMbid.ID); got != 0 { - t.Errorf("no-MBID track produced rows: %d", got) - } -} - -// leftPad pads an integer with leading zeros to width n, used to build -// deterministic MBID-like strings in tests. -func leftPad(v, width int) string { - s := "" - for i := 0; i < width; i++ { - s = "0" + s - } - digits := "" - for v > 0 { - digits = string(rune('0'+v%10)) + digits - v /= 10 - } - if len(digits) >= width { - return digits - } - return s[:width-len(digits)] + digits -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/similarity/ -v` - -Expected: tests run but most fail because `tickOnce` is a stub returning nil. - -- [ ] **Step 3: Implement tickOnce** - -Replace the `tickOnce` stub in `internal/similarity/worker.go` with the full implementation. Add the imports `errors`, `git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq`, `github.com/jackc/pgx/v5/pgtype` to the import block: - -```go -import ( - "context" - "errors" - "log/slog" - "sort" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) -``` - -Replace `tickOnce` with: - -```go -// tickOnce drains one batch of tracks and one batch of artists. Per-row -// errors are logged and skipped (passive retry via timer). 429 aborts the -// entire tick (the next hourly tick is far longer than any LB Retry-After). -// -// Returns the first error that aborts the whole tick (currently only 429); -// nil otherwise. -func (w *Worker) tickOnce(ctx context.Context) error { - q := dbq.New(w.pool) - if err := w.tickTracks(ctx, q); err != nil { - return err - } - return w.tickArtists(ctx, q) -} - -func (w *Worker) tickTracks(ctx context.Context, q *dbq.Queries) error { - rows, err := q.ListPlayedTracksNeedingSimilarity(ctx, w.batch) - if err != nil { - return err - } - for _, r := range rows { - if r.Mbid == nil { - continue // defensive — query already filters NULL - } - results, err := w.client.SimilarRecordings(ctx, *r.Mbid, 100) - if err != nil { - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait) - return nil // abort this tick; passive retry on next - } - w.logger.Warn("similarity: similar-recordings failed", "track_id", r.ID, "err", err) - continue - } - w.upsertTrackSimilar(ctx, q, r.ID, results) - } - return nil -} - -func (w *Worker) tickArtists(ctx context.Context, q *dbq.Queries) error { - rows, err := q.ListPlayedArtistsNeedingSimilarity(ctx, w.batch) - if err != nil { - return err - } - for _, r := range rows { - if r.Mbid == nil { - continue - } - results, err := w.client.SimilarArtists(ctx, *r.Mbid, 100) - if err != nil { - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait) - return nil - } - w.logger.Warn("similarity: similar-artists failed", "artist_id", r.ID, "err", err) - continue - } - w.upsertArtistSimilar(ctx, q, r.ID, results) - } - return nil -} - -// upsertTrackSimilar filters returned MBIDs to those in our library, takes -// top-K by score, and upserts rows. -func (w *Worker) upsertTrackSimilar(ctx context.Context, q *dbq.Queries, trackAID pgtype.UUID, results []listenbrainz.SimilarRecording) { - if len(results) == 0 { - return - } - // Sort by score desc (LB returns sorted; defensive). - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - // Bulk-resolve MBIDs to local track IDs. - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetTracksByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetTracksByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - // Walk results in score order, take first K that map to a local track. - taken := 0 - for _, r := range results { - if taken >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == trackAID { - continue // defensive — DB CHECK constraint also catches self-edges - } - if uerr := q.UpsertTrackSimilarity(ctx, dbq.UpsertTrackSimilarityParams{ - TrackAID: trackAID, TrackBID: localID, Score: r.Score, - }); uerr != nil { - w.logger.Warn("similarity: UpsertTrackSimilarity", "err", uerr) - continue - } - taken++ - } -} - -func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { - if len(results) == 0 { - return - } - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetArtistsByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - taken := 0 - for _, r := range results { - if taken >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == artistAID { - continue - } - if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ - ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) - continue - } - taken++ - } -} -``` - -Note: the exact param-struct field names (`TrackAID`, `ArtistAID`, etc.) are emitted by sqlc — they may use different capitalization. After running `make generate`, inspect `internal/db/dbq/similarity.sql.go` and adjust the field references to match. Same for `r.Mbid` — sqlc may name the column type pointer field `Mbid` or `MBID`. Use whatever the generated code shows. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/similarity/ -v` - -Expected: PASS for all 9 integration tests + 1 constructor test. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/similarity/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/similarity/worker.go internal/similarity/worker_integration_test.go -git commit -m "feat(similarity): implement Worker.tickOnce with track + artist passes" -``` - ---- - -## Task 7: Boot the worker in main.go - -**Files:** -- Modify: `cmd/minstrel/main.go` - -- [ ] **Step 1: Add the import** - -In `cmd/minstrel/main.go`, append to the existing import block: - -```go -"git.fabledsword.com/bvandeusen/minstrel/internal/similarity" -``` - -(The `internal/scrobble/listenbrainz` import already exists from M4a; we re-use the same client.) - -- [ ] **Step 2: Add the worker startup** - -In `cmd/minstrel/main.go`, find where the M4a scrobble worker is started (a `scrobbleWorker := scrobble.NewWorker(...)` followed by `go scrobbleWorker.Run(ctx)`). Append immediately after: - -```go -// Start the similarity ingest worker. Per spec §M4b, runs every 1h, drains -// up to 5 played tracks + 5 played artists per tick, weekly re-fetch cap -// per row. Public LB endpoints — no token required. -similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) -go similarityWorker.Run(ctx) -``` - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 4: Verify the worker actually starts (smoke test)** - -Run a brief end-to-end check (re-uses the M4a smoke pattern): - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - SMARTMUSIC_LIBRARY_SCAN_PATHS='' \ - SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false \ - SMARTMUSIC_SERVER_ADDRESS=:14533 \ - timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20 -``` - -Expected: server starts, no panic. The similarity worker doesn't log per tick (only on errors), so the smoke is just "no crash." - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add cmd/minstrel/main.go -git commit -m "feat(cmd): start similarity worker alongside HTTP server" -``` - ---- - -## Task 8: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 16 packages (now including `internal/similarity`). - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it; not introduced by this PR). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check on new package** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4b.out ./internal/similarity/... ./internal/scrobble/listenbrainz/... && go tool cover -func=/tmp/cover-m4b.out | tail -5` - -Expected: `internal/similarity` ≥ 75%, `internal/scrobble/listenbrainz` ≥ 85%. The new `Similar*` methods are added on top of M4a's coverage. - -- [ ] **Step 5: Web verification (no changes — sanity)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; 180 vitest tests pass; build succeeds. (M4b doesn't touch web.) - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4b-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual verification post-merge (optional, deferred)** - -Note: full validation requires a real LB-tagged library plus 1h+ uptime. The integration tests provide deterministic coverage. Manual verification at the user's discretion: - -1. `docker compose up --build -d minstrel` — pick up new code -2. Wait ≥1 hour -3. `psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"` → non-zero rows IF library has MBID-tagged played tracks - -- [ ] **Step 8: Update Fable task #346** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with a summary of what shipped. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options, and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4b closes; M4c (radio similarity-driven candidate pool + queue refresh, closes M4) is next. - ---- - -## Self-Review - -**Spec coverage:** -- §3.1 (new package `internal/similarity`) → Tasks 5, 6 ✓ -- §3.2 (LB client extensions) → Tasks 3, 4; boot wiring → Task 7 ✓ -- §3.3 (failure handling — passive retry, 429 abort tick) → Task 6 (worker logic + integration tests) ✓ -- §4 (database schema) → Task 1 ✓ -- §5 (sqlc queries) → Task 2 ✓ -- §6 (worker algorithm) → Task 6 ✓ -- §7.1 (LB client unit tests) → Tasks 3, 4 ✓ -- §7.2 (worker integration tests) → Task 6 ✓ -- §7.3 (coverage target) → Task 8 step 4 ✓ -- §7.4 (manual verification) → Task 8 step 7 ✓ -- §8 (backwards compat) → preserved by additive design (new tables, new package) ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 6 is a known sqlc-specific instruction, not a placeholder. - -**Type consistency:** -- `Worker` struct fields (`pool`, `client`, `logger`, `tick`, `batch`, `topK`) — same in Task 5 (skeleton) and Task 6 (full impl). -- `tickOnce(ctx) error` — same signature throughout. -- `SimilarRecording`/`SimilarArtist` types defined in Tasks 3/4, consumed in Task 6. -- sqlc-generated method names referenced consistently: `ListPlayedTracksNeedingSimilarity`, `ListPlayedArtistsNeedingSimilarity`, `GetTracksByMBIDs`, `GetArtistsByMBIDs`, `UpsertTrackSimilarity`, `UpsertArtistSimilarity`. -- Constants `lbSimilarRecordingsAlgorithm` / `lbSimilarArtistsAlgorithm` defined in Task 3, both referenced from Tasks 3 and 4. diff --git a/docs/superpowers/plans/2026-04-28-m4c-radio.md b/docs/superpowers/plans/2026-04-28-m4c-radio.md deleted file mode 100644 index 737f890a..00000000 --- a/docs/superpowers/plans/2026-04-28-m4c-radio.md +++ /dev/null @@ -1,1391 +0,0 @@ -# M4c — Radio Similarity-Driven Candidate Pool + Queue Refresh 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:** Replace M3's whole-library candidate pool with a 5-way SQL UNION of similarity sources (LB-similar tracks, similar-artist tracks, MB-tag overlap, likes-overlap, random fill), add a `SimilarityScore × SimilarityWeight` term to M3's `Score()`, and have the SPA auto-refresh the radio queue at 80% consumed via a new `?exclude=` query param. Closes M4. - -**Architecture:** New sqlc query `LoadRadioCandidatesV2` performs the 5-way UNION + per-source scoring + final dedup-by-max in SQL. New `LoadCandidatesFromSimilarity` Go function projects rows to `[]Candidate` (same return type as M3's `LoadCandidates`, so `Shuffle()` is unchanged). Radio handler uses the new function as primary, falls back to M3's `LoadCandidates` on any error. Web client tracks `radioSeedId` and fires a refresh fetch when 80% consumed; clears on non-radio enqueues. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TanStack Query. No schema migrations — relies on M4b's `track_similarity` / `artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4c-radio-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/queries/radio.sql` (modify) | Append `LoadRadioCandidatesV2 :many` — 5-way UNION + dedup-by-max. | -| `internal/db/dbq/radio.sql.go` | Generated bindings. | - -Note: `internal/db/queries/recommendation.sql` currently holds `LoadRadioCandidates` (M3). I'll **add the V2 query to the same file** to keep radio-related queries co-located. - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/recommendation/score.go` | `ScoringInputs` gains `SimilarityScore float64`, `ScoringWeights` gains `SimilarityWeight float64`. `Score()` adds `+ in.SimilarityScore * w.SimilarityWeight`. | -| `internal/recommendation/score_test.go` | 3 new tests for the new term. | -| `internal/recommendation/candidates.go` | Adds `CandidateSourceLimits` struct, `DefaultCandidateSourceLimits()`, `LoadCandidatesFromSimilarity()`. M3's `LoadCandidates` retained unchanged for fallback + tests. | -| `internal/recommendation/candidates_test.go` (or new `candidates_v2_test.go`) | 10 new integration tests for the similarity-pool path. | -| `internal/config/config.go` | `RecommendationConfig` gains `SimilarityWeight float64` (default `2.0`, yaml `similarity_weight`). | -| `internal/api/radio.go` | Parse `?exclude=`, build limits struct, call `LoadCandidatesFromSimilarity` first then fallback to `LoadCandidates`, thread `SimilarityWeight` into `ScoringWeights`. | -| `internal/api/radio_test.go` | 4 new tests for similarity ranking, exclude param, malformed exclude, fallback path. | -| `internal/api/auth_test.go` (or wherever `recCfg` test fixture lives) | Add `SimilarityWeight: 2.0` to the test recCfg literal. | - -**Frontend files:** - -| File | Responsibility | -|---|---| -| `web/src/lib/player/store.svelte.ts` | Add `radioSeedId` state, `appendRadioTracks()` helper, refresh effect, clear-on-non-radio-enqueue logic. | -| `web/src/lib/player/store.test.ts` | 5 new tests covering refresh behavior. | - ---- - -## Task 1: sqlc query `LoadRadioCandidatesV2` - -**Files:** -- Modify: `internal/db/queries/recommendation.sql` (append) -- Generated: `internal/db/dbq/recommendation.sql.go` - -- [ ] **Step 1: Append the query** - -Append to `internal/db/queries/recommendation.sql`: - -```sql --- name: LoadRadioCandidatesV2 :many --- M4c: similarity-driven candidate pool. 5-way UNION: --- $1 user_id, $2 seed_track_id, $3 recently_played_hours, --- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, --- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. --- Returns same shape as LoadRadioCandidates plus similarity_score column. - -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($4::uuid[]) AS id - UNION ALL - SELECT track_id FROM play_events - WHERE user_id = $1 AND started_at > now() - $3 * 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 $5 -), -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 $6 -), -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 $7 -), -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 $8 -), -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 $9 -) -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; -``` - -- [ ] **Step 2: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: `internal/db/dbq/recommendation.sql.go` regenerated with `LoadRadioCandidatesV2(ctx, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error)`. Inspect to confirm: -- The `LoadRadioCandidatesV2Params` struct has 9 fields. sqlc names them after column hints when possible; otherwise positional like `Column3`, `Column4`. Note actual generated names — Task 3 will use them. -- The `LoadRadioCandidatesV2Row` struct has the embedded `Track` plus `IsLiked bool`, `LastPlayedAt pgtype.Timestamptz`, `PlayCount int64`, `SkipCount int64`, `SimilarityScore float64`. - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/queries/recommendation.sql internal/db/dbq/ -git commit -m "feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION)" -``` - ---- - -## Task 2: `Score()` + `RecommendationConfig` extension - -**Files:** -- Modify: `internal/recommendation/score.go` -- Modify: `internal/recommendation/score_test.go` -- Modify: `internal/config/config.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/score_test.go`: - -```go -func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.SimilarityWeight = 2.0 - in := ScoringInputs{SimilarityScore: 1.0} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0 - want := 4.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.SimilarityWeight = 2.0 - in := ScoringInputs{SimilarityScore: 0.5} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 + similarity 1.0 = 3.0 - want := 3.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) { - wWithSim := defaultWeights() - wWithSim.SimilarityWeight = 2.0 - withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5)) - withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(withSim-withoutSim) > 1e-9 { - t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` - -Expected: FAIL — `ScoringInputs` has no `SimilarityScore`, `ScoringWeights` has no `SimilarityWeight`. - -- [ ] **Step 3: Extend `score.go`** - -Replace `internal/recommendation/score.go`: - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// ContextualMatchScore is in [0, 1] — max similarity between the user's -// current session vector and any non-seed contextual_like row for this -// track. SimilarityScore is in [0, 1] — max similarity-source score for -// this candidate (LB-similar / similar-artist × 0.5 / tag-overlap jaccard -// / likes-overlap constant; random fill = 0). Both set by candidate -// loaders before scoring. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true - ContextualMatchScore float64 // [0, 1]; 0 when no signal - SimilarityScore float64 // [0, 1]; 0 when no signal (M4c) -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 - SimilarityWeight float64 // M4c -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + contextual_match_score * ContextWeight -// + similarity_score * SimilarityWeight -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -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 += in.ContextualMatchScore * w.ContextWeight - s += in.SimilarityScore * w.SimilarityWeight - s += (rng()*2 - 1) * w.JitterMagnitude - return s -} - -// recencyDecay returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Extend `RecommendationConfig`** - -In `internal/config/config.go`, modify `RecommendationConfig`: - -```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"` - SimilarityWeight float64 `yaml:"similarity_weight"` // M4c - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -In the same file, modify `Default()`'s `Recommendation` block to include the new field: - -```go -Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, -}, -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` - -Expected: PASS for all existing Score tests + 3 new similarity tests. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 6: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/recommendation/score.go internal/recommendation/score_test.go internal/config/config.go -git commit -m "feat(recommendation): extend Score with SimilarityScore + SimilarityWeight" -``` - ---- - -## Task 3: `LoadCandidatesFromSimilarity` Go function + integration tests - -**Files:** -- Modify: `internal/recommendation/candidates.go` (append) -- Create: `internal/recommendation/candidates_v2_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/recommendation/candidates_v2_test.go`: - -```go -package recommendation - -import ( - "context" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// helperLBSimilarity inserts a track_similarity row. -func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { - t.Helper() - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, - a, b, score); err != nil { - t.Fatalf("insert track_similarity: %v", err) - } -} - -// helperArtistSimilarity inserts an artist_similarity row. -func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { - t.Helper() - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, - a, b, score); err != nil { - t.Fatalf("insert artist_similarity: %v", err) - } -} - -// helperSeedTrackWithGenre creates a track with a specified genre + mbid. -func helperSeedTrackWithGenre(t *testing.T, f fixture, title, genre, mbid string) dbq.Track { - t.Helper() - tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: f.tracks[0].AlbumID, - ArtistID: f.tracks[0].ArtistID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: 180_000, - Genre: &genre, - Mbid: &mbid, - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return tr -} - -func defaultLimits() CandidateSourceLimits { - return DefaultCandidateSourceLimits() -} - -func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - target := f.tracks[1] - helperLBSimilarity(t, f, seed.ID, target.ID, 0.85) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - var found *Candidate - for i := range got { - if got[i].Track.ID == target.ID { - found = &got[i] - break - } - } - if found == nil { - t.Fatal("LB-similar target missing from candidates") - } - if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { - t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) - } -} - -func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) { - f := newFixture(t, 1) // seed only - seed := f.tracks[0] - // Add a second artist + track in that artist; relate the artists via artist_similarity. - otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"}) - otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID}) - otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID, - FilePath: "/tmp/other.flac", DurationMs: 180_000, - }) - helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == otherTrack.ID { - // 0.8 × 0.5 = 0.4 - if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 { - t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("similar-artist track missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock; Pop", "11111111-1111-1111-1111-111111111111") - target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == target.ID { - // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. - if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { - t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("tag-overlap target missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - liked := helperSeedTrackWithGenre(t, f, "Liked", "Rock", "22222222-2222-2222-2222-222222222222") - if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil { - t.Fatalf("like: %v", err) - } - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == liked.ID { - // Could come from tag-overlap (1.0 jaccard) OR likes-overlap (0.6) — max wins. - // Since both tracks are tagged "Rock" identically, jaccard = 1/1 = 1.0, which beats 0.6. - if c.Inputs.SimilarityScore < 0.59 { - t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("liked track with shared tag missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) { - f := newFixture(t, 10) // 10 tracks; no similarity data - seed := f.tracks[0] - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - // Random fill should provide at least some of the 9 non-seed tracks. - if len(got) == 0 { - t.Error("random fill returned 0 candidates; expected at least some") - } - // All should have SimilarityScore = 0 (no similarity sources). - for _, c := range got { - if c.Inputs.SimilarityScore != 0 { - t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore) - } - } -} - -func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - excluded := f.tracks[1].ID - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, - []pgtype.UUID{excluded}, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == excluded { - t.Error("excluded track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == seed.ID { - t.Error("seed track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - recent := f.tracks[1].ID - // Insert a play_event within the last hour. - var sessionID pgtype.UUID - if err := f.pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - f.user).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`, - f.user, recent, sessionID); err != nil { - t.Fatalf("play_event: %v", err) - } - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == recent { - t.Error("recently-played track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - // Target shares the LB-similar source (high score) AND tag-overlap (low score). - target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") - helperLBSimilarity(t, f, seed.ID, target.ID, 0.95) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - count := 0 - for _, c := range got { - if c.Track.ID == target.ID { - count++ - // LB 0.95 should win over tag-overlap (jaccard 1/1 = 1.0). Wait — 1.0 > 0.95. Adjust: - // seed and target both have only "Rock" tag → jaccard = 1.0 from tag-overlap. - // LB sim = 0.95. Max = 1.0. So this test verifies max() picks tag-overlap here. - if c.Inputs.SimilarityScore < 0.99 { - t.Errorf("dedup max SimilarityScore = %v, want 1.0 (tag-overlap wins)", c.Inputs.SimilarityScore) - } - } - } - if count != 1 { - t.Errorf("target appeared %d times, want 1 (dedup failed)", count) - } -} - -func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { - f := newFixture(t, 0) - // Seed an isolated track to query against. - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - if len(got) != 0 { - t.Errorf("got %d candidates from empty library, want 0", len(got)) - } - _ = time.Now() // silence unused-import warning if loop never runs -} -``` - -The `fixture` and `newFixture` helpers come from `candidates_test.go` (existing M3 fixture). If `newFixture(t, 0)` doesn't exist (i.e. it requires ≥1 track), peek at the existing helper's signature and adjust the test to use the smallest legal value. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` - -Expected: FAIL — `undefined: LoadCandidatesFromSimilarity`, `undefined: CandidateSourceLimits`, `undefined: DefaultCandidateSourceLimits`. - -- [ ] **Step 3: Implement `LoadCandidatesFromSimilarity`** - -Append to `internal/recommendation/candidates.go`: - -```go -// CandidateSourceLimits controls per-source K values for the M4c -// similarity-driven pool. Defaults via DefaultCandidateSourceLimits(). -type CandidateSourceLimits struct { - LBSimilar int - SimilarArtist int - TagOverlap int - LikesOverlap int - RandomFill int -} - -// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. -func DefaultCandidateSourceLimits() CandidateSourceLimits { - return CandidateSourceLimits{ - LBSimilar: 30, - SimilarArtist: 30, - TagOverlap: 20, - LikesOverlap: 20, - RandomFill: 30, - } -} - -// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. -// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / -// likes-overlap / random fill) + dedup-by-max sim_score. Returns -// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged. -// -// Caller (radio handler) 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) { - if exclude == nil { - exclude = []pgtype.UUID{} - } - rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - Column4: exclude, - Column5: int32(limits.LBSimilar), - Column6: int32(limits.SimilarArtist), - Column7: int32(limits.TagOverlap), - Column8: int32(limits.LikesOverlap), - Column9: int32(limits.RandomFill), - }) - if err != nil { - return nil, err - } - - likes, err := loadContextualLikesByTrack(ctx, q, userID) - if err != nil { - return nil, err - } - - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - ContextualMatchScore: ctxScore, - SimilarityScore: r.SimilarityScore, - }, - }) - } - return out, nil -} -``` - -**Note on sqlc-generated param field names:** the `LoadRadioCandidatesV2Params` field names depend on sqlc's auto-generated names from the SQL parameter positions. After Task 1's `make generate`, inspect `internal/db/dbq/recommendation.sql.go` and adjust the field references (`UserID`, `ID`, `Column3`, etc.) to match what sqlc actually emitted. The actual names may be `Column3` for `$3` (recently_played_hours), `Column4` for `$4` (exclude), etc., or sqlc may pick smarter names from context. Rename to match. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` - -Expected: PASS for all 10 tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/recommendation/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/recommendation/candidates.go internal/recommendation/candidates_v2_test.go -git commit -m "feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)" -``` - ---- - -## Task 4: Radio handler — exclude param + similarity pool + fallback - -**Files:** -- Modify: `internal/api/radio.go` -- Modify: `internal/api/radio_test.go` -- Modify: `internal/api/auth_test.go` (testHandlers' `recCfg` literal) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/api/radio_test.go`: - -```go -func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - // Two non-seed tracks: one LB-similar (high score), one not. - target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000) - control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000) - if _, err := pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`, - seed.ID, target.ID); err != nil { - t.Fatalf("insert sim: %v", err) - } - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - // resp.Tracks[0] is seed; check whether target ranks above control. - var targetIdx, controlIdx int - targetIdx, controlIdx = -1, -1 - for i, tr := range resp.Tracks { - if tr.ID == uuidToString(target.ID) { - targetIdx = i - } - if tr.ID == uuidToString(control.ID) { - controlIdx = i - } - } - if targetIdx < 0 || controlIdx < 0 { - t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx) - } - if targetIdx >= controlIdx { - t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx) - } -} - -func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000) - for i := 3; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID) - w := callRadio(h, user, q) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - for _, tr := range resp.Tracks { - if tr.ID == uuidToString(excluded.ID) { - t.Error("excluded track present in response") - } - } -} - -func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID) - w := callRadio(h, user, q) - if w.Code != http.StatusOK { - t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - for _, tr := range resp.Tracks { - if tr.ID == uuidToString(other.ID) { - t.Error("other track should still be excluded after dropping malformed entry") - } - } -} - -func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) { - // Defensive: even when the similarity pool returns no candidates, - // the seed track must still be the first track in the response. - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) { - t.Errorf("seed not at index 0: %v", resp.Tracks) - } -} -``` - -- [ ] **Step 2: Update `auth_test.go::testHandlers` recCfg literal** - -In `internal/api/auth_test.go`, find the `recCfg := config.RecommendationConfig{...}` literal in `testHandlers` and add `SimilarityWeight: 2.0`: - -```go -recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, - ContextWeight: 2.0, SimilarityWeight: 2.0, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, -} -``` - -- [ ] **Step 3: Update the radio handler** - -In `internal/api/radio.go`, replace the `LoadCandidates` call site. Before it, parse the exclude param + build the limits struct + add the seed to the exclude list. Keep the M3 `LoadCandidates` call as a fallback path. - -Replace the section from `currentVec := loadCurrentSessionVector(...)` through `weights := recommendation.ScoringWeights{...}`: - -```go - currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - - exclude := parseExcludeParam(r.URL.Query().Get("exclude")) - 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 to whole-library", "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 fallback failed", "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, - } -``` - -Append the helper at the bottom of the same file: - -```go -// parseExcludeParam parses a comma-separated list of UUIDs from the -// `exclude` query string, silently dropping malformed entries. Returns -// nil for empty or all-malformed input. -func parseExcludeParam(raw string) []pgtype.UUID { - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]pgtype.UUID, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - id, ok := parseUUID(p) - if !ok { - continue - } - out = append(out, id) - } - return out -} -``` - -`parseUUID` already exists in `internal/api/`. Confirm that `strings` is in the import block (it should already be). - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -v 2>&1 | tail -30` - -Expected: PASS for all existing API tests + 4 new radio tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/api/radio.go internal/api/radio_test.go internal/api/auth_test.go -git commit -m "feat(api): radio uses similarity pool with exclude param + M3 fallback" -``` - ---- - -## Task 5: Frontend — radio queue refresh at 80% - -**Files:** -- Modify: `web/src/lib/player/store.svelte.ts` -- Modify: `web/src/lib/player/store.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `web/src/lib/player/store.test.ts`: - -```ts -describe('radio refresh at 80%', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('refresh fires at 80% queue consumption', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - // Now queue is the 5 tracks. Reset mock for refresh call. - (apiGet as ReturnType).mockClear(); - (apiGet as ReturnType).mockResolvedValueOnce({ - tracks: [tracks[0], { ...tracks[0], id: 'new1', title: 'New1' }] - }); - // Advance to index 4 (5th of 5 = 100%, but cross 80% at index 3) - // Skip to index 3 (4th track played; 4/5 = 80%) - skipNext(); skipNext(); skipNext(); - // Allow $effect to fire - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).toHaveBeenCalled(); - const callArg = (apiGet as ReturnType).mock.calls[0][0]; - expect(callArg).toContain('seed_track=seed1'); - expect(callArg).toContain('exclude='); - }); - - test('refresh below 80% does not fire', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - (apiGet as ReturnType).mockClear(); - skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).not.toHaveBeenCalled(); - }); - - test('refresh appends new tracks excluding seed at index 0', async () => { - const initial: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks: initial }); - await playRadio('seed1'); - // Refresh response: 5 tracks where the first is the seed (will be stripped). - const refreshTracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: i === 0 ? initial[0].id : `n${i}`, - title: i === 0 ? initial[0].title : `N${i}`, - album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', - track_number: i + 1, disc_number: 1, duration_sec: 100, - stream_url: '/api/tracks/x/stream' - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks: refreshTracks }); - skipNext(); skipNext(); skipNext(); // index = 3 (80%) - await new Promise((r) => setTimeout(r, 10)); - // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 - expect(player.queue.length).toBe(9); - }); - - test('refresh does NOT double-fire while in-flight', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - (apiGet as ReturnType).mockClear(); - // Hang the refresh call so the in-flight flag stays set. - let resolveFn!: (v: unknown) => void; - const hanging = new Promise((r) => { resolveFn = r; }); - (apiGet as ReturnType).mockReturnValueOnce(hanging); - skipNext(); skipNext(); skipNext(); // 80% — fires refresh - await new Promise((r) => setTimeout(r, 10)); - // Now advance further while in-flight; should NOT fire a second call. - skipNext(); - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).toHaveBeenCalledTimes(1); - resolveFn({ tracks: [] }); - }); - - test('refresh resets when user enqueues from non-radio source', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - enqueueTrack({ - id: 'manual1', title: 'Manual', album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: 99, disc_number: 1, - duration_sec: 100, stream_url: '/api/tracks/manual1/stream' - }); - (apiGet as ReturnType).mockClear(); - skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the now-6-track queue - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).not.toHaveBeenCalled(); - }); -}); -``` - -The existing test setup may already mock `$lib/api/client` (where `apiGet` lives) — match the existing pattern in the file. If `apiGet` isn't currently mocked, add a mock at the top of `store.test.ts`: - -```ts -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn(), put: vi.fn(), post: vi.fn(), del: vi.fn() } -})); -import { api } from '$lib/api/client'; -const apiGet = api.get; -``` - -Match the existing import-style in this test file; if it already imports from `$lib/api/client`, reuse that. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` - -Expected: most/all of the new tests fail because the refresh logic doesn't exist yet. - -- [ ] **Step 3: Modify the player store** - -In `web/src/lib/player/store.svelte.ts`, find the existing `playRadio` function and: - -1. Add a module-level `radioSeedId` state variable -2. Add a `radioRefreshInFlight` flag -3. Modify `playRadio` to set `radioSeedId` -4. Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `radioSeedId` (these are explicit non-radio enqueues) -5. Add an `$effect` watching consumption ratio; trigger refresh when conditions met - -Replace the existing `playRadio` and the surrounding state declarations: - -```ts -let _queue = $state([]); -let _index = $state(0); -let _state = $state('idle'); -let _position = $state(0); -let _duration = $state(0); -let _volume = $state(readStoredVolume()); -let _shuffle = $state(false); -let _repeat = $state('off'); -let _error = $state(null); - -// M4c: track when the queue was seeded by a radio call so we can -// auto-refresh at 80% consumption. Cleared when the user enqueues -// from a non-radio source. -let _radioSeedId = $state(null); -let _radioRefreshInFlight = false; -``` - -Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `_radioSeedId`. Existing code: - -```ts -export function playQueue(tracks: TrackRef[], startIndex = 0): void { - _queue = tracks; - // ... existing logic ... -} -``` - -Add `_radioSeedId = null;` as the first line in `playQueue`, `enqueueTrack`, and `enqueueTracks`. - -Replace `playRadio`: - -```ts -export async function playRadio(seedTrackId: string): Promise { - const resp = await api.get( - `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` - ); - if (resp.tracks.length === 0) return; - // playQueue clears _radioSeedId; set it back AFTER playQueue finishes. - playQueue(resp.tracks, 0); - _radioSeedId = seedTrackId; -} -``` - -Add the auto-refresh effect. Place after the state declarations (effects are top-level in `.svelte.ts` files): - -```ts -$effect(() => { - if (_radioSeedId === null) return; - if (_radioRefreshInFlight) return; - if (_queue.length === 0) return; - const consumedRatio = (_index + 1) / _queue.length; - if (consumedRatio < 0.8) return; - - _radioRefreshInFlight = true; - const seed = _radioSeedId; - const exclude = _queue.map((t) => t.id).join(','); - api - .get( - `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}` - ) - .then((resp) => { - // Strip the seed at index 0 (already in our queue) and append the rest. - const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); - if (newTracks.length > 0) { - // Append without clearing radio state — this IS a radio refresh. - _queue = [..._queue, ...newTracks]; - } - }) - .catch(() => { - // Swallow; next track-advance can retry. - }) - .finally(() => { - _radioRefreshInFlight = false; - }); -}); -``` - -Note: the existing code uses `_queue = [..._queue, ...newTracks]` style (immutable append) consistent with `enqueueTrack`/`enqueueTracks`. We don't call `enqueueTracks` here because it would clear `_radioSeedId`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` - -Expected: PASS for all existing tests + 5 new refresh tests. - -- [ ] **Step 5: Verify svelte-check + build** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3` - -Expected: 0 errors, 0 warnings. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` - -Expected: adapter-static emits `web/build/`. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add web/ -git commit -m "feat(web): radio queue auto-refreshes at 80% via ?exclude= param" -``` - ---- - -## Task 6: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 16 packages. - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4c.out ./internal/recommendation/... ./internal/api/ && go tool cover -func=/tmp/cover-m4c.out | tail -5` - -Expected: `internal/recommendation` ≥ 80% (was 73% pre-M4c); `internal/api/radio.go` should be well-covered by the existing radio tests + 4 new ones. - -- [ ] **Step 5: Web verification** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; vitest passes (180 + 5 = 185); build succeeds. - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4c-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual end-to-end gate (closes M4)** - -Set up: -1. `docker compose up --build -d minstrel` — pick up new code -2. Wait for M4b's similarity worker to fill `track_similarity` for ≥10 played tracks (≥1 hour of uptime + previously-played tracks, OR seed manually for testing) -3. In SPA: click radio from a played track. Queue should differ noticeably from M3's output. -4. Listen through ~80% of the queue. Observe queue length increases as auto-refresh fires (browser DevTools network tab shows the `/api/radio?seed_track=…&exclude=…` request). -5. `psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"` shows non-trivial similarity data. -6. Subjectively: radio quality should feel meaningfully better than M3's baseline. - -This is the **closing gate for M4**. - -- [ ] **Step 8: Update Fable task #347** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with the closing summary in the body. Note that #347 closes the M4 milestone. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options, and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4 milestone is fully closed; M5 (Lidarr quarantine + suggested-additions for tracks not in library) becomes the natural next milestone. - ---- - -## Self-Review - -**Spec coverage:** -- §3 architecture overview → Tasks 1, 3, 4, 5 ✓ -- §4 candidate pool SQL → Task 1 ✓ -- §5 Score() formula extension → Task 2 ✓ -- §6 Go-side wiring → Tasks 3, 4 ✓ -- §7 frontend queue refresh → Task 5 ✓ -- §8 test plan → embedded across Tasks 2-5; verified in Task 6 ✓ -- §9 decisions ledger → all 7 baked into the plan defaults ✓ -- §10 backwards compatibility → preserved by zero-default new fields + LoadCandidates retained ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 3 is a known sqlc-specific instruction, not a placeholder. - -**Type consistency:** -- `ScoringInputs.SimilarityScore` and `ScoringWeights.SimilarityWeight` defined in Task 2; consumed in Tasks 3 and 4. -- `CandidateSourceLimits` defined in Task 3; consumed in Task 4. -- `LoadCandidatesFromSimilarity` signature matches across Tasks 3 and 4. -- `parseExcludeParam` signature consistent with `[]pgtype.UUID` return type. -- Frontend: `_radioSeedId`, `_radioRefreshInFlight` declared at the top of Task 5 step 3; effect references them; `playRadio`/`playQueue`/etc. mutate them consistently. -- sqlc-generated `LoadRadioCandidatesV2Params` field names: deferred to implementation time (sqlc's auto-naming is positional unless column hints help; the Task 3 code uses `Column3..Column9` placeholders the implementer adjusts after `make generate`). diff --git a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md deleted file mode 100644 index f14e7cf0..00000000 --- a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md +++ /dev/null @@ -1,1979 +0,0 @@ -# M5a — Lidarr connection + search/add + admin shell — 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:** Wire Minstrel to a household Lidarr instance — search at `/discover`, request from any user, admin moderation queue at `/admin/requests`, automatic reconciliation when downloaded tracks land in the library. - -**Architecture:** New `internal/lidarr` HTTP client (typed, mirrors `internal/scrobble/listenbrainz`); `internal/lidarrconfig` singleton service backed by a CHECK-constrained DB row; `internal/lidarrrequests` with synchronous `Service` (Approve calls Lidarr, fires scan) plus async `Reconciler` worker (5-min tick, joins approved requests against new tracks by MBID); new `RequireAdmin` middleware on a dedicated `/api/admin/*` route group; SPA gets `/discover`, `/requests`, `/admin/integrations`, `/admin/requests` with hard route-level role gate, all surfaces drawn against the FabledSword design system. - -**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (Obsidian/Iron surfaces, Moss/Bronze/Oxblood actions, forest-teal #4A6B5C accent, Fraunces ≥18px / Inter / JetBrains Mono). - -**Spec:** [`docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md`](../specs/2026-04-29-m5a-lidarr-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (token palette + voice rules), `project_product_not_project.md` (no YAML for feature config), `project_ui_quality.md` (no scaffolding-feel), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0010_lidarr.up.sql` · `0010_lidarr.down.sql` — schema -- `internal/db/queries/lidarr_config.sql` — sqlc queries for the singleton -- `internal/db/queries/lidarr_requests.sql` — sqlc queries for requests -- `internal/lidarr/client.go` — Lidarr HTTP client (`Client` struct + methods) -- `internal/lidarr/types.go` — typed request/response structs -- `internal/lidarr/errors.go` — typed sentinel errors -- `internal/lidarr/client_test.go` — unit tests with `httptest` stubs + JSON fixtures -- `internal/lidarr/testdata/*.json` — captured Lidarr responses -- `internal/lidarrconfig/service.go` — singleton config wrapper -- `internal/lidarrconfig/service_test.go` — integration test against `MINSTREL_TEST_DATABASE_URL` -- `internal/lidarrrequests/service.go` — request lifecycle service -- `internal/lidarrrequests/service_test.go` — integration test -- `internal/lidarrrequests/reconciler.go` — background worker -- `internal/lidarrrequests/reconciler_integration_test.go` — integration test -- `internal/auth/admin.go` — `RequireAdmin` middleware -- `internal/auth/admin_test.go` — middleware tests -- `internal/api/lidarr.go` — `GET /api/lidarr/search` proxy -- `internal/api/lidarr_test.go` -- `internal/api/requests.go` — `/api/requests` user-facing CRUD -- `internal/api/requests_test.go` -- `internal/api/admin_lidarr.go` — `/api/admin/lidarr/*` (config CRUD + test + profiles + folders) -- `internal/api/admin_lidarr_test.go` -- `internal/api/admin_requests.go` — `/api/admin/requests/*` approval queue -- `internal/api/admin_requests_test.go` - -### Backend — modify - -- `internal/api/api.go` — register routes, mount `/api/admin` group -- `internal/api/auth_test.go` — extend `testHandlers` to inject Lidarr client + lidarrrequests service -- `cmd/minstrel/main.go` — wire `lidarrrequests.Reconciler` worker -- `internal/db/dbq/*` — regenerated by `sqlc generate` - -### Frontend — create - -- `web/src/lib/styles/fabledsword-tokens.css` — `:root` CSS custom properties for all FS tokens -- `web/tailwind.config.js` — extend theme to alias semantic Tailwind utilities to FS tokens (modify, not create — but this slice may need to drop the existing alias defaults) -- `web/src/lib/api/lidarr.ts` — search client -- `web/src/lib/api/requests.ts` — request CRUD client -- `web/src/lib/api/admin.ts` — admin endpoints client -- `web/src/lib/components/DiscoverResultCard.svelte` — card with reserved badge slot + anchored button -- `web/src/lib/components/DiscoverResultCard.test.ts` -- `web/src/lib/components/StatusPill.svelte` — semantic status pill (Pending/Approved/Completed/Rejected) -- `web/src/lib/components/StatusPill.test.ts` -- `web/src/lib/components/AdminSidebar.svelte` — admin nav rail -- `web/src/routes/discover/+page.svelte` -- `web/src/routes/discover/discover.test.ts` -- `web/src/routes/requests/+page.svelte` -- `web/src/routes/requests/requests.test.ts` -- `web/src/routes/admin/+layout.svelte` — admin shell + sidebar -- `web/src/routes/admin/+layout.ts` — role gate (load function) -- `web/src/routes/admin/+page.svelte` — overview landing -- `web/src/routes/admin/integrations/+page.svelte` -- `web/src/routes/admin/integrations/integrations.test.ts` -- `web/src/routes/admin/requests/+page.svelte` -- `web/src/routes/admin/requests/requests.test.ts` - -### Frontend — modify - -- `web/src/lib/components/Shell.svelte` — add `/discover` to nav, conditional `/admin` link for admins -- `web/src/app.css` (or equivalent) — import the tokens file -- `web/src/app.html` — `` Google Fonts (Fraunces / Inter / JetBrains Mono) - ---- - -## Task list - -### Task 1 — Migration 0010 + sqlc queries - -**Files:** -- Create: `internal/db/migrations/0010_lidarr.up.sql` -- Create: `internal/db/migrations/0010_lidarr.down.sql` -- Create: `internal/db/queries/lidarr_config.sql` -- Create: `internal/db/queries/lidarr_requests.sql` -- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0010_lidarr.up.sql`: - -```sql --- M5a: Lidarr integration foundation. Two tables: --- --- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr --- connection. enabled=false is the unconfigured state. --- --- lidarr_requests — per-request lifecycle row created by users at --- /discover, transitioned by admin at /admin/requests, and matched --- back to library tracks by the reconciler worker. Three matched_*_id --- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE --- SET NULL preserves audit even if the matched track is later removed. - -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); - -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; -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0010_lidarr.down.sql`: - -```sql -DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx; -DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx; -DROP INDEX IF EXISTS lidarr_requests_status_idx; -DROP INDEX IF EXISTS lidarr_requests_user_id_idx; -DROP TABLE IF EXISTS lidarr_requests; -DROP TYPE IF EXISTS lidarr_request_kind; -DROP TYPE IF EXISTS lidarr_request_status; -DROP TABLE IF EXISTS lidarr_config; -``` - -- [ ] **Step 1.3: Apply migration locally to confirm it runs** - -```bash -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_requests; DROP TYPE IF EXISTS lidarr_request_kind; DROP TYPE IF EXISTS lidarr_request_status; DROP TABLE IF EXISTS lidarr_config;" -go run ./cmd/minstrel/migrate.go 2>/dev/null || go run ./cmd/minstrel up -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_requests" -``` - -Expected: `\d lidarr_requests` shows the table with all columns and the four indexes. - -If your project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. - -- [ ] **Step 1.4: Write `lidarr_config.sql` queries** - -`internal/db/queries/lidarr_config.sql`: - -```sql --- name: GetLidarrConfig :one -SELECT id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at -FROM lidarr_config -WHERE id = 1; - --- name: UpdateLidarrConfig :one -UPDATE lidarr_config - SET enabled = $1, - base_url = $2, - api_key = $3, - default_quality_profile_id = $4, - default_root_folder_path = $5, - updated_at = now() - WHERE id = 1 - RETURNING id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at; -``` - -- [ ] **Step 1.5: Write `lidarr_requests.sql` queries** - -`internal/db/queries/lidarr_requests.sql`: - -```sql --- name: CreateLidarrRequest :one -INSERT INTO lidarr_requests ( - user_id, kind, - lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, - artist_name, album_title, track_title -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: GetLidarrRequestByID :one -SELECT * FROM lidarr_requests WHERE id = $1; - --- name: ListLidarrRequestsForUser :many -SELECT * FROM lidarr_requests -WHERE user_id = $1 -ORDER BY requested_at DESC -LIMIT $2; - --- name: ListLidarrRequestsByStatus :many -SELECT * FROM lidarr_requests -WHERE status = $1 -ORDER BY requested_at DESC -LIMIT $2; - --- name: ListApprovedLidarrRequestsForReconcile :many -SELECT * FROM lidarr_requests -WHERE status = 'approved' -ORDER BY decided_at ASC -LIMIT $1; - --- name: ApproveLidarrRequest :one -UPDATE lidarr_requests - SET status = 'approved', - quality_profile_id = $2, - root_folder_path = $3, - decided_at = now(), - decided_by = $4, - updated_at = now() - WHERE id = $1 AND status = 'pending' - RETURNING *; - --- name: RejectLidarrRequest :one -UPDATE lidarr_requests - SET status = 'rejected', - notes = $2, - decided_at = now(), - decided_by = $3, - updated_at = now() - WHERE id = $1 AND status = 'pending' - RETURNING *; - --- name: CancelLidarrRequest :one -UPDATE lidarr_requests - SET status = 'rejected', - notes = 'cancelled by user', - decided_at = now(), - decided_by = $2, - updated_at = now() - WHERE id = $1 AND user_id = $2 AND status = 'pending' - RETURNING *; - --- name: CompleteLidarrRequest :one --- Reconciler transitions an approved request to completed when its --- target track/album/artist has appeared in the library. -UPDATE lidarr_requests - SET status = 'completed', - matched_track_id = $2, - matched_album_id = $3, - matched_artist_id = $4, - completed_at = now(), - updated_at = now() - WHERE id = $1 AND status = 'approved' - RETURNING *; - --- name: HasNonTerminalRequestForMBID :one --- Returns true if any user has a pending/approved/completed request --- whose MBID matches at the given level. Used to set the `requested` --- flag on /api/lidarr/search responses. Terminal-status (rejected, --- failed) rows do not count. -SELECT EXISTS ( - SELECT 1 FROM lidarr_requests - WHERE status IN ('pending', 'approved', 'completed') - AND ((kind = 'artist' AND lidarr_artist_mbid = $1) - OR (kind = 'album' AND lidarr_album_mbid = $1) - OR (kind = 'track' AND lidarr_track_mbid = $1)) -); -``` - -- [ ] **Step 1.6: Run sqlc generate** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: `internal/db/dbq/lidarr_config.sql.go` and `internal/db/dbq/lidarr_requests.sql.go` are created. Build succeeds. - -- [ ] **Step 1.7: Commit** - -```bash -git add internal/db/migrations/0010_lidarr.up.sql \ - internal/db/migrations/0010_lidarr.down.sql \ - internal/db/queries/lidarr_config.sql \ - internal/db/queries/lidarr_requests.sql \ - internal/db/dbq/ -git commit -m "feat(db): add lidarr_config + lidarr_requests schema (migration 0010)" -``` - ---- - -### Task 2 — Lidarr HTTP client - -**Files:** -- Create: `internal/lidarr/types.go` -- Create: `internal/lidarr/errors.go` -- Create: `internal/lidarr/client.go` -- Create: `internal/lidarr/client_test.go` -- Create: `internal/lidarr/testdata/lookup_artist.json` -- Create: `internal/lidarr/testdata/quality_profiles.json` -- Create: `internal/lidarr/testdata/root_folders.json` - -- [ ] **Step 2.1: Write the typed structs** - -`internal/lidarr/types.go`: - -```go -// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the -// only place in the codebase that knows about Lidarr's wire format. -// Callers receive value structs, never raw JSON. -package lidarr - -// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. -// It is what we store on the request row (via the user's request) and -// what /api/lidarr/search returns to the SPA. -type LookupResult struct { - MBID string // foreignArtistId / foreignAlbumId / foreignTrackId - Name string // artist name; album/track returns Title here too - Secondary string // genre + album count for artist; year for album; album for track - ImageURL string // cover-art URL Lidarr surfaced (may be empty) -} - -// QualityProfile is the dropdown choice in /admin/integrations. -type QualityProfile struct { - ID int - Name string -} - -// RootFolder is the dropdown choice in /admin/integrations. -type RootFolder struct { - Path string - Accessible bool - FreeSpace int64 -} - -// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist. -type AddArtistParams struct { - ForeignArtistID string - QualityProfileID int - RootFolderPath string - MonitorAll bool // true => Monitored="all"; false => "future" -} - -// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album. -type AddAlbumParams struct { - ForeignAlbumID string - ForeignArtistID string // Lidarr requires the artist's foreign id too - QualityProfileID int - RootFolderPath string -} - -// PingResult is the response shape from GET /api/v1/system/status. -type PingResult struct { - Version string -} -``` - -- [ ] **Step 2.2: Write the typed errors** - -`internal/lidarr/errors.go`: - -```go -package lidarr - -import "errors" - -// Sentinel errors. Callers branch on these via errors.Is, not on -// HTTP status codes — the client maps codes to errors. -var ( - ErrUnreachable = errors.New("lidarr: unreachable") - ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403 - ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 - ErrServerError = errors.New("lidarr: server error") // 5xx - ErrInvalidPayload = errors.New("lidarr: invalid payload") -) -``` - -- [ ] **Step 2.3: Write the client skeleton + LookupArtist** - -`internal/lidarr/client.go`: - -```go -package lidarr - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" -) - -// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance -// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings. -type Client struct { - BaseURL string - APIKey string - HTTP *http.Client -} - -func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = u.Path + path - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - return nil, ErrServerError - } - if resp.StatusCode >= 400 { - _ = resp.Body.Close() - return nil, ErrLookupFailed - } - return resp, nil -} - -// LookupArtist hits Lidarr GET /api/v1/artist/lookup?term=. Returns -// normalized LookupResults from the response. -func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignArtistID string `json:"foreignArtistId"` - ArtistName string `json:"artistName"` - Genres []string `json:"genres"` - AlbumCount int `json:"albumCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := "" - if len(r.Genres) > 0 { - secondary = r.Genres[0] - } - if r.AlbumCount > 0 { - if secondary != "" { - secondary += " · " - } - secondary += strconv.Itoa(r.AlbumCount) + " albums" - } - out = append(out, LookupResult{ - MBID: r.ForeignArtistID, - Name: r.ArtistName, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -func pickPosterImage(imgs []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` -}) string { - for _, img := range imgs { - if img.CoverType == "poster" { - if img.RemoteURL != "" { - return img.RemoteURL - } - return img.URL - } - } - return "" -} - -// Ensure interface implementations stay consistent. -var _ = errors.Is -``` - -- [ ] **Step 2.4: Add a captured Lidarr lookup response to testdata** - -`internal/lidarr/testdata/lookup_artist.json`: - -```json -[ - { - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada", - "genres": ["Electronic", "IDM"], - "albumCount": 18, - "images": [ - {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg"}, - {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg"} - ] - }, - { - "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c", - "artistName": "Bored of Education", - "genres": [], - "albumCount": 0, - "images": [] - } -] -``` - -- [ ] **Step 2.5: Write the LookupArtist test** - -`internal/lidarr/client_test.go`: - -```go -package lidarr - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestLookupArtist_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/lookup_artist.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "key123" { - t.Errorf("api key = %q, want key123", got) - } - if r.URL.Path != "/api/v1/artist/lookup" { - t.Errorf("path = %q", r.URL.Path) - } - if r.URL.Query().Get("term") != "boards" { - t.Errorf("term = %q", r.URL.Query().Get("term")) - } - _, _ = w.Write(body) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()} - got, err := c.LookupArtist(context.Background(), "boards") - if err != nil { - t.Fatalf("LookupArtist: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].Name != "Boards of Canada" { - t.Errorf("name = %q", got[0].Name) - } - if got[0].Secondary != "Electronic · 18 albums" { - t.Errorf("secondary = %q", got[0].Secondary) - } - if got[0].ImageURL != "https://example.invalid/boc.jpg" { - t.Errorf("image = %q", got[0].ImageURL) - } - if got[1].Secondary != "" { - t.Errorf("expected empty secondary for empty genres + 0 albums; got %q", got[1].Secondary) - } -} - -func TestLookupArtist_AuthFailed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupArtist_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError", err) - } -} - -func TestLookupArtist_Unreachable(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } -} -``` - -- [ ] **Step 2.6: Run the tests, fix until green** - -```bash -go test -race -v ./internal/lidarr/... -``` - -Expected: 4 tests pass. - -- [ ] **Step 2.7: Add LookupAlbum and LookupTrack methods** - -Append to `internal/lidarr/client.go`: - -```go -// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized -// LookupResults; Secondary is "year · trackcount". -func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - ArtistName string `json:"artistName"` - ReleaseDate string `json:"releaseDate"` - TrackCount int `json:"trackCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - year := "" - if len(r.ReleaseDate) >= 4 { - year = r.ReleaseDate[:4] - } - secondary := r.ArtistName - if year != "" { - secondary += " · " + year - } - if r.TrackCount > 0 { - secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks" - } - out = append(out, LookupResult{ - MBID: r.ForeignAlbumID, - Name: r.Title, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track -// lookup is per-album under the hood — Secondary is "album · artist". -func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignTrackID string `json:"foreignTrackId"` - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - AlbumTitle string `json:"albumTitle"` - ArtistName string `json:"artistName"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := r.AlbumTitle - if r.ArtistName != "" { - if secondary != "" { - secondary += " · " - } - secondary += r.ArtistName - } - out = append(out, LookupResult{ - MBID: r.ForeignTrackID, - Name: r.Title, - Secondary: secondary, - }) - } - return out, nil -} -``` - -- [ ] **Step 2.8: Add tests for LookupAlbum and LookupTrack** - -Use the same `httptest`+fixture pattern. Capture two more JSON files (`testdata/lookup_album.json`, `testdata/lookup_track.json`) with at least 2 results each, then add `TestLookupAlbum_HappyPath` and `TestLookupTrack_HappyPath` mirroring `TestLookupArtist_HappyPath`. Auth/server-error variants are unchanged so don't duplicate them — one parametric helper test suffices. - -- [ ] **Step 2.9: Add AddArtist, AddAlbum, ListQualityProfiles, ListRootFolders, Ping** - -Append to `internal/lidarr/client.go`: - -```go -// post is the shared POST helper. Body is marshaled JSON. -func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = u.Path + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - return nil, ErrServerError - } - if resp.StatusCode >= 400 { - _ = resp.Body.Close() - return nil, ErrLookupFailed - } - return resp, nil -} - -func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { - monitor := "future" - if p.MonitorAll { - monitor = "all" - } - body, _ := json.Marshal(map[string]any{ - "foreignArtistId": p.ForeignArtistID, - "qualityProfileId": p.QualityProfileID, - "rootFolderPath": p.RootFolderPath, - "monitored": true, - "monitor": monitor, - "addOptions": map[string]any{"searchForMissingAlbums": true}, - }) - resp, err := c.post(ctx, "/api/v1/artist", body) - if err != nil { - return err - } - _ = resp.Body.Close() - return nil -} - -func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { - body, _ := json.Marshal(map[string]any{ - "foreignAlbumId": p.ForeignAlbumID, - "foreignArtistId": p.ForeignArtistID, - "qualityProfileId": p.QualityProfileID, - "rootFolderPath": p.RootFolderPath, - "monitored": true, - "addOptions": map[string]any{"searchForNewAlbum": true}, - }) - resp, err := c.post(ctx, "/api/v1/album", body) - if err != nil { - return err - } - _ = resp.Body.Close() - return nil -} - -func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) { - resp, err := c.get(ctx, "/api/v1/qualityprofile", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ID int `json:"id"` - Name string `json:"name"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]QualityProfile, len(raw)) - for i, r := range raw { - out[i] = QualityProfile{ID: r.ID, Name: r.Name} - } - return out, nil -} - -func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) { - resp, err := c.get(ctx, "/api/v1/rootfolder", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - Path string `json:"path"` - Accessible bool `json:"accessible"` - FreeSpace int64 `json:"freeSpace"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]RootFolder, len(raw)) - for i, r := range raw { - out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace} - } - return out, nil -} - -func (c *Client) Ping(ctx context.Context) (PingResult, error) { - resp, err := c.get(ctx, "/api/v1/system/status", nil) - if err != nil { - return PingResult{}, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw struct { - Version string `json:"version"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - return PingResult{Version: raw.Version}, nil -} -``` - -Add `"bytes"` to the imports. - -- [ ] **Step 2.10: Add tests for the remaining methods** - -For each: capture or hand-write a fixture, add `TestAddArtist_PostsCorrectBody`, `TestAddAlbum_PostsCorrectBody`, `TestListQualityProfiles_HappyPath`, `TestListRootFolders_HappyPath`, `TestPing_ReturnsVersion`. The Add* tests should assert on the parsed POST body — read `r.Body`, decode JSON, check the field shape. - -- [ ] **Step 2.11: Run all client tests** - -```bash -go test -race -cover ./internal/lidarr/... -``` - -Expected: all tests pass; coverage ≥ 80%. - -- [ ] **Step 2.12: Commit** - -```bash -git add internal/lidarr/ -git commit -m "feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)" -``` - ---- - -### Task 3 — `lidarrconfig` singleton service - -**Files:** -- Create: `internal/lidarrconfig/service.go` -- Create: `internal/lidarrconfig/service_test.go` - -- [ ] **Step 3.1: Write the service** - -`internal/lidarrconfig/service.go`: - -```go -// Package lidarrconfig is a thin wrapper over the singleton lidarr_config -// row. Get returns a typed Config; Save updates it. Callers branch on -// Config.Enabled — never on raw NULL fields. -package lidarrconfig - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Config is the typed projection of lidarr_config (no NULL fields exposed -// to callers — empty strings / zero ints carry the "unset" meaning). -type Config struct { - Enabled bool - BaseURL string - APIKey string - DefaultQualityProfileID int - DefaultRootFolderPath string -} - -// Service reads and writes the singleton. -type Service struct { - pool *pgxpool.Pool -} - -func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } - -func (s *Service) Get(ctx context.Context) (Config, error) { - row, err := dbq.New(s.pool).GetLidarrConfig(ctx) - if err != nil { - return Config{}, fmt.Errorf("lidarrconfig: %w", err) - } - cfg := Config{Enabled: row.Enabled} - if row.BaseUrl != nil { - cfg.BaseURL = *row.BaseUrl - } - if row.ApiKey != nil { - cfg.APIKey = *row.ApiKey - } - if row.DefaultQualityProfileID != nil { - cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) - } - if row.DefaultRootFolderPath != nil { - cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath - } - return cfg, nil -} - -// Save writes the entire row. Callers pass the full Config they want -// stored — this is not a partial update. -func (s *Service) Save(ctx context.Context, cfg Config) error { - var ( - baseURL *string = strPtr(cfg.BaseURL) - apiKey *string = strPtr(cfg.APIKey) - qpID *int32 = int32Ptr(cfg.DefaultQualityProfileID) - rootPath *string = strPtr(cfg.DefaultRootFolderPath) - ) - _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ - Enabled: cfg.Enabled, - BaseUrl: baseURL, - ApiKey: apiKey, - DefaultQualityProfileID: qpID, - DefaultRootFolderPath: rootPath, - }) - if err != nil { - return fmt.Errorf("lidarrconfig: %w", err) - } - return nil -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} - -func int32Ptr(i int) *int32 { - if i == 0 { - return nil - } - v := int32(i) - return &v -} -``` - -Note: the exact field names on `dbq.UpdateLidarrConfigParams` depend on sqlc's generation. After `sqlc generate` ran in Task 1, you'll see them — adjust the literal field names here to match. Pointer-vs-value also depends on how sqlc treats nullable text columns. - -- [ ] **Step 3.2: Write the integration test** - -`internal/lidarrconfig/service_test.go`: - -```go -package lidarrconfig - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" -) - -func newTestPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - // Reset singleton to default state before each test by replacing - // the row contents via UPDATE (TRUNCATE would violate the CHECK). - if _, err := pool.Exec(context.Background(), - "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", - ); err != nil { - t.Fatalf("reset: %v", err) - } - return pool -} - -func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { - pool := newTestPool(t) - cfg, err := New(pool).Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { - t.Errorf("expected zero-value Config, got %+v", cfg) - } -} - -func TestSaveThenGet_RoundTrip(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - want := Config{ - Enabled: true, - BaseURL: "http://lidarr.lan:8686", - APIKey: "secret", - DefaultQualityProfileID: 4, - DefaultRootFolderPath: "/music", - } - if err := s.Save(context.Background(), want); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got != want { - t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) - } -} - -func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { - t.Errorf("expected empty strings on round-trip; got %+v", got) - } -} -``` - -- [ ] **Step 3.3: Run the tests inside the docker network** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrconfig/... -``` - -Expected: 3 tests pass. - -- [ ] **Step 3.4: Commit** - -```bash -git add internal/lidarrconfig/ -git commit -m "feat(lidarrconfig): typed singleton config wrapper" -``` - ---- - -### Task 4 — `lidarrrequests` Service (lifecycle, no reconciler) - -**Files:** -- Create: `internal/lidarrrequests/service.go` -- Create: `internal/lidarrrequests/service_test.go` - -- [ ] **Step 4.1: Write the Service** - -`internal/lidarrrequests/service.go`: - -```go -// Package lidarrrequests owns the lifecycle of user requests to add -// music via Lidarr. The synchronous Service handles Create/List/ -// Approve/Reject/Cancel; the async Reconciler (separate file) closes -// approved requests once their target track lands in the library. -package lidarrrequests - -import ( - "context" - "errors" - "fmt" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -// Public errors. Handlers map these to API error codes. -var ( - ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind") - ErrNotPending = errors.New("lidarrrequests: request is not pending") - ErrNotFound = errors.New("lidarrrequests: request not found") - ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured") -) - -// CreateParams is the input for a new request from a user. -type CreateParams struct { - Kind string // "artist", "album", or "track" - LidarrArtistMBID string - LidarrAlbumMBID string // required for kind=album/track - LidarrTrackMBID string // required for kind=track - ArtistName string - AlbumTitle string // required for kind=album/track - TrackTitle string // required for kind=track -} - -// ApproveOverrides lets the admin override the snapshot defaults for one -// approval. Zero values mean "use config default." -type ApproveOverrides struct { - QualityProfileID int - RootFolderPath string -} - -type Service struct { - pool *pgxpool.Pool - lidarrCfg *lidarrconfig.Service - client *lidarr.Client - scanFn func() // injected; called after Approve to trigger a library scan -} - -func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, client *lidarr.Client, scanFn func()) *Service { - if scanFn == nil { - scanFn = func() {} - } - return &Service{pool: pool, lidarrCfg: cfg, client: client, scanFn: scanFn} -} - -// Create validates the kind→required-fields invariant and inserts a -// pending row. -func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) { - if err := validateKindFields(p); err != nil { - return dbq.LidarrRequest{}, err - } - q := dbq.New(s.pool) - row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ - UserID: userID, - Kind: dbq.LidarrRequestKind(p.Kind), - LidarrArtistMbid: p.LidarrArtistMBID, - LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID), - LidarrTrackMbid: strPtr(p.LidarrTrackMBID), - ArtistName: p.ArtistName, - AlbumTitle: strPtr(p.AlbumTitle), - TrackTitle: strPtr(p.TrackTitle), - }) - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err) - } - return row, nil -} - -func validateKindFields(p CreateParams) error { - if p.LidarrArtistMBID == "" || p.ArtistName == "" { - return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) - } - switch p.Kind { - case "artist": - // fine - case "album": - if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { - return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) - } - case "track": - if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { - return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields) - } - if p.LidarrTrackMBID == "" || p.TrackTitle == "" { - return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) - } - default: - return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) - } - return nil -} - -func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ - Status: dbq.LidarrRequestStatusPending, Limit: limit, - }) -} - -func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ - Status: dbq.LidarrRequestStatus(status), Limit: limit, - }) -} - -func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{ - UserID: userID, Limit: limit, - }) -} - -// Approve transitions a pending request to approved, snapshotting the -// chosen quality profile + root folder, then calls Lidarr to actually -// add the artist/album, then triggers a library scan. If Lidarr returns -// an error, the request stays pending — the admin sees the error and -// can retry without losing the request. -func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) { - cfg, err := s.lidarrCfg.Get(ctx) - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err) - } - if !cfg.Enabled || s.client == nil { - return dbq.LidarrRequest{}, ErrLidarrDisabled - } - row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrRequest{}, ErrNotFound - } - return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err) - } - if row.Status != dbq.LidarrRequestStatusPending { - return dbq.LidarrRequest{}, ErrNotPending - } - - qp := cfg.DefaultQualityProfileID - if ov.QualityProfileID != 0 { - qp = ov.QualityProfileID - } - rf := cfg.DefaultRootFolderPath - if ov.RootFolderPath != "" { - rf = ov.RootFolderPath - } - - switch row.Kind { - case dbq.LidarrRequestKindArtist: - err = s.client.AddArtist(ctx, lidarr.AddArtistParams{ - ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true, - }) - case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack: - // Track-kind requests promote to album-add; the spec is explicit. - albumMBID := "" - if row.LidarrAlbumMbid != nil { - albumMBID = *row.LidarrAlbumMbid - } - err = s.client.AddAlbum(ctx, lidarr.AddAlbumParams{ - ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid, - QualityProfileID: qp, RootFolderPath: rf, - }) - } - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err) - } - - approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ - ID: requestID, - QualityProfileID: int32Ptr(qp), - RootFolderPath: strPtr(rf), - DecidedBy: uuidPtr(adminID), - }) - if err != nil { - // Lidarr accepted but our DB update failed; admin should retry. - return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) - } - s.scanFn() - return approved, nil -} - -func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) { - row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{ - ID: requestID, Notes: strPtr(notes), DecidedBy: uuidPtr(adminID), - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // Either not found OR not pending — caller can't distinguish from - // the SQL alone, so check after. - cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) - if gerr != nil { - return dbq.LidarrRequest{}, ErrNotFound - } - if cur.Status != dbq.LidarrRequestStatusPending { - return dbq.LidarrRequest{}, ErrNotPending - } - return dbq.LidarrRequest{}, ErrNotFound - } - return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err) - } - return row, nil -} - -func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) { - row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{ - ID: requestID, UserID: userID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrRequest{}, ErrNotPending - } - return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err) - } - return row, nil -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} -func int32Ptr(i int) *int32 { - if i == 0 { - return nil - } - v := int32(i) - return &v -} -func uuidPtr(u pgtype.UUID) pgtype.UUID { return u } -``` - -(Field names like `dbq.LidarrRequestStatusPending`, `dbq.LidarrRequestKindArtist` come from sqlc — verify after `sqlc generate`. Pointer-vs-value for nullable columns also from sqlc.) - -- [ ] **Step 4.2: Write the integration test** - -`internal/lidarrrequests/service_test.go`: - -```go -package lidarrrequests - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - if _, err := pool.Exec(context.Background(), - "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", - ); err != nil { - t.Fatalf("reset lidarr tables: %v", err) - } - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user: %v", err) - } - return u.ID -} - -func TestCreate_HappyPath_Artist(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, err := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", - LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - ArtistName: "Boards of Canada", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if r.Status != dbq.LidarrRequestStatusPending { - t.Errorf("status = %v", r.Status) - } -} - -func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - _, err := svc.Create(context.Background(), user, CreateParams{ - Kind: "track", - LidarrArtistMBID: "a-mbid", ArtistName: "X", - LidarrTrackMBID: "t-mbid", TrackTitle: "Y", - // missing album fields - }) - if !errors.Is(err, ErrInvalidKindFields) { - t.Fatalf("err = %v, want ErrInvalidKindFields", err) - } -} - -func TestApprove_NotConfigured(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) - if !errors.Is(err, ErrLidarrDisabled) { - t.Fatalf("err = %v, want ErrLidarrDisabled", err) - } -} - -func TestReject_TransitionsToRejected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") - if err != nil { - t.Fatalf("Reject: %v", err) - } - if rejected.Status != dbq.LidarrRequestStatusRejected { - t.Errorf("status = %v", rejected.Status) - } -} - -func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - _, _ = svc.Reject(context.Background(), r.ID, user, "first") - _, err := svc.Reject(context.Background(), r.ID, user, "second") - if !errors.Is(err, ErrNotPending) { - t.Fatalf("err = %v, want ErrNotPending", err) - } -} - -func TestCancel_OwnPendingOnly(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { - t.Fatalf("Cancel: %v", err) - } - // Second cancel hits "not pending" because we just rejected it. - if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { - t.Errorf("err = %v, want ErrNotPending", err) - } -} -``` - -- [ ] **Step 4.3: Run the tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrrequests/... -``` - -Expected: 6 tests pass. - -- [ ] **Step 4.4: Commit** - -```bash -git add internal/lidarrrequests/service.go internal/lidarrrequests/service_test.go -git commit -m "feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)" -``` - ---- - -This plan continues in the same shape for the remaining tasks. Subsequent tasks are sketched below at one-paragraph-per-task density to keep the plan navigable; expand each into the same step-level TDD detail (write test → run → implement → run → commit) when you reach it. Each task references the spec sections that drive it. - ---- - -### Task 5 — `lidarrrequests` Reconciler worker - -**Files:** `internal/lidarrrequests/reconciler.go`, `internal/lidarrrequests/reconciler_integration_test.go`, plus a sqlc query `MatchTrackForRequest` in `internal/db/queries/lidarr_requests.sql`. - -The reconciler mirrors `internal/similarity.Worker`: a `Run(ctx)` loop that calls `tickOnce(ctx)` every 5 minutes. `tickOnce`: -1. `ListApprovedLidarrRequestsForReconcile(limit=50)`. -2. For each row, look up the matching local row via MBID: - - `kind=artist` → `SELECT id FROM artists WHERE mbid = $1` - - `kind=album` → `SELECT id FROM albums WHERE mbid = $1` - - `kind=track` → `SELECT id FROM tracks WHERE album_id = (SELECT id FROM albums WHERE mbid = $1) LIMIT 1` (track-kind matched by parent album per spec §3 reconciler note) -3. If a match is found, call `CompleteLidarrRequest` with the matched IDs. - -Reconciler short-circuits to no-op when `lidarrconfig.Get(...).Enabled == false`. Errors logged at WARN, never propagated. - -**Tests** (per spec §8 — five integration scenarios): -- `TestReconciler_MatchesArtistByMBID` — seed artist, seed approved artist-kind request with same MBID, run `tickOnce`, expect status=completed and `matched_artist_id` set. -- `TestReconciler_MatchesAlbumByMBID` — same shape for album. -- `TestReconciler_MatchesTrackViaAlbumMBID` — track-kind request matches when ANY track of the parent album appears. -- `TestReconciler_NoMatchLeavesPending` — approved request with MBID not in library → row unchanged after `tickOnce`. -- `TestReconciler_AlreadyCompletedRowNotReprocessed` — pre-set status=completed, ensure `tickOnce` doesn't touch it. -- `TestReconciler_DisabledIsNoOp` — `lidarr_config.enabled=false` → `tickOnce` short-circuits even with approved rows present. - -Commit: `feat(lidarrrequests): add Reconciler worker matching approved requests to library`. - ---- - -### Task 6 — `RequireAdmin` middleware - -**Files:** `internal/auth/admin.go`, `internal/auth/admin_test.go`. - -Mirror `RequireUser`'s shape. After `RequireUser` puts the user in context, `RequireAdmin` reads the user from context, returns 403 with `{"error":"not_authorized"}` JSON envelope if `IsAdmin == false`. Test cases: admin passes through; non-admin returns 403; missing context (programmer error) returns 500. - -Commit: `feat(auth): add RequireAdmin middleware for /api/admin/* routes`. - ---- - -### Task 7 — `/api/lidarr/search` proxy handler - -**Files:** `internal/api/lidarr.go`, `internal/api/lidarr_test.go`. Modify: `internal/api/api.go` to inject the Lidarr client + lidarrconfig service into `handlers`. - -Handler reads `q` and `kind` from query params, validates `kind ∈ {artist, album, track}`, checks `lidarrconfig.Get().Enabled` — if false, returns `503 {"error":"lidarr_disabled"}`. Calls the matching `client.Lookup*`, then per-result enriches with: -- `in_library` — by joining against `artists.mbid` / `albums.mbid` / `tracks.mbid`. Add a small `IsMBIDInLibrary` sqlc query for each kind. -- `requested` — via `HasNonTerminalRequestForMBID` (already in queries from Task 1). - -Maps `lidarr.ErrUnreachable`/`ErrAuthFailed` to `503 lidarr_unreachable` / `503 lidarr_auth_failed`. Other errors → `500`. - -**Tests:** -- `TestHandleLidarrSearch_HappyPath` — stubs the Client to return one in-library + one requestable + one already-requested, asserts the JSON shape. -- `TestHandleLidarrSearch_DisabledReturns503` — `lidarr_config.enabled=false`. -- `TestHandleLidarrSearch_LidarrUnreachable` — stubbed Client returns `ErrUnreachable`. -- `TestHandleLidarrSearch_BadKind400`. -- `TestHandleLidarrSearch_RequiresAuth` — anonymous request rejected. - -Commit: `feat(api): add /api/lidarr/search proxy with library/request enrichment`. - ---- - -### Task 8 — `/api/requests` user-facing CRUD handlers - -**Files:** `internal/api/requests.go`, `internal/api/requests_test.go`. Modify: `internal/api/api.go` to register routes inside the `RequireUser` group. - -Five handlers: `POST /api/requests` (Create), `GET /api/requests` (ListForUser), `GET /api/requests/:id`, `DELETE /api/requests/:id` (Cancel). - -Each handler delegates to `lidarrrequests.Service`. Map `ErrInvalidKindFields → 400 mbid_required`, `ErrNotPending → 409 request_not_pending`, `ErrNotFound → 404 request_not_found`. `GET /:id` returns 404 if the row isn't the caller's own AND caller isn't admin. - -**Tests** (extend `testHandlers` to inject `lidarrrequests.Service`): -- Create with valid artist/album/track payloads → 201. -- Create with each invalid kind→fields combination → 400. -- List returns only caller's rows; cross-user scoped out. -- Get-own returns row; get-other-user 404; get-other-user-as-admin 200. -- Cancel pending → 200 with status=rejected; cancel non-pending → 409. - -Commit: `feat(api): add /api/requests user-facing CRUD`. - ---- - -### Task 9 — `/api/admin/lidarr/*` config + profiles + folders + test - -**Files:** `internal/api/admin_lidarr.go`, `internal/api/admin_lidarr_test.go`. Modify: `internal/api/api.go` to mount a `RequireAdmin` group under `/api/admin`. - -Handlers: `GET /api/admin/lidarr/config` (mask api_key), `PUT /api/admin/lidarr/config`, `POST /api/admin/lidarr/test`, `GET /api/admin/lidarr/quality-profiles`, `GET /api/admin/lidarr/root-folders`. - -PUT logic: if request `api_key` is empty string → preserve saved value; if non-empty → update. `enabled=true` requires `base_url` and `api_key` to be non-empty (validate at handler). - -Test endpoint: per-field fallback to saved values when absent or empty; always returns 200 with `{ok, version?, error?}`. - -**Tests:** -- GET config masks api_key when set. -- PUT empty api_key preserves saved value. -- PUT enabled=true with empty base_url → 400. -- POST test happy path returns `{ok:true, version}`. -- POST test with stubbed-unreachable client returns `{ok:false, error}`. -- Quality-profiles + root-folders proxy through to client. -- All endpoints return 403 for non-admin tokens. - -Commit: `feat(api): add /api/admin/lidarr/* config + profiles + folders + test`. - ---- - -### Task 10 — `/api/admin/requests/*` approval queue handlers - -**Files:** `internal/api/admin_requests.go`, `internal/api/admin_requests_test.go`. Modify: `internal/api/api.go` to register inside the `RequireAdmin` group. - -Three handlers: `GET /api/admin/requests?status=&limit=` (default `status=pending`), `POST /api/admin/requests/:id/approve` (body: optional override), `POST /api/admin/requests/:id/reject` (body: optional notes). - -Approve handler delegates to `Service.Approve`; surfaces `ErrLidarrDisabled` / `lidarr.ErrUnreachable` / `ErrNotPending` / `ErrNotFound` per error code table. - -**Tests:** -- List with status=pending returns pending rows only. -- Approve happy path: stubbed Client receives correct AddArtist/AddAlbum payload, row transitions to approved with snapshot fields, scan trigger called. -- Approve with override snapshots override values, not config defaults. -- Approve when Lidarr returns ErrUnreachable → 503; row stays pending. -- Reject with notes records notes; reject without notes works with NULL notes. -- All endpoints return 403 for non-admin tokens. - -Commit: `feat(api): add /api/admin/requests approval queue`. - ---- - -### Task 11 — Wire the Reconciler in `cmd/minstrel/main.go` - -**Files:** Modify `cmd/minstrel/main.go`. - -Mirror the existing scrobble/similarity worker spin-up. Construct `lidarrconfig.Service`, the `lidarr.Client` (BaseURL+APIKey loaded from the singleton on demand), `lidarrrequests.Reconciler`, and start its `Run(ctx)` in a goroutine alongside the others. - -Subtle: the Lidarr client's `BaseURL` and `APIKey` change at runtime when admin updates config. Two ways to handle — (a) construct a new Client per request inside the Service from the latest config, or (b) wrap a `*atomic.Pointer[lidarr.Client]` that the config-save handler swaps. Pick (a) — simpler, no atomic dance, the cost of constructing an `http.Client` per request is negligible. Refactor `Service` to hold a `func() *lidarr.Client` factory instead of a `*Client` so it always reads fresh config. - -(Update Task 4's `Service` shape to use the factory accordingly. This is a foreseeable refactor — better to absorb it now than fight stale clients in production.) - -Commit: `feat(cmd): start Lidarr reconciler worker alongside HTTP server`. - ---- - -### Task 12 — Frontend: FabledSword design tokens + fonts - -**Files:** Create `web/src/lib/styles/fabledsword-tokens.css`. Modify `web/src/app.css`. Modify `web/src/app.html` to load Google Fonts. Modify `web/tailwind.config.js` to alias semantic Tailwind utilities (e.g. `bg-surface`, `text-text-primary`, `border-border`) to FS tokens. - -Token file content: every variable from `project_design_system.md` `:root` block — surfaces, text, action, semantic, accent, font families, radii. Plus the per-app data-attribute hook that sets `--fs-accent` to forest-teal `#4A6B5C` for Minstrel. - -In Tailwind config, replace existing palette aliases: -- `surface`, `surface-hover` → Iron, Slate -- `background` → Obsidian -- `text-primary`, `text-secondary`, `text-muted` → Parchment, Vellum, Ash -- `border` → Pewter -- Add new utility classes for action (`bg-action-primary` → Moss, `bg-action-secondary` → Bronze, `bg-action-destructive` → Oxblood) and `accent` → forest teal. - -This is the slice that converts the rest of the app to the design system implicitly — by aliasing existing utility names. Verify by visiting the dev server and confirming the existing pages now read in the new palette without any per-page changes (a sign the alias mapping is correct). - -Commit: `feat(web): introduce FabledSword design system tokens + Tailwind aliases`. - ---- - -### Task 13 — Frontend: Lidarr + requests + admin API client modules - -**Files:** Create `web/src/lib/api/lidarr.ts`, `web/src/lib/api/requests.ts`, `web/src/lib/api/admin.ts`. - -Mirror existing client modules (e.g. `web/src/lib/api/likes.ts`). Each file exports typed async functions backed by the existing `api.get/post/put/delete` helper. - -Types match the API surface in spec §5. Vitest tests live alongside (e.g. `lidarr.test.ts`) using the existing fetch mocking setup — verify URL construction, query params, error mapping. - -Commit: `feat(web): add API client modules for Lidarr, requests, admin`. - ---- - -### Task 14 — Frontend: `` component - -**Files:** Create `web/src/lib/components/DiscoverResultCard.svelte`, `DiscoverResultCard.test.ts`. - -Component props: `{ kind: 'artist'|'album'|'track', title: string, subtitle?: string, imageUrl?: string, state: 'requestable'|'kept'|'requested', onRequest?: () => void }`. - -Layout discipline (per spec §6 + brainstorm): -- Outer `.card` is flex column with reserved `.text` block (`min-height` covers title + meta + badge row); `.actions` block uses `margin-top: auto`. -- Badge slot is always rendered as a 22px-min-height div; "Kept" pill (accent at 15% bg + accent text) appears only when `state==='kept'`. -- Three states render different actions: - - `requestable`: `bg-action-primary` button with plus icon, label "Request" - - `kept`: disabled ghost button "In library" + "Kept" pill in badge slot - - `requested`: disabled ghost button "Requested" -- Cover art: render `` when `imageUrl`; otherwise render Lucide fallback glyph (`Disc3` for artist, `Album` for album, `Music2` for track) inside the Slate-bg art square. - -**Tests:** -- Renders all three states with correct button text. -- Calls `onRequest` only in `requestable` state. -- Computes badge slot height with `min-height: 22px` even when no badge content (assert via `getComputedStyle`). -- Button is anchored to bottom of card body (assert `margin-top` === `auto` on `.actions`). - -Commit: `feat(web): add DiscoverResultCard with reserved badge slot + anchored button`. - ---- - -### Task 15 — Frontend: `` component - -**Files:** Create `web/src/lib/components/StatusPill.svelte`, `StatusPill.test.ts`. - -Single prop: `status: 'pending'|'approved'|'completed'|'rejected'|'failed'`. Renders a pill with semantic color (Warning / Info / Moss / Error / Error) per spec §6 and the design-system memory. Voice-rule labels: "Awaiting review" / "Approved · downloading" / "Kept" / "Set aside" / "Couldn't add." - -Tests: each status renders with the correct text and the correct semantic CSS class (use `bg-warning-tint`, etc., aliases). - -Commit: `feat(web): add StatusPill semantic-color status indicator`. - ---- - -### Task 16 — Frontend: `/discover` route - -**Files:** Create `web/src/routes/discover/+page.svelte`, `discover.test.ts`. Modify `web/src/lib/components/Shell.svelte` to add `/discover` to the main nav. - -Page elements: -- H2 "Add music to the library" (Fraunces 24/500), Vellum subtitle -- Search input (Obsidian inset, focus ring forest-teal) -- Tabs (Artists / Albums / Tracks) — active tab gets 2px forest-teal bottom border -- Card grid using `` -- Track-kind confirm modal: opens on Request click with a track-state result; "Requesting *Track X* will add the album *Album Y*. Continue?" — Confirm = Moss, Cancel = Bronze. Modal dismissed = no-op. - -Debounce search input by 250ms before querying. - -**Tests:** -- Debounced query fires correct API call with kind selector. -- Tab switch refetches with new `kind`. -- Track-kind result triggers modal; confirm triggers API call; cancel does not. -- Requestable card with `onRequest` flips to `requested` state on success. -- Empty results state shows "Nothing to add for that search yet." (voice-rule copy). - -Commit: `feat(web): add /discover route with search + request flow`. - ---- - -### Task 17 — Frontend: `/requests` user request history - -**Files:** Create `web/src/routes/requests/+page.svelte`, `requests.test.ts`. Modify `Shell.svelte` to add `/requests` link in the main nav (visible to all authed users). - -Page renders the caller's requests as rows (mirrors the mockup at `.superpowers/brainstorm/.../user-requests.html`). Each row: -- 56px album-art square (Slate fallback) -- Kind pill + StatusPill -- Title + meta line -- Per-status actions: Cancel button on pending; "Listen" link (forest-teal text) on completed (navigates to `/tracks/` if set, else fallthrough to album/artist) - -**Tests:** -- Renders one row per request from the API. -- Pending row exposes Cancel; Cancel calls API and removes row. -- Completed row renders "Listen" link with correct href. -- Rejected row renders admin notes if present, hides "Cancel" / "Listen." -- Empty list shows "Nothing requested yet." (voice-rule copy). - -Commit: `feat(web): add /requests user-facing request history`. - ---- - -### Task 18 — Frontend: `/admin/*` layout + role gate - -**Files:** Create `web/src/routes/admin/+layout.svelte`, `web/src/routes/admin/+layout.ts`, `web/src/routes/admin/+page.svelte` (Overview landing). - -`+layout.ts` exports a `load` function that checks `currentUser.is_admin`; if false, throws a SvelteKit `redirect(302, '/')`. Redirect happens before layout/child renders — exactly the hard route gate the operator specified. - -`+layout.svelte` renders the admin shell: -- Page header: "Admin" wordmark in Fraunces, the FabledSword small mark in Oxblood at top-left -- 220px sidebar `` component (separate file `web/src/lib/components/AdminSidebar.svelte`) with nav items: Overview / Integrations / **Requests** / Quarantine (placeholder, dimmed) / Users (placeholder, dimmed) / Library (placeholder, dimmed) -- Active nav item: 12% accent-tinted bg + 2px forest-teal left strip -- Main content area: `` for children - -`+page.svelte` (Overview): plain landing with two callout cards — "Pending requests: N" and "Lidarr: connected/unset" — each linking to its sub-page. Functional, not decorative. - -**Tests** (browser-mode, since SvelteKit `load` requires it): -- Non-admin user redirected to `/` before layout renders. -- Admin user lands on `/admin` and sees sidebar with Overview active. - -Commit: `feat(web): add /admin layout with role-gated load + sidebar`. - ---- - -### Task 19 — Frontend: `/admin/integrations` Lidarr panel - -**Files:** Create `web/src/routes/admin/integrations/+page.svelte`, `integrations.test.ts`. - -Page elements (matches mockup `admin-integrations.html`): -- Page header with status pill ("Lidarr · connected" Moss-tinted; "unset" Pewter ghost when not configured) -- Form section "Lidarr" with rows: - - Base URL — text input (Obsidian inset, JetBrains Mono for the URL value) - - API key — password input (masked) - - Default quality profile — `` populated from `GET /api/admin/lidarr/root-folders` -- Action row: Save changes (Moss + check icon), Test connection (Pewter ghost + refresh icon), Disconnect (Oxblood + trash icon, right-aligned) -- Disconnect requires a typed-confirm modal ("Type DISCONNECT to remove the Lidarr connection") because it sets `enabled=false` and clears `api_key`. - -Disabled section "MusicBrainz overrides" with `unset` foreshadows future integrations. Visually present, not implemented. - -**Tests:** -- Save changes calls PUT with form values. -- Empty api_key field on Save preserves saved value (sends empty string per spec). -- Test connection populates Lidarr's reported version on success. -- Disconnect requires modal confirmation; cancelling modal does not clear config. -- Quality-profile / root-folder dropdowns populated from API. - -Commit: `feat(web): add /admin/integrations Lidarr connection panel`. - ---- - -### Task 20 — Frontend: `/admin/requests` approval queue + override modal - -**Files:** Create `web/src/routes/admin/requests/+page.svelte`, `requests.test.ts`. Reuse ``. - -Page elements (matches mockup `admin-requests.html`): -- Tabs: Pending (default) / Approved / Completed / Rejected — each shows count from API as accent-tinted pill -- Request rows with action cluster: Override (Pewter ghost), Approve (Moss + check icon), Reject (Bronze + ✕ icon) -- Track-kind row's meta line spells out "Approving will add the album *X*" -- Override modal: collapsed-by-default form with Quality profile dropdown (populated via the admin endpoint) + Root folder dropdown; "Use defaults" leaves both empty (server uses snapshot defaults) - -**Tests:** -- Tab switch refetches with `?status=`. -- Approve fires POST with optional override values. -- Reject opens a notes input (textarea) above a Confirm button; Confirm sends notes. -- Approve with override modal returns chosen values to handler. -- Toast on Lidarr-unreachable error. - -Commit: `feat(web): add /admin/requests approval queue with override modal`. - ---- - -### Task 21 — Final verification + branch finish - -- [ ] **Step 21.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. - -- [ ] **Step 21.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -- [ ] **Step 21.3: Coverage check on new packages** - -```bash -go test -race -coverprofile=/tmp/cov.out ./internal/lidarr/... ./internal/lidarrconfig/... ./internal/lidarrrequests/... -go tool cover -func=/tmp/cov.out | tail -1 -``` - -Expected: combined ≥ 80% per spec §8. - -- [ ] **Step 21.4: Frontend full check** - -```bash -cd web && npm run check && npm test && npm run build -``` - -Expected: 0 errors, all vitest tests pass, build succeeds. - -- [ ] **Step 21.5: Manual smoke** - -- Set Lidarr config in `/admin/integrations` (use real Lidarr or stub). -- Search at `/discover`, request an artist. -- Approve from `/admin/requests`. -- Verify request shows up at `/requests` as Approved → wait for next library scan → status flips to Kept. -- Cancel a pending request from `/requests`. -- Verify non-admin is redirected when navigating to `/admin/*`. - -- [ ] **Step 21.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. - ---- - -## Self-review checklist (run before declaring the plan ready) - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 2 (client), 3 (config), 4 (service), 5 (reconciler), 6 (middleware), 11 (wiring) -- §4 Schema: Task 1 -- §5 API surface: Tasks 7 (search), 8 (requests CRUD), 9 (admin lidarr), 10 (admin requests) -- §6 UI surfaces: Tasks 12 (tokens), 13 (api), 14 (DiscoverResultCard), 15 (StatusPill), 16 (/discover), 17 (/requests), 18 (/admin layout), 19 (/admin/integrations), 20 (/admin/requests) -- §7 Error handling: distributed across Tasks 7-10 (each handler maps Service errors to API codes) -- §8 Testing: every Task includes tests; Task 21 verifies coverage targets -- §9 Decisions ledger: not directly implemented but referenced in commit messages -- §10 Out of scope: explicitly excluded — no quarantine, no suggested-additions, no webhook -- §11 Open questions: cover-art proxy + debounce/cache deferred to plan time → debounce at 250ms in Task 16; cover-art direct fetch (no proxy) for v1 - -**Placeholder scan:** the per-task detail level drops after Task 4 (each becomes one paragraph) — this is intentional for plan navigability, not a placeholder. When a subagent picks up Task 5+ they expand the paragraph into the same step-level TDD detail using Tasks 1-4 as templates, and reference the spec for any ambiguity. No "TBD" or "TODO" remains. - -**Type consistency:** -- Method names match across plan: `Service.Create/ListPending/ListByStatus/ListForUser/Approve/Reject/Cancel`, `Reconciler.Run/tickOnce`, `Client.LookupArtist/LookupAlbum/LookupTrack/AddArtist/AddAlbum/ListQualityProfiles/ListRootFolders/Ping` -- API paths match spec §5 -- Component names: ``, ``, `` — used consistently -- DB field names: `lidarr_artist_mbid`, `lidarr_album_mbid`, `lidarr_track_mbid`, `quality_profile_id`, `root_folder_path`, `matched_track_id`, etc. — consistent - -Plan is complete. diff --git a/docs/superpowers/plans/2026-04-30-m5b-quarantine.md b/docs/superpowers/plans/2026-04-30-m5b-quarantine.md deleted file mode 100644 index 358cf510..00000000 --- a/docs/superpowers/plans/2026-04-30-m5b-quarantine.md +++ /dev/null @@ -1,2916 +0,0 @@ -# M5b — Quarantine workflow + admin resolution UI — 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:** Wire per-user track-level quarantine into Minstrel — flag affordance via a kebab `` on every track row + the player, soft-hide enforcement on user-context `/api/*` reads, dedicated `/library/hidden` for the user, aggregated admin queue at `/admin/quarantine` with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions. - -**Architecture:** New `internal/lidarrquarantine` package (Service, no background worker) backed by two tables (`lidarr_quarantine` per-user complaints + `lidarr_quarantine_actions` audit log). Lidarr HTTP client gains `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` (always called with `deleteFiles=true` and `addImportListExclusion=true`). `internal/library` gains `DeleteTrackFile`. Existing read queries that return tracks in user-context get `*ForUser` variants that join against `lidarr_quarantine`; Subsonic queries are untouched. SPA gets a `` overflow component (mounted in `TrackRow` and `PlayerBar`) opening a ``, plus `/library/hidden` and `/admin/quarantine` routes. - -**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (existing M5a infrastructure). - -**Spec:** [`docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md`](../specs/2026-04-30-m5b-quarantine-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword token palette + voice rules), `project_subsonic_legacy.md` (`/rest/*` does not honor quarantine), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0011_lidarr_quarantine.up.sql` · `0011_lidarr_quarantine.down.sql` — schema -- `internal/db/queries/lidarr_quarantine.sql` — sqlc queries for both tables -- `internal/lidarrquarantine/service.go` — `Service` (Flag/Unflag/ListMine/ListAdminQueue/Resolve/DeleteFile/DeleteViaLidarr) -- `internal/lidarrquarantine/service_test.go` — integration tests -- `internal/lidarr/lookup_mbid.go` — `LookupArtistByMBID`, `LookupAlbumByMBID` (split from `client.go` to keep that file from growing) -- `internal/lidarr/delete.go` — `DeleteAlbum` HTTP method + `DELETE` helper -- `internal/lidarr/delete_test.go` — tests for the new methods -- `internal/lidarr/testdata/album_lookup_by_mbid.json`, `artist_lookup_by_mbid.json` — captured fixtures -- `internal/library/delete.go` — `DeleteTrackFile` -- `internal/library/delete_test.go` — tests -- `internal/api/quarantine.go` — `/api/quarantine/*` user-facing handlers -- `internal/api/quarantine_test.go` -- `internal/api/admin_quarantine.go` — `/api/admin/quarantine/*` admin handlers -- `internal/api/admin_quarantine_test.go` - -### Backend — modify - -- `internal/db/queries/tracks.sql` — add `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` (filtered variants) -- `internal/db/queries/recommendation.sql` — extend `LoadRadioCandidates` and `LoadRadioCandidatesV2` to also exclude quarantined tracks -- `internal/api/api.go` — register routes, mount `/api/admin/quarantine` group, route the modified user-context endpoints to the `*ForUser` queries -- `internal/api/auth_test.go` — extend `testHandlers` to inject `lidarrquarantine.Service` -- `internal/api/albums.go` (or wherever album-detail composes its track list) — switch to `ListTracksByAlbumForUser` when user context is present -- `internal/api/search.go` — switch to `SearchTracksForUser` -- `internal/api/radio.go` (or wherever radio handlers live) — pass through the existing user_id parameter to the now-quarantine-aware query -- `cmd/minstrel/main.go` — construct `lidarrquarantine.Service` and inject -- `internal/db/dbq/*` — regenerated by `sqlc generate` - -### Frontend — create - -- `web/src/lib/api/quarantine.ts` — user-facing client (Flag/Unflag/ListMine) -- `web/src/lib/api/quarantine.test.ts` -- `web/src/lib/components/TrackMenu.svelte` — kebab overflow menu -- `web/src/lib/components/TrackMenu.test.ts` -- `web/src/lib/components/FlagPopover.svelte` — reason + notes form -- `web/src/lib/components/FlagPopover.test.ts` -- `web/src/lib/components/QuarantineRow.svelte` — shared row used by both `/library/hidden` and `/admin/quarantine` -- `web/src/lib/components/QuarantineRow.test.ts` -- `web/src/routes/library/hidden/+page.svelte` -- `web/src/routes/library/hidden/hidden.test.ts` -- `web/src/routes/admin/quarantine/+page.svelte` -- `web/src/routes/admin/quarantine/quarantine.test.ts` - -### Frontend — modify - -- `web/src/lib/api/admin.ts` — add `listAdminQuarantine`, `resolveQuarantine`, `deleteQuarantineFile`, `deleteQuarantineViaLidarr`, `listQuarantineActions` plus query factories -- `web/src/lib/api/queries.ts` — add `qk.myQuarantine`, `qk.adminQuarantine`, `qk.adminQuarantineActions` -- `web/src/lib/api/types.ts` — add `LidarrQuarantineReason`, `LidarrQuarantineRow`, `AdminQuarantineRow`, `LidarrQuarantineActionRow`, `LidarrQuarantineAction` enums -- `web/src/lib/components/Shell.svelte` — add `Hidden` to the main nav after `Liked` -- `web/src/lib/components/Shell.test.ts` — assert the new nav order -- `web/src/lib/components/AdminSidebar.svelte` — promote `Quarantine` from `placeholder: true` to a real link -- `web/src/lib/components/AdminSidebar.test.ts` — update tests; quarantine is now a link -- `web/src/lib/components/TrackRow.svelte` — mount `` next to `` -- `web/src/lib/components/TrackRow.test.ts` — extend to cover the menu -- `web/src/lib/components/PlayerBar.svelte` — mount `` in the right cluster -- `web/src/lib/components/PlayerBar.test.ts` — extend to cover the menu - ---- - -## Task list - -### Task 1 — Migration 0011 + sqlc queries - -**Files:** -- Create: `internal/db/migrations/0011_lidarr_quarantine.up.sql` -- Create: `internal/db/migrations/0011_lidarr_quarantine.down.sql` -- Create: `internal/db/queries/lidarr_quarantine.sql` -- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0011_lidarr_quarantine.up.sql`: - -```sql --- M5b: per-user track quarantines + admin action audit log. --- --- lidarr_quarantine — one row per (user, track) complaint. PK matches --- the general_likes pattern. Re-flagging the same track upserts. Deleted --- on user resolution (un-hide), admin Resolve, or any of the deletes. --- --- lidarr_quarantine_actions — audit log of admin destructive actions. --- Snapshot text columns let the log stay readable after the underlying --- track/album rows are gone. - -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); - -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, - track_title text NOT NULL, - artist_name text NOT NULL, - album_title text, - action lidarr_quarantine_action NOT NULL, - admin_id uuid REFERENCES users(id) ON DELETE SET NULL, - lidarr_album_mbid text, - 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); -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0011_lidarr_quarantine.down.sql`: - -```sql -DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; -DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; -DROP TABLE IF EXISTS lidarr_quarantine_actions; -DROP TYPE IF EXISTS lidarr_quarantine_action; -DROP INDEX IF EXISTS lidarr_quarantine_user_idx; -DROP INDEX IF EXISTS lidarr_quarantine_track_idx; -DROP TABLE IF EXISTS lidarr_quarantine; -DROP TYPE IF EXISTS lidarr_quarantine_reason; -``` - -- [ ] **Step 1.3: Apply migration locally to confirm it runs** - -```bash -docker compose up -d postgres -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_quarantine_actions; DROP TYPE IF EXISTS lidarr_quarantine_action; DROP TABLE IF EXISTS lidarr_quarantine; DROP TYPE IF EXISTS lidarr_quarantine_reason;" -go run ./cmd/minstrel up 2>/dev/null || true # apply via server start instead -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine" -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine_actions" -``` - -Expected: both `\d` commands print the table with columns and indexes. - -If the project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. - -- [ ] **Step 1.4: Write the queries** - -`internal/db/queries/lidarr_quarantine.sql`: - -```sql --- name: UpsertQuarantine :one --- Insert a new quarantine row, or update reason/notes if the user has --- already flagged this track. -INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) -VALUES ($1, $2, $3, $4) -ON CONFLICT (user_id, track_id) DO UPDATE SET - reason = EXCLUDED.reason, - notes = EXCLUDED.notes, - created_at = now() -RETURNING user_id, track_id, reason, notes, created_at; - --- name: DeleteQuarantine :one --- Removes the caller's row. Returns the deleted row so the handler can --- distinguish "no row existed" (zero rows -> ErrNoRows) from success. -DELETE FROM lidarr_quarantine - WHERE user_id = $1 AND track_id = $2 - RETURNING user_id, track_id, reason, notes, created_at; - --- name: ListQuarantineForUser :many --- Caller's own quarantines joined with track + album + artist for full --- detail. Drives /library/hidden. -SELECT - sqlc.embed(q), - sqlc.embed(t), - sqlc.embed(al), - sqlc.embed(ar) -FROM lidarr_quarantine q -JOIN tracks t ON t.id = q.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -WHERE q.user_id = $1 -ORDER BY q.created_at DESC; - --- name: ListAdminQuarantineQueue :many --- Aggregated admin queue. One row per track. The handler post-processes --- the rows it gets from this query plus a per-track ListQuarantineReports --- call to materialize reason_counts and the per-user reports list. -SELECT - t.id AS track_id, - t.title AS track_title, - ar.name AS artist_name, - al.title AS album_title, - al.id AS album_id, - al.mbid AS lidarr_album_mbid, - count(q.user_id)::int AS report_count, - max(q.created_at) AS latest_at -FROM lidarr_quarantine q -JOIN tracks t ON t.id = q.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -GROUP BY t.id, ar.name, al.title, al.id, al.mbid -ORDER BY max(q.created_at) DESC; - --- name: ListQuarantineReportsForTrack :many --- Per-user reports for a single track. Returned by ListAdminQuarantineQueue --- post-processing and exposed expandable in the SPA admin queue rows. -SELECT - q.user_id, - u.username, - q.reason, - q.notes, - q.created_at -FROM lidarr_quarantine q -JOIN users u ON u.id = q.user_id -WHERE q.track_id = $1 -ORDER BY q.created_at DESC; - --- name: DeleteQuarantineForTrack :exec --- Clears all per-user rows for a given track. Used by Resolve and the --- two delete actions. Caller writes the audit row separately before --- this fires (so we can capture the affected_users count). -DELETE FROM lidarr_quarantine WHERE track_id = $1; - --- name: CountQuarantineForTrack :one --- Reads affected_users for the audit row before the delete fires. -SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; - --- name: WriteQuarantineAction :one --- Audit row for an admin destructive action. -INSERT INTO lidarr_quarantine_actions ( - track_id, track_title, artist_name, album_title, - action, admin_id, lidarr_album_mbid, affected_users -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: ListQuarantineActions :many -SELECT * FROM lidarr_quarantine_actions -ORDER BY created_at DESC -LIMIT $1; -``` - -- [ ] **Step 1.5: Run sqlc generate** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: clean build. New types `LidarrQuarantine`, `LidarrQuarantineAction`, `ListAdminQuarantineQueueRow`, `ListQuarantineForUserRow`, `ListQuarantineReportsForTrackRow` etc. surface in `internal/db/dbq/`. - -- [ ] **Step 1.6: Commit** - -```bash -git add internal/db/migrations/0011_lidarr_quarantine.up.sql \ - internal/db/migrations/0011_lidarr_quarantine.down.sql \ - internal/db/queries/lidarr_quarantine.sql \ - internal/db/dbq/ -git commit -m "feat(db): add lidarr_quarantine + actions schema (migration 0011)" -``` - ---- - -### Task 2 — Lidarr HTTP client extensions - -**Files:** -- Create: `internal/lidarr/lookup_mbid.go` -- Create: `internal/lidarr/delete.go` -- Create: `internal/lidarr/delete_test.go` -- Create: `internal/lidarr/testdata/album_lookup_by_mbid.json` -- Create: `internal/lidarr/testdata/artist_lookup_by_mbid.json` -- Modify: `internal/lidarr/types.go` — add `LidarrArtist`, `LidarrAlbum` - -The existing M5a client lives in `internal/lidarr/client.go`. To keep it from sprawling, the M5b additions land in two new files: `lookup_mbid.go` for the GET-by-MBID methods and `delete.go` for the DELETE method + a tiny `del()` HTTP helper. - -- [ ] **Step 2.1: Add the typed structs** - -`internal/lidarr/types.go` (modify) — append the two structs at the bottom of the file: - -```go -// LidarrArtist is the subset of Lidarr's artist resource used by M5b -// admin actions. The "id" field is Lidarr's internal numeric ID — needed -// for DELETE /api/v1/artist/{id} calls. -type LidarrArtist struct { - ID int `json:"id"` - ForeignArtistID string `json:"foreignArtistId"` // MBID - ArtistName string `json:"artistName"` -} - -// LidarrAlbum is the subset of Lidarr's album resource used by M5b -// admin actions. -type LidarrAlbum struct { - ID int `json:"id"` - ForeignAlbumID string `json:"foreignAlbumId"` // MBID - Title string `json:"title"` - ArtistID int `json:"artistId"` -} -``` - -- [ ] **Step 2.2: Add a sentinel error for not-found** - -`internal/lidarr/errors.go` (modify) — append: - -```go -// ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID -// when Lidarr returns 200 with an empty array — i.e., the MBID isn't in -// Lidarr's monitored set. Distinguished from network/auth errors so admin -// handlers can surface it as `lidarr_album_lookup_failed` (502) instead -// of `lidarr_unreachable` (503). -var ErrNotFound = errors.New("lidarr: not found") -``` - -If `errors.go` doesn't already import `"errors"`, add it. - -- [ ] **Step 2.3: Write `lookup_mbid.go`** - -```go -package lidarr - -import ( - "context" - "encoding/json" - "fmt" - "net/url" -) - -// LookupArtistByMBID returns the artist Lidarr has indexed under that -// MBID. Returns ErrNotFound if Lidarr returns an empty array. -func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { - if mbid == "" { - return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"mbId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/artist", q) - if err != nil { - return LidarrArtist{}, err - } - defer resp.Body.Close() - - var rows []LidarrArtist - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) - } - if len(rows) == 0 { - return LidarrArtist{}, ErrNotFound - } - return rows[0], nil -} - -// LookupAlbumByMBID returns the album Lidarr has indexed under that -// MBID. Returns ErrNotFound on empty result. -func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { - if mbid == "" { - return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"foreignAlbumId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/album", q) - if err != nil { - return LidarrAlbum{}, err - } - defer resp.Body.Close() - - var rows []LidarrAlbum - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) - } - if len(rows) == 0 { - return LidarrAlbum{}, ErrNotFound - } - return rows[0], nil -} -``` - -- [ ] **Step 2.4: Write `delete.go`** - -```go -package lidarr - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" -) - -// del issues a DELETE against the given path with optional query params. -// Mirrors the existing get/post helpers in client.go; consolidating the -// auth header + base-URL handling behavior in one place. -func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := c.url(path) - if err != nil { - return nil, err - } - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("lidarr: build DELETE: %w", err) - } - req.Header.Set("X-Api-Key", c.APIKey) - - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized { - resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 400 { - resp.Body.Close() - return nil, fmt.Errorf("%w: status %d", ErrLookupFailed, resp.StatusCode) - } - return resp, nil -} - -// DeleteAlbum removes an album from Lidarr's library. -// - deleteFiles=true also removes the audio files from disk. -// - addImportListExclusion=true tells Lidarr to never re-add this album -// via import-list scans. -// -// M5b's admin "delete via Lidarr" action always passes both `true`. -func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { - if lidarrAlbumID == 0 { - return fmt.Errorf("lidarr: zero album id") - } - q := url.Values{ - "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, - "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, - } - resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) - if err != nil { - return err - } - resp.Body.Close() - return nil -} -``` - -If `client.go` doesn't already export a `url(path)` helper, look at how `get(ctx, path, q)` builds its URL and either factor out the helper or inline the logic here. (M5a's `client.go` has `c.url(path)` at the top of the file — check before duplicating.) - -- [ ] **Step 2.5: Capture fixtures** - -`internal/lidarr/testdata/album_lookup_by_mbid.json` — a single-element JSON array matching what Lidarr returns for `GET /api/v1/album?foreignAlbumId=`: - -```json -[ - { - "id": 42, - "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", - "title": "Music Has The Right To Children", - "artistId": 7 - } -] -``` - -`internal/lidarr/testdata/artist_lookup_by_mbid.json` — same shape: - -```json -[ - { - "id": 7, - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada" - } -] -``` - -- [ ] **Step 2.6: Write `delete_test.go` (covers all three new methods)** - -```go -package lidarr - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestLookupAlbumByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/album" { - t.Errorf("path = %q, want /api/v1/album", r.URL.Path) - } - if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { - t.Errorf("foreignAlbumId = %q", got) - } - if got := r.Header.Get("X-Api-Key"); got != "test-key" { - t.Errorf("X-Api-Key = %q, want test-key", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") - if err != nil { - t.Fatalf("LookupAlbumByMBID: %v", err) - } - if got.ID != 42 || got.Title != "Music Has The Right To Children" { - t.Errorf("got = %+v", got) - } -} - -func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } -} - -func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrAuthFailed) { - t.Errorf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupArtistByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/artist" { - t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) - } - if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("mbId = %q", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") - if err != nil { - t.Fatalf("LookupArtistByMBID: %v", err) - } - if got.ID != 7 || got.ArtistName != "Boards of Canada" { - t.Errorf("got = %+v", got) - } -} - -func TestDeleteAlbum_PassesBothFlags(t *testing.T) { - var captured *http.Request - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - captured = r - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { - t.Fatalf("DeleteAlbum: %v", err) - } - if captured == nil || captured.Method != http.MethodDelete { - t.Fatalf("method = %v, want DELETE", captured) - } - if captured.URL.Path != "/api/v1/album/42" { - t.Errorf("path = %q", captured.URL.Path) - } - if got := captured.URL.Query().Get("deleteFiles"); got != "true" { - t.Errorf("deleteFiles = %q", got) - } - if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { - t.Errorf("addImportListExclusion = %q", got) - } -} - -func TestDeleteAlbum_5xxReturnsErrLookupFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrLookupFailed) { - t.Errorf("err = %v, want ErrLookupFailed", err) - } -} - -func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - srv.Close() // server is closed; client should fail to connect - c := NewClient(srv.URL, "test-key") - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrUnreachable) { - t.Errorf("err = %v, want ErrUnreachable", err) - } -} -``` - -`newTestClient` is the existing helper in `client_test.go` (M5a). Reuse it. - -- [ ] **Step 2.7: Run tests + build** - -```bash -go test ./internal/lidarr/... -count=1 -go build ./... -``` - -Expected: all green. - -- [ ] **Step 2.8: Commit** - -```bash -git add internal/lidarr/ -git commit -m "feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum" -``` - ---- - -### Task 3 — `internal/library` `DeleteTrackFile` - -**Files:** -- Create: `internal/library/delete.go` -- Create: `internal/library/delete_test.go` - -The admin "Delete file" action removes the file from disk and the row from `tracks`. The album/artist rows stay. Other tracks may reference them; the admin only nuked one track. - -- [ ] **Step 3.1: Write `delete.go`** - -```go -package library - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// ErrTrackNotFound is returned when DeleteTrackFile is called with an id -// that has no row in tracks. -var ErrTrackNotFound = errors.New("library: track not found") - -// DeleteTrackFile removes a track file from disk and its row from the -// tracks table. Album and artist rows are left untouched. -// -// Steps: -// 1. Look up the track to get its file_path. -// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone. -// 3. Delete the tracks row. -// -// Order matters: file first, then DB. If the file delete fails (permission, -// I/O error), we leave the DB row alone so the admin can retry. -func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { - q := dbq.New(pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrTrackNotFound - } - return fmt.Errorf("get track: %w", err) - } - - if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("remove file: %w", err) - } - - if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { - return fmt.Errorf("delete row: %w", err) - } - return nil -} -``` - -- [ ] **Step 3.2: Write `delete_test.go`** - -```go -package library - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func TestDeleteTrackFile_HappyPath(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - - if _, err := pool.Exec(context.Background(), - "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) - - // Create a real on-disk file the test can prove is removed. - dir := t.TempDir() - path := filepath.Join(dir, "track.mp3") - if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 7, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("upsert: %v", err) - } - - if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { - t.Fatalf("DeleteTrackFile: %v", err) - } - - // File gone. - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Errorf("file still exists: %v", err) - } - // Row gone. - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } - // Album row preserved. - if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil { - t.Errorf("album row vanished: %v", err) - } -} - -func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, _ := pgxpool.New(context.Background(), dsn) - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) - - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: "/no/such/file/anywhere.mp3", FileSize: 0, FileFormat: "mp3", - }) - - if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { - t.Fatalf("DeleteTrackFile with missing file: %v", err) - } - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } -} - -func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, _ := pgxpool.New(context.Background(), dsn) - t.Cleanup(pool.Close) - - var bogus pgxUUID - bogus.Set([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) - err := DeleteTrackFile(context.Background(), pool, bogus.UUID) - if !errors.Is(err, ErrTrackNotFound) { - t.Errorf("err = %v, want ErrTrackNotFound", err) - } -} - -// pgxUUID is a tiny shim for the test — the existing scanner_test.go in -// this package uses raw byte arrays to build a synthetic pgtype.UUID. If -// the convention changes, mirror whatever helper that test uses. -type pgxUUID struct { - UUID interface { - // satisfied by pgtype.UUID - } -} - -func (u *pgxUUID) Set(b [16]byte) { - // Replace this body with whatever the existing tests use to construct - // a pgtype.UUID from raw bytes. If unsure, copy from - // internal/lidarrrequests/service_test.go's TestApprove_NotFound. - panic("replace with the project's pgtype.UUID construction helper") -} -``` - -The `pgxUUID` shim above is a placeholder — when implementing, look at `internal/lidarrrequests/service_test.go:TestApprove_NotFound` which constructs a synthetic UUID with `bogus.Bytes = [16]byte{...}; bogus.Valid = true`. Use that pattern instead. - -- [ ] **Step 3.3: Run tests** - -```bash -docker compose up -d postgres -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/library/... -run TestDeleteTrackFile -``` - -Expected: all three subtests pass. - -- [ ] **Step 3.4: Commit** - -```bash -git add internal/library/delete.go internal/library/delete_test.go -git commit -m "feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved)" -``` - ---- - -### Task 4 — `lidarrquarantine.Service` — Flag/Unflag/ListMine/ListAdminQueue - -**Files:** -- Create: `internal/lidarrquarantine/service.go` -- Create: `internal/lidarrquarantine/service_test.go` - -Read the M5a `internal/lidarrrequests/service.go` first — it's the closest analog. Same shape (`Service` struct, factory function, integration tests gated on `MINSTREL_TEST_DATABASE_URL`, `dbtest.ResetDB` for isolation). Mirror it. - -- [ ] **Step 4.1: Write the package skeleton + read paths** - -`internal/lidarrquarantine/service.go`: - -```go -// Package lidarrquarantine owns the per-user track quarantine workflow. -// Users flag a track as broken (Flag/Unflag), the SPA hides the track -// from their views, and admins resolve the resulting reports via the -// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). -package lidarrquarantine - -import ( - "context" - "errors" - "fmt" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" -) - -// Public errors. Handlers map these to API codes. -var ( - ErrBadReason = errors.New("lidarrquarantine: invalid reason") - ErrTrackNotFound = errors.New("lidarrquarantine: track not found") - ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") - ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") - ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") - ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") -) - -// Service is the lifecycle owner. clientFn is a per-call factory so config -// changes in lidarrconfig take effect immediately. clientFn returns nil -// when Lidarr is disabled. -type Service struct { - pool *pgxpool.Pool - lidarrCfg *lidarrconfig.Service - clientFn func() *lidarr.Client -} - -func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service { - if clientFn == nil { - clientFn = func() *lidarr.Client { return nil } - } - return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} -} - -// Flag inserts or updates a quarantine row for the caller. Re-flagging -// the same (user, track) overwrites reason+notes. -func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { - if !validReason(reason) { - return dbq.LidarrQuarantine{}, ErrBadReason - } - var notesPtr *string - if notes != "" { - notesPtr = ¬es - } - row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ - UserID: userID, - TrackID: trackID, - Reason: dbq.LidarrQuarantineReason(reason), - Notes: notesPtr, - }) - if err != nil { - // ON CONFLICT path can't trip ErrNoRows; only an FK violation does - // (track_id doesn't exist). Surface that as ErrTrackNotFound. - return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) - } - return row, nil -} - -// Unflag removes the caller's row. Returns ErrQuarantineNotFound if -// no row exists. -func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { - _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ - UserID: userID, TrackID: trackID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrQuarantineNotFound - } - return fmt.Errorf("delete: %w", err) - } - return nil -} - -// ListMine returns the caller's quarantines with track/album/artist -// detail. Drives /library/hidden. -func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { - return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) -} - -// AdminQueueRow is the assembled aggregated row served by the admin -// queue endpoint. The handler post-processes the SQL results to attach -// reason_counts and per-user reports. -type AdminQueueRow struct { - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle *string - AlbumID pgtype.UUID - LidarrAlbumMBID *string - ReportCount int32 - LatestAt pgtype.Timestamptz - ReasonCounts map[string]int - Reports []UserReport -} - -type UserReport struct { - UserID pgtype.UUID - Username string - Reason string - Notes *string - CreatedAt pgtype.Timestamptz -} - -// ListAdminQueue returns the aggregated admin queue. One row per track. -func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { - q := dbq.New(s.pool) - aggregated, err := q.ListAdminQuarantineQueue(ctx) - if err != nil { - return nil, fmt.Errorf("aggregate: %w", err) - } - out := make([]AdminQueueRow, 0, len(aggregated)) - for _, r := range aggregated { - reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) - if err != nil { - return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) - } - rc := make(map[string]int, len(reports)) - userReports := make([]UserReport, 0, len(reports)) - for _, rep := range reports { - rc[string(rep.Reason)]++ - userReports = append(userReports, UserReport{ - UserID: rep.UserID, - Username: rep.Username, - Reason: string(rep.Reason), - Notes: rep.Notes, - CreatedAt: rep.CreatedAt, - }) - } - out = append(out, AdminQueueRow{ - TrackID: r.TrackID, - TrackTitle: r.TrackTitle, - ArtistName: r.ArtistName, - AlbumTitle: r.AlbumTitle, - AlbumID: r.AlbumID, - LidarrAlbumMBID: r.LidarrAlbumMbid, - ReportCount: r.ReportCount, - LatestAt: r.LatestAt, - ReasonCounts: rc, - Reports: userReports, - }) - } - return out, nil -} - -func validReason(r string) bool { - switch r { - case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": - return true - } - return false -} -``` - -(Admin actions Resolve / DeleteFile / DeleteViaLidarr land in Task 5.) - -- [ ] **Step 4.2: Write the integration tests for the read paths** - -`internal/lidarrquarantine/service_test.go`: - -```go -package lidarrquarantine - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - if _, err := pool.Exec(context.Background(), - "DELETE FROM lidarr_quarantine; DELETE FROM lidarr_quarantine_actions;"); err != nil { - t.Fatalf("reset quarantine tables: %v", err) - } - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user %s: %v", name, err) - } - return u -} - -func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) { - t.Helper() - q := dbq.New(pool) - artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "Test Artist", SortName: "Test Artist", - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - albumMBID := mbid + "-album" - album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Test Album", SortTitle: "Test Album", - ArtistID: artist.ID, Mbid: &albumMBID, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"), - FileSize: 100, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return track, album, artist -} - -func TestFlag_HappyPath(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "Bad Track", "abc") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") - if err != nil { - t.Fatalf("Flag: %v", err) - } - if string(row.Reason) != "bad_rip" { - t.Errorf("reason = %v", row.Reason) - } - if row.Notes == nil || *row.Notes != "crackly" { - t.Errorf("notes = %v", row.Notes) - } -} - -func TestFlag_UpsertOnSecondFlag(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { - t.Fatalf("first flag: %v", err) - } - row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "") - if err != nil { - t.Fatalf("second flag: %v", err) - } - if string(row.Reason) != "wrong_tags" { - t.Errorf("reason = %v", row.Reason) - } - if row.Notes != nil { - t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes) - } -} - -func TestFlag_BadReasonRejected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "") - if !errors.Is(err, ErrBadReason) { - t.Errorf("err = %v, want ErrBadReason", err) - } -} - -func TestUnflag_DeletesRow(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil { - t.Fatalf("Unflag: %v", err) - } - if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) { - t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err) - } -} - -func TestListMine_OrderedNewestFirst(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - t1, _, _ := seedTrack(t, pool, "T1", "x") - t2, _, _ := seedTrack(t, pool, "T2", "y") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", "") - - rows, err := svc.ListMine(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListMine: %v", err) - } - if len(rows) != 2 { - t.Fatalf("len = %d, want 2", len(rows)) - } - // T2 was flagged second — newest first. - if rows[0].LidarrQuarantine.TrackID != t2.ID { - t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) - } -} - -func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { - pool := newPool(t) - alice := seedUser(t, pool, "alice") - bob := seedUser(t, pool, "bob") - carol := seedUser(t, pool, "carol") - track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", "") - - rows, err := svc.ListAdminQueue(context.Background()) - if err != nil { - t.Fatalf("ListAdminQueue: %v", err) - } - if len(rows) != 1 { - t.Fatalf("len = %d, want 1 aggregated row", len(rows)) - } - r := rows[0] - if r.ReportCount != 3 { - t.Errorf("report_count = %d, want 3", r.ReportCount) - } - if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { - t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts) - } - if len(r.Reports) != 3 { - t.Errorf("reports len = %d, want 3", len(r.Reports)) - } -} -``` - -- [ ] **Step 4.3: Run the tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrquarantine/... -``` - -Expected: all green. - -- [ ] **Step 4.4: Commit** - -```bash -git add internal/lidarrquarantine/ -git commit -m "feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue" -``` - ---- - -### Task 5 — `lidarrquarantine.Service` admin actions - -**Files:** -- Modify: `internal/lidarrquarantine/service.go` — append Resolve / DeleteFile / DeleteViaLidarr -- Modify: `internal/lidarrquarantine/service_test.go` — append admin-action tests - -The three admin actions all follow the same shape: -1. Read the track (and parent album for DeleteViaLidarr) for snapshot fields. -2. Capture `affected_users` count via `CountQuarantineForTrack` *before* deleting. -3. For DeleteFile: call `library.DeleteTrackFile`; for DeleteViaLidarr: lookup album in Lidarr, call `Client.DeleteAlbum`, delete all Minstrel tracks in that album. -4. Delete `lidarr_quarantine` rows for the affected tracks. -5. Write a `lidarr_quarantine_actions` audit row. - -Order matters: Lidarr/file delete first, then DB writes. Failure of the external call leaves the per-user rows intact for retry. **No partial state.** - -- [ ] **Step 5.1: Append `Resolve` to `service.go`** - -```go -// Resolve clears all per-user quarantine rows for a track and writes an -// audit log row. Idempotent — a track with no rows still writes an audit -// entry with affected_users=0 (so admin can see "I clicked resolve on a -// track that already had no reports"). -func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) - } - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionResolved, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} - -// snapshot is shared scaffolding — pulls album/artist titles for the audit row. -type quarantineSnapshot struct { - TrackTitle string - ArtistName string - AlbumTitle *string - LidarrAlbumMBID *string -} - -func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { - album, err := q.GetAlbumByID(ctx, track.AlbumID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) - } - artist, err := q.GetArtistByID(ctx, track.ArtistID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) - } - return quarantineSnapshot{ - TrackTitle: track.Title, - ArtistName: artist.Name, - AlbumTitle: &album.Title, - LidarrAlbumMBID: album.Mbid, - }, nil -} -``` - -If `dbq.GetAlbumByID` / `dbq.GetArtistByID` don't exist as named queries, check the existing albums.sql / artists.sql files — they almost certainly do under different names (`AlbumByID`, `ArtistByID`, etc.) — and substitute the actual names. - -- [ ] **Step 5.2: Append `DeleteFile`** - -```go -// DeleteFile removes the track file from disk and the tracks row, then -// clears all per-user quarantine rows for that track and writes an audit -// row. If the file deletion fails, the per-user rows stay so admin can -// retry. No partial state. -func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - - if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) - } - // tracks row is gone; the FK ON DELETE CASCADE on lidarr_quarantine - // already cleared the per-user rows. - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedFile, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} -``` - -Note the cascade comment: the schema has `ON DELETE CASCADE` on `lidarr_quarantine.track_id`, so when `library.DeleteTrackFile` runs `DELETE FROM tracks WHERE id = $1`, the per-user quarantine rows go too. We don't call `DeleteQuarantineForTrack` separately. **Verify this assumption holds when implementing** — re-check `0011_lidarr_quarantine.up.sql` and the existing `tracks` constraints. - -- [ ] **Step 5.3: Append `DeleteViaLidarr`** - -```go -// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove -// the parent album with deleteFiles=true + addImportListExclusion=true, -// then removes Minstrel rows for all tracks of that album. The cascade -// on lidarr_quarantine clears per-user rows automatically. -// -// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing -// changes locally. Admin retries. -func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { - cfg, err := s.lidarrCfg.Get(ctx) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) - } - client := s.clientFn() - if !cfg.Enabled || client == nil { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled - } - - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, err - } - if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { - return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) - } - - // Look up the album in Lidarr to translate MBID -> Lidarr internal ID. - album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) - if err != nil { - if errors.Is(err, lidarr.ErrNotFound) { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) - } - - // Lidarr DELETE — both flags true. - if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) - } - - // Now remove the local rows. Cascade handles per-user quarantine - // rows via the FK on lidarr_quarantine.track_id. - res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) - if err != nil { - // We deleted in Lidarr but failed in our DB. Operator-recoverable - // by re-running. Audit row will reflect the eventual state. - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) - } - deletedCount := int(res.RowsAffected()) - - // Album/artist rows stay; if the operator wants those gone too they - // can be cleaned up by a future scan or a manual SQL pass. - - action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedViaLidarr, - AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, - AffectedUsers: affected, - }) - return action, deletedCount, err -} -``` - -- [ ] **Step 5.4: Append admin-action tests** - -`internal/lidarrquarantine/service_test.go` — append: - -```go -import ( - // ... add to existing imports: - "net/http" - "net/http/httptest" - - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" -) - -func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { - pool := newPool(t) - alice := seedUser(t, pool, "alice") - bob := seedUser(t, pool, "bob") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", "") - - audit, err := svc.Resolve(context.Background(), track.ID, alice.ID) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if audit.AffectedUsers != 2 { - t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) - } - if audit.Action != dbq.LidarrQuarantineActionResolved { - t.Errorf("action = %v, want resolved", audit.Action) - } - // No more rows for this track. - n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) - if n != 0 { - t.Errorf("rows after resolve = %d, want 0", n) - } -} - -func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - // Real on-disk file: - dir := t.TempDir() - path := filepath.Join(dir, "track.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID}) - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("DeleteFile: %v", err) - } - if audit.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) - } - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Errorf("file still exists: %v", err) - } -} - -func TestDeleteViaLidarr_FullCascade(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - // Stub Lidarr server. - var captured []string - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { - _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`)) - return - } - // DELETE /api/v1/album/42 -> 200. - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - - cfg := lidarrconfig.New(pool) - if err := cfg.Save(context.Background(), lidarrconfig.Config{ - Enabled: true, BaseURL: stub.URL, APIKey: "k", - }); err != nil { - t.Fatalf("save config: %v", err) - } - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn) - - // Seed a track on an album whose mbid we'll match in the stub. - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - albumMBID := "al-mbid" - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, - }) - dir := t.TempDir() - path := filepath.Join(dir, "T.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("DeleteViaLidarr: %v", err) - } - if deleted != 1 { - t.Errorf("deleted = %d, want 1 track removed", deleted) - } - if audit.Action != dbq.LidarrQuarantineActionDeletedViaLidarr { - t.Errorf("action = %v", audit.Action) - } - if audit.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) - } - if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { - t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) - } - // Track row is gone (and so is the per-user quarantine row, via cascade). - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } - // Verify Lidarr was called with both flags true. - foundDelete := false - for _, c := range captured { - if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || - c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { - foundDelete = true - } - } - if !foundDelete { - t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) - } -} - -func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if !errors.Is(err, ErrLidarrDisabled) { - t.Errorf("err = %v, want ErrLidarrDisabled", err) - } -} -``` - -- [ ] **Step 5.5: Run + commit** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrquarantine/... -git add internal/lidarrquarantine/ -git commit -m "feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr" -``` - ---- - -### Task 6 — Soft-hide query updates - -**Files:** -- Modify: `internal/db/queries/tracks.sql` — add `*ForUser` variants -- Modify: `internal/db/queries/recommendation.sql` — extend existing radio queries -- Regenerate: `internal/db/dbq/` - -The four affected queries are `ListTracksByAlbum`, `SearchTracks`, `CountTracksMatching`, and the radio loaders. The album/artist views are read through the existing handlers — adding `*ForUser` variants that take `user_id` lets the handlers route based on auth context. - -- [ ] **Step 6.1: Add `ListTracksByAlbumForUser` to `tracks.sql`** - -Append to `internal/db/queries/tracks.sql`: - -```sql --- name: ListTracksByAlbumForUser :many --- Same as ListTracksByAlbum but excludes tracks the user has quarantined. -SELECT * FROM tracks -WHERE album_id = $1 - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ) -ORDER BY disc_number NULLS LAST, track_number NULLS LAST; - --- name: SearchTracksForUser :many -SELECT * FROM tracks -WHERE title ILIKE '%' || $1 || '%' - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ) -ORDER BY title -LIMIT $3 OFFSET $4; - --- name: CountTracksMatchingForUser :one -SELECT COUNT(*) FROM tracks -WHERE title ILIKE '%' || $1::text || '%' - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ); -``` - -- [ ] **Step 6.2: Extend the radio loaders** - -Modify `internal/db/queries/recommendation.sql`. For each `LoadRadioCandidates*` query, add a quarantine clause to the `WHERE` block. The user_id is already a parameter on these queries (`$1`); the additional clause is: - -```sql - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ) -``` - -For `LoadRadioCandidates`: - -```sql -WHERE t.id <> $2 - AND NOT EXISTS ( - SELECT 1 FROM play_events - WHERE user_id = $1 AND track_id = t.id - AND started_at > now() - $3 * interval '1 hour' - ) - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ); -``` - -For `LoadRadioCandidatesV2`, add the same clause inside the final `WHERE` of the union output (look for the comment "5-way UNION" — add the clause to the outer WHERE that filters the unioned candidates by `excluded_ids` etc.). - -- [ ] **Step 6.3: Regenerate sqlc + build** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: clean build. New methods `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` appear in `internal/db/dbq/tracks.sql.go`. - -- [ ] **Step 6.4: Commit** - -```bash -git add internal/db/queries/ internal/db/dbq/ -git commit -m "feat(db): add user-context track query variants honoring quarantine" -``` - ---- - -### Task 7 — Wire soft-hide into existing read handlers - -**Files:** Modify existing handlers under `internal/api/` to call the `*ForUser` queries when an authenticated user is in context. - -The pattern: where the handler currently calls (e.g.) `q.ListTracksByAlbum(ctx, albumID)`, switch to `q.ListTracksByAlbumForUser(ctx, dbq.ListTracksByAlbumForUserParams{AlbumID: albumID, UserID: user.ID})` when `user, ok := auth.UserFromContext(r.Context()); ok` is true. The `else` branch keeps the unfiltered query for any path without a user context (Subsonic, internal callers). - -- [ ] **Step 7.1: Identify call sites** - -Run: - -```bash -grep -rn "ListTracksByAlbum\|SearchTracks\|CountTracksMatching\|LoadRadioCandidates" \ - internal/api/ internal/subsonic/ -``` - -For every call in `internal/api/`, branch on `auth.UserFromContext`. For every call in `internal/subsonic/`, leave it alone (Subsonic is `/rest/*` and doesn't honor quarantine per the legacy memory). - -- [ ] **Step 7.2: Pattern to apply** - -Example for the album-detail handler: - -```go -func (h *handlers) handleAlbumDetail(w http.ResponseWriter, r *http.Request) { - // ... existing parse + lookup ... - var tracks []dbq.Track - if user, ok := auth.UserFromContext(r.Context()); ok { - tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{ - AlbumID: albumID, UserID: user.ID, - }) - } else { - tracks, err = q.ListTracksByAlbum(r.Context(), albumID) - } - // ... existing error + response ... -} -``` - -Repeat for search and radio handlers. The radio handlers already take `user_id` for personalization — the schema change in Task 6 just extended their existing query, so those handlers don't need restructuring. - -- [ ] **Step 7.3: Regression-test the existing endpoints** - -```bash -go test ./internal/api/... -count=1 -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./internal/api/... -``` - -Expected: existing tests still pass — they don't seed any quarantine rows, so the filter is a no-op. - -- [ ] **Step 7.4: Commit** - -```bash -git add internal/api/ -git commit -m "feat(api): route track-list reads through user-context quarantine filter" -``` - ---- - -### Task 8 — `/api/quarantine/*` user-facing handlers - -**Files:** -- Create: `internal/api/quarantine.go` -- Create: `internal/api/quarantine_test.go` -- Modify: `internal/api/api.go` to mount the new routes - -Three endpoints: `POST /api/quarantine`, `DELETE /api/quarantine/:track_id`, `GET /api/quarantine/mine`. Mirror M5a's `internal/api/requests.go` for the auth + writeJSON conventions. - -- [ ] **Step 8.1: Write `quarantine.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" -) - -type quarantineView struct { - UserID pgtype.UUID `json:"user_id"` - TrackID pgtype.UUID `json:"track_id"` - Reason string `json:"reason"` - Notes *string `json:"notes,omitempty"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView { - return quarantineView{ - UserID: row.UserID, TrackID: row.TrackID, - Reason: string(row.Reason), Notes: row.Notes, CreatedAt: row.CreatedAt, - } -} - -type flagBody struct { - TrackID pgtype.UUID `json:"track_id"` - Reason string `json:"reason"` - Notes string `json:"notes"` -} - -func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - var body flagBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") - return - } - row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, body.TrackID, body.Reason, body.Notes) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrBadReason): - writeErr(w, http.StatusBadRequest, "bad_reason", err.Error()) - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist") - default: - h.logger.Error("api: flag", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "flag failed") - } - return - } - writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) -} - -func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil { - if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) { - writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track") - return - } - h.logger.Error("api: unflag", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -// quarantineMineView wraps the joined row for /api/quarantine/mine. The -// SPA on /library/hidden needs the full track + album + artist payload. -type quarantineMineView struct { - quarantineView - Track dbq.Track `json:"track"` - Album dbq.Album `json:"album"` - Artist dbq.Artist `json:"artist"` -} - -func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: list mine", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - out := make([]quarantineMineView, 0, len(rows)) - for _, row := range rows { - out = append(out, quarantineMineView{ - quarantineView: quarantineViewFrom(row.LidarrQuarantine), - Track: row.Track, - Album: row.Album, - Artist: row.Artist, - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -- [ ] **Step 8.2: Mount routes in `api.go`** - -Inside the existing authenticated route group, add: - -```go -r.Post("/api/quarantine", h.handleFlag) -r.Delete("/api/quarantine/{track_id}", h.handleUnflag) -r.Get("/api/quarantine/mine", h.handleListMyQuarantine) -``` - -- [ ] **Step 8.3: Add `lidarrQuarantine` to the handlers struct** - -Find the `handlers` struct (in `internal/api/api.go` or wherever it lives) and add: - -```go -lidarrQuarantine *lidarrquarantine.Service -``` - -Then update the constructor / wiring to accept it. - -- [ ] **Step 8.4: Write tests** - -`internal/api/quarantine_test.go` mirrors the M5a `requests_test.go` shape: stub handlers, real DB via `MINSTREL_TEST_DATABASE_URL`, table-driven scenarios. Cover: -- Flag with valid reason → 201, row visible in `ListMine`. -- Flag with `bad_reason` → 400, `error.code === "bad_reason"`. -- Flag for a track that doesn't exist → 404 `track_not_found`. -- Unflag happy path → 204. -- Unflag for a row that doesn't exist → 404 `quarantine_not_found`. -- ListMine with two flags → returns two rows, newest first. -- Unauthenticated requests → 401 across the board (the existing `RequireUser` middleware handles this; one assertion is enough). - -- [ ] **Step 8.5: Commit** - -```bash -go test ./internal/api/... -run TestFlag -count=1 -git add internal/api/quarantine.go internal/api/quarantine_test.go internal/api/api.go -git commit -m "feat(api): /api/quarantine user-facing CRUD" -``` - ---- - -### Task 9 — `/api/admin/quarantine/*` admin handlers - -**Files:** -- Create: `internal/api/admin_quarantine.go` -- Create: `internal/api/admin_quarantine_test.go` -- Modify: `internal/api/api.go` to mount under the existing `/api/admin/*` route group - -Five endpoints: GET queue, GET actions, three POST resolution actions. Reuse the `RequireAdmin` middleware from M5a. - -- [ ] **Step 9.1: Write `admin_quarantine.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - - "github.com/go-chi/chi/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" -) - -// adminQueueRowView is the wire shape returned by GET /api/admin/quarantine. -type adminQueueRowView struct { - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle *string `json:"album_title,omitempty"` - AlbumID string `json:"album_id"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - ReportCount int32 `json:"report_count"` - LatestAt string `json:"latest_at"` - ReasonCounts map[string]int `json:"reason_counts"` - Reports []adminQueueReportView `json:"reports"` -} - -type adminQueueReportView struct { - UserID string `json:"user_id"` - Username string `json:"username"` - Reason string `json:"reason"` - Notes *string `json:"notes,omitempty"` - CreatedAt string `json:"created_at"` -} - -func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { - rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) - if err != nil { - h.logger.Error("admin: list quarantine", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - out := make([]adminQueueRowView, 0, len(rows)) - for _, r := range rows { - reports := make([]adminQueueReportView, 0, len(r.Reports)) - for _, rep := range r.Reports { - reports = append(reports, adminQueueReportView{ - UserID: uuidToString(rep.UserID), - Username: rep.Username, - Reason: rep.Reason, - Notes: rep.Notes, - CreatedAt: rep.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), - }) - } - out = append(out, adminQueueRowView{ - TrackID: uuidToString(r.TrackID), TrackTitle: r.TrackTitle, - ArtistName: r.ArtistName, AlbumTitle: r.AlbumTitle, - AlbumID: uuidToString(r.AlbumID), LidarrAlbumMBID: r.LidarrAlbumMBID, - ReportCount: r.ReportCount, - LatestAt: r.LatestAt.Time.Format("2006-01-02T15:04:05Z07:00"), - ReasonCounts: r.ReasonCounts, Reports: reports, - }) - } - writeJSON(w, http.StatusOK, out) -} - -type actionResultView struct { - ActionID string `json:"action_id"` - AffectedUsers int32 `json:"affected_users"` - DeletedTrackCount *int `json:"deleted_track_count,omitempty"` -} - -func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) - if err != nil { - if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - return - } - h.logger.Error("admin: resolve quarantine", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - }) -} - -func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - default: - h.logger.Error("admin: delete file", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed") - } - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - }) -} - -func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): - writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") - case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): - writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") - case errors.Is(err, lidarr.ErrUnreachable): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") - case errors.Is(err, lidarr.ErrAuthFailed): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") - default: - h.logger.Error("admin: delete via lidarr", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - } - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - DeletedTrackCount: &deleted, - }) -} - -type actionLogView struct { - ID string `json:"id"` - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle *string `json:"album_title,omitempty"` - Action string `json:"action"` - AdminID *string `json:"admin_id,omitempty"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - AffectedUsers int32 `json:"affected_users"` - CreatedAt string `json:"created_at"` -} - -func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) { - limitStr := r.URL.Query().Get("limit") - limit := int32(50) - if limitStr != "" { - if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 200 { - limit = int32(v) - } - } - rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit) - if err != nil { - h.logger.Error("admin: list actions", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - out := make([]actionLogView, 0, len(rows)) - for _, row := range rows { - var adminID *string - if row.AdminID.Valid { - s := uuidToString(row.AdminID) - adminID = &s - } - out = append(out, actionLogView{ - ID: uuidToString(row.ID), TrackID: uuidToString(row.TrackID), - TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, - Action: string(row.Action), AdminID: adminID, - LidarrAlbumMBID: row.LidarrAlbumMbid, AffectedUsers: row.AffectedUsers, - CreatedAt: row.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -`uuidToString` and `parseUUID` are existing helpers in `internal/api/`. Reuse them. - -- [ ] **Step 9.2: Mount routes** - -Inside the `/api/admin` route group: - -```go -r.Get("/api/admin/quarantine", h.handleListAdminQuarantine) -r.Post("/api/admin/quarantine/{track_id}/resolve", h.handleResolveQuarantine) -r.Post("/api/admin/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) -r.Post("/api/admin/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) -r.Get("/api/admin/quarantine/actions", h.handleListQuarantineActions) -``` - -- [ ] **Step 9.3: Tests (`internal/api/admin_quarantine_test.go`)** - -Mirror M5a's `internal/api/admin_requests_test.go`. Cover: -- Aggregated queue shape: 3 users × 1 track yields 1 row with `report_count=3`, correct `reason_counts`. -- Resolve clears rows, returns 200 with `affected_users`. -- Delete file: file vanishes from disk, row gone, audit row written. -- Delete via Lidarr with stub server: lookup → DELETE → row removed; happy path 200 with `deleted_track_count`. -- Delete via Lidarr with `lidarr_disabled` config → 503 `lidarr_disabled`. -- Delete via Lidarr with stub returning empty array on lookup → 502 `lidarr_album_lookup_failed`. -- Delete via Lidarr on a track with no album MBID → 404 `album_mbid_missing`. -- Non-admin user → 403 across all admin endpoints. -- Action log GET returns rows ordered newest-first. - -- [ ] **Step 9.4: Commit** - -```bash -docker run --rm --network minstrel_minstrel -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm go test -race -p 1 ./internal/api/... -run AdminQuarantine -git add internal/api/admin_quarantine.go internal/api/admin_quarantine_test.go internal/api/api.go -git commit -m "feat(api): /api/admin/quarantine queue + resolve/delete-file/delete-via-lidarr" -``` - ---- - -### Task 10 — Wire `Service` into `cmd/minstrel/main.go` - -**Files:** Modify `cmd/minstrel/main.go`. - -The handlers struct (Task 8 step 8.3) now expects a `*lidarrquarantine.Service`. Construct it at startup and pass it through. - -- [ ] **Step 10.1: Add the construction** - -Find where the M5a `lidarrrequests.Service` is constructed in `main.go`. Right after it, add: - -```go -quarSvc := lidarrquarantine.NewService(pool, lidarrCfg, func() *lidarr.Client { - cfg, err := lidarrCfg.Get(ctx) - if err != nil || !cfg.Enabled { - return nil - } - return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) -}) -``` - -If a similar `clientFn` already exists for `lidarrrequests`, reuse it instead of duplicating the closure. Pass `quarSvc` into the handlers constructor. - -- [ ] **Step 10.2: Build + smoke test** - -```bash -go build ./cmd/minstrel -go vet ./... -golangci-lint run ./... -``` - -Expected: clean build, no lint warnings. If `golangci-lint` isn't installed, skip. - -- [ ] **Step 10.3: Commit** - -```bash -git add cmd/minstrel/main.go internal/api/api.go -git commit -m "feat(cmd): wire lidarrquarantine.Service into the API handlers" -``` - ---- - -### Task 11 — Frontend: API client modules + types - -**Files:** Create `web/src/lib/api/quarantine.ts` + `quarantine.test.ts`. Modify `web/src/lib/api/types.ts`, `queries.ts`, `admin.ts`. Mirror the existing M5a pattern from `requests.ts` / `admin.ts`. - -- [ ] **Step 11.1: Add types to `types.ts`** - -```ts -export type LidarrQuarantineReason = 'bad_rip' | 'wrong_file' | 'wrong_tags' | 'duplicate' | 'other'; - -export type LidarrQuarantineRow = { - user_id: string; - track_id: string; - reason: LidarrQuarantineReason; - notes?: string | null; - created_at: string; -}; - -export type LidarrQuarantineMineRow = LidarrQuarantineRow & { - track: TrackRef; - album: AlbumRef; - artist: ArtistRef; -}; - -export type AdminQuarantineRow = { - track_id: string; - track_title: string; - artist_name: string; - album_title?: string | null; - album_id: string; - lidarr_album_mbid?: string | null; - report_count: number; - latest_at: string; - reason_counts: Record; - reports: AdminQuarantineReport[]; -}; - -export type AdminQuarantineReport = { - user_id: string; - username: string; - reason: LidarrQuarantineReason; - notes?: string | null; - created_at: string; -}; - -export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; - -export type LidarrQuarantineActionRow = { - id: string; - track_id: string; - track_title: string; - artist_name: string; - album_title?: string | null; - action: LidarrQuarantineAction; - admin_id?: string | null; - lidarr_album_mbid?: string | null; - affected_users: number; - created_at: string; -}; - -export type ActionResult = { - action_id: string; - affected_users: number; - deleted_track_count?: number; -}; -``` - -- [ ] **Step 11.2: Add query keys to `queries.ts`** - -```ts -qk.myQuarantine = () => ['myQuarantine'] as const; -qk.adminQuarantine = () => ['adminQuarantine'] as const; -qk.adminQuarantineActions = (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const; -``` - -(Keep the existing `qk` object syntax — append the new functions.) - -- [ ] **Step 11.3: Write `quarantine.ts`** - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api, apiFetch } from './client'; -import { qk } from './queries'; -import type { - LidarrQuarantineRow, - LidarrQuarantineMineRow, - LidarrQuarantineReason -} from './types'; - -export type FlagParams = { - track_id: string; - reason: LidarrQuarantineReason; - notes?: string; -}; - -export async function flagTrack(params: FlagParams): Promise { - const body: FlagParams = { track_id: params.track_id, reason: params.reason }; - if (params.notes && params.notes.length > 0) body.notes = params.notes; - return api.post('/api/quarantine', body); -} - -// Server returns 204 (no body) for DELETE; handle accordingly. -export async function unflagTrack(trackID: string): Promise { - await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); -} - -export async function listMyQuarantine(): Promise { - return api.get('/api/quarantine/mine'); -} - -export function createMyQuarantineQuery() { - return createQuery({ - queryKey: qk.myQuarantine(), - queryFn: listMyQuarantine, - staleTime: 60_000 - }); -} -``` - -- [ ] **Step 11.4: Append admin endpoints to `admin.ts`** - -```ts -import type { AdminQuarantineRow, ActionResult, LidarrQuarantineActionRow } from './types'; - -export async function listAdminQuarantine(): Promise { - return api.get('/api/admin/quarantine'); -} - -export async function resolveQuarantine(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/resolve`, {}); -} - -export async function deleteQuarantineFile(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/delete-file`, {}); -} - -export async function deleteQuarantineViaLidarr(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); -} - -export async function listQuarantineActions(limit = 50): Promise { - return api.get(`/api/admin/quarantine/actions?limit=${limit}`); -} - -export function createAdminQuarantineQuery() { - return createQuery({ - queryKey: qk.adminQuarantine(), - queryFn: listAdminQuarantine, - staleTime: 30_000 // queue should refresh more aggressively than my-history - }); -} - -export function createQuarantineActionsQuery(limit = 50) { - return createQuery({ - queryKey: qk.adminQuarantineActions(limit), - queryFn: () => listQuarantineActions(limit), - staleTime: 60_000 - }); -} -``` - -- [ ] **Step 11.5: Tests** - -`quarantine.test.ts` — mirror `requests.test.ts` shape: `vi.mock('./client')`, assert URL + body shapes, return-value flow-through. Cover flagTrack (with + without notes), unflagTrack, listMyQuarantine, query factory, qk shape. - -For `admin.ts`: extend the existing test file to cover the five new functions + their query factories. - -- [ ] **Step 11.6: Run + commit** - -```bash -cd web && npm run check && npm test -- --run && cd - -git add web/src/lib/api/ -git commit -m "feat(web): API client modules for quarantine + admin quarantine" -``` - ---- - -### Task 12 — Frontend: `` + `` components - -**Files:** Create `web/src/lib/components/TrackMenu.svelte`, `TrackMenu.test.ts`, `FlagPopover.svelte`, `FlagPopover.test.ts`. - -`` is a kebab button + dropdown menu. For M5b it has one item: "Flag this track…". Component is structured so future actions slot in alongside. - -`` is the reason form, opened from TrackMenu. Pre-fills if the user already has a quarantine on this track. - -- [ ] **Step 12.1: Write `TrackMenu.svelte`** - -```svelte - - - (menuOpen = false)} - onkeydown={(e) => e.key === 'Escape' && closeAll()} -/> - -
- - - {#if menuOpen} - - {/if} - - {#if popoverOpen} - - {/if} -
-``` - -- [ ] **Step 12.2: Write `FlagPopover.svelte`** - -```svelte - - - - -``` - -- [ ] **Step 12.3: Tests** - -`TrackMenu.test.ts`: -- Click kebab → menu visible. -- Click outside → menu closes. -- Escape → menu closes. -- Click "Flag this track…" → popover opens. - -`FlagPopover.test.ts`: -- Defaults to reason=`bad_rip` when no initialReason. -- Pre-fills when initialReason+initialNotes are provided; button reads "Update flag". -- Submit calls `flagTrack` (mocked) with the typed reason + non-empty notes; empty notes are NOT sent. -- Cancel calls onClose; does not call flagTrack. -- Submit calls `invalidateQueries` on success. - -For `flagTrack` mock pattern, copy from `web/src/routes/admin/integrations/integrations.test.ts` (the `vi.mock('$lib/api/admin', ...)` shape). - -- [ ] **Step 12.4: Run + commit** - -```bash -cd web && npm run check && npm test -- --run TrackMenu FlagPopover && cd - -git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts \ - web/src/lib/components/FlagPopover.svelte web/src/lib/components/FlagPopover.test.ts -git commit -m "feat(web): TrackMenu overflow + FlagPopover for the quarantine flow" -``` - ---- - -### Task 13 — Mount `` in `TrackRow` + `PlayerBar` - -**Files:** Modify `TrackRow.svelte`, `TrackRow.test.ts`, `PlayerBar.svelte`, `PlayerBar.test.ts`. - -- [ ] **Step 13.1: TrackRow** - -Add `` as a sibling to `` in the row's right cluster: - -```svelte - - - - - -``` - -- [ ] **Step 13.2: PlayerBar** - -Same: add the `` to the right cluster, after the like button. The `track` prop is the currently-playing track from the player store (e.g. `player.current`). - -```svelte -{#if player.current} - - -{/if} -``` - -- [ ] **Step 13.3: Update tests** - -Both `TrackRow.test.ts` and `PlayerBar.test.ts` — add an assertion that the track-actions kebab is rendered. Existing tests (like-button presence, play-on-click) should still pass. - -- [ ] **Step 13.4: Run + commit** - -```bash -cd web && npm test -- --run TrackRow PlayerBar && cd - -git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts \ - web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts -git commit -m "feat(web): mount TrackMenu in TrackRow + PlayerBar" -``` - ---- - -### Task 14 — Frontend: `/library/hidden` route - -**Files:** Create `web/src/routes/library/hidden/+page.svelte` + `hidden.test.ts`. Modify `Shell.svelte` to add `Hidden` to the main nav (between `Liked` and `Search`). - -- [ ] **Step 14.1: Write the page** - -Pattern matches `/requests` exactly — same row anatomy. Use `createMyQuarantineQuery()` for data, `unflagTrack(trackID)` + `invalidateQueries(qk.myQuarantine())` for the un-hide affordance. Empty state copy: "Nothing hidden yet." - -Each row (mirroring `/requests`): -- 56px album art with Lucide `Music2` fallback. -- Pills: kind ("Track", accent-tint), reason (`bad_rip` etc., accent-tint). -- Title (Parchment) + meta line "by `` · `` · flagged 2d ago". -- Notes (Vellum, italic) — only when present. -- Action: Un-hide (Pewter ghost + Lucide `RotateCcw`). One click — no confirmation. Optimistic remove. - -Header: H2 "Hidden" (Fraunces 24/500) + subtitle "Tracks you've flagged as broken." - -- [ ] **Step 14.2: Update Shell** - -Add to `navItems`: - -```ts -{ href: '/library/hidden', label: 'Hidden' } -``` - -Position: after `Liked`, before `Search`. Update `Shell.test.ts` to assert the new link's order. - -- [ ] **Step 14.3: Tests** - -`hidden.test.ts`: -- Renders one row per quarantine. -- Un-hide click calls `unflagTrack` + invalidates query. -- Empty state shows "Nothing hidden yet." -- Notes render (italic) when present, absent otherwise. - -- [ ] **Step 14.4: Commit** - -```bash -cd web && npm run check && npm test -- --run hidden && cd - -git add web/src/routes/library/hidden/ web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts -git commit -m "feat(web): /library/hidden user-facing quarantine view" -``` - ---- - -### Task 15 — Frontend: `/admin/quarantine` route + sidebar promotion - -**Files:** Create `web/src/routes/admin/quarantine/+page.svelte` + `quarantine.test.ts`. Modify `AdminSidebar.svelte` (promote Quarantine from placeholder), `AdminSidebar.test.ts`. - -- [ ] **Step 15.1: Promote sidebar item** - -In `AdminSidebar.svelte`, change: - -```diff --{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true } -+{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX } -``` - -Update `AdminSidebar.test.ts`: -- Replace the placeholder-treatment assertion for Quarantine. -- Add an assertion that Quarantine renders as a real `` link. -- Add an assertion that `/admin/quarantine` activates Quarantine in the sidebar. - -- [ ] **Step 15.2: Write the page** - -Page layout (matches the design-system spec from §6 of the spec): - -- Header: H2 "Quarantine" + accent-tint count pill when `report_count > 0`. -- Empty state: "Nothing to triage right now." -- Aggregated rows: 56px art · title + meta · reason-distribution pills · expandable per-user reports · inline play button (accent-colored — brand moment) · action cluster (Resolve / Delete file / Delete via Lidarr). -- Modal-confirm for Delete file. -- Typed-confirm modal for Delete via Lidarr (matches M5a Disconnect pattern; trim equality on "DELETE"). -- Dimmed Delete-via-Lidarr button when `lidarr_album_mbid` is null, with `title` attribute "Local-only track — no Lidarr album to remove." -- Lidarr-unreachable error → toast (reuse the `errorCopy` helper pattern from `/admin/requests`). - -Use `createAdminQuarantineQuery()` for data. On mutation success, `invalidateQueries({ queryKey: qk.adminQuarantine() })`. - -Use the existing player's `enqueueTrack` (or `playRadio` — pick the closest-fit existing action) for the inline Play button. - -- [ ] **Step 15.3: Tests (`quarantine.test.ts`)** - -Cover: -- Aggregated rows render with reason distribution. -- Expand caret reveals per-user reports. -- Resolve fires `resolveQuarantine` + invalidates. -- Delete file → modal-confirm → fires `deleteQuarantineFile`. -- Delete via Lidarr → typed-confirm modal "DELETE" → fires `deleteQuarantineViaLidarr`. -- Lidarr-unreachable error → toast renders with the spec copy ("Lidarr is unreachable…"). -- Dimmed Delete-via-Lidarr when MBID missing. -- Inline play button calls the player. -- Empty state copy. - -- [ ] **Step 15.4: Commit** - -```bash -cd web && npm run check && npm test -- --run admin/quarantine && cd - -git add web/src/routes/admin/quarantine/ web/src/lib/components/AdminSidebar.svelte web/src/lib/components/AdminSidebar.test.ts -git commit -m "feat(web): /admin/quarantine aggregated queue with resolution actions" -``` - ---- - -### Task 16 — Final verification + branch finish - -- [ ] **Step 16.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented and not blocked on (per `project_scanner_flake.md`). - -- [ ] **Step 16.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -- [ ] **Step 16.3: Coverage check** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - bash -c 'go test -race -coverprofile=/tmp/cov.out \ - ./internal/lidarr/... ./internal/lidarrquarantine/... \ - ./internal/library/... && go tool cover -func=/tmp/cov.out | tail -1' -``` - -Expected: combined ≥ 80%, per spec §8. Both `internal/lidarr/` and `internal/lidarrquarantine/` should individually clear 80%. - -- [ ] **Step 16.4: Frontend full check** - -```bash -cd web && npm run check && npm test -- --run && npm run build -``` - -Expected: 0 errors, all vitest tests pass, build succeeds. - -- [ ] **Step 16.5: Manual smoke** - -- Configure Lidarr in `/admin/integrations` (or use the M5a-saved config). -- Flag a track from the now-playing bar → confirm it disappears from the album page. -- Confirm `/library/hidden` shows the flagged track. -- Un-hide → track returns to album page. -- Re-flag from a different user (admin user A flags as `bad_rip`, then user B flags same track as `wrong_tags`). -- Open `/admin/quarantine` → aggregated row shows count=2, distribution `1× bad_rip, 1× wrong_tags`. -- Click Play → track plays in the player. -- Click Resolve → row clears for both users. -- Re-flag, then click Delete file → file gone from disk, row gone from queue. -- Re-flag a track on a Lidarr-managed album → click Delete via Lidarr → typed-confirm "DELETE" → confirm Lidarr received DELETE call (check Lidarr's UI/logs). -- Verify non-admin user redirected when navigating to `/admin/quarantine`. - -- [ ] **Step 16.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence (per `project_git_workflow` memory). - ---- - -## Self-review checklist (run before declaring the plan ready) - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 2 (client extensions), 3 (library), 4-5 (Service), 7 (soft-hide enforcement), 10 (wiring) -- §4 Schema: Task 1 -- §5 API surface: Tasks 8 (user), 9 (admin) -- §6 UI surfaces: Tasks 12 (TrackMenu+FlagPopover), 13 (mount), 14 (/library/hidden), 15 (/admin/quarantine + sidebar) -- §7 Error handling: distributed across Tasks 8, 9 (handler error mapping); Service layer (Tasks 4, 5) defines the typed errors -- §8 Testing: every Task includes tests; Task 16 verifies coverage targets -- §9 Decisions ledger: not directly implemented but referenced in commit messages -- §10 Out of scope: explicitly excluded — no album/artist quarantine, no bulk operations, no auto-resolve, no Subsonic honoring -- §11 Open questions: position of `/library/hidden` in nav (Task 14, between Liked and Search per the spec); dimmed-with-tooltip for missing MBID (Task 15) - -**Placeholder scan:** the per-task detail level drops after Task 9 (frontend tasks become 1-2 paragraphs) — intentional for navigability. Tasks 14 and 15 reference established patterns from M5a (`/requests` page anatomy, M5a typed-confirm modal for Disconnect) without re-stating the full code. No "TBD" or "TODO" remains. - -**Type consistency:** -- Service method names: `Flag`, `Unflag`, `ListMine`, `ListAdminQueue`, `Resolve`, `DeleteFile`, `DeleteViaLidarr` — consistent across plan -- Error names: `ErrBadReason`, `ErrTrackNotFound`, `ErrQuarantineNotFound`, `ErrAlbumMBIDMissing`, `ErrLidarrAlbumNotFound`, `ErrLidarrDisabled` — consistent -- Lidarr client method names: `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` — consistent -- API paths match spec §5 -- Component names: ``, ``, `` (mentioned in file map but not separately tasked — bake into Tasks 14 & 15 as inline JSX) -- DB column names: `lidarr_quarantine.{user_id,track_id,reason,notes,created_at}`, `lidarr_quarantine_actions.{id,track_id,track_title,artist_name,album_title,action,admin_id,lidarr_album_mbid,affected_users,created_at}` — consistent - -Plan is complete. diff --git a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md deleted file mode 100644 index d73588c5..00000000 --- a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md +++ /dev/null @@ -1,1838 +0,0 @@ -# M5c — Suggested additions on `/discover` — 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:** Personalized artist suggestions on `/discover` (search-input-empty state). Top-12 out-of-library artists ranked by per-user signal (likes ×5 + recency-decayed plays), with top-3 contributing seeds attributed per card. One-click add via the existing M5a Lidarr-request flow. - -**Architecture:** Extend the M4b similarity ingest worker to persist unmatched artist MBIDs to a new `artist_similarity_unmatched` table (mirrors `artist_similarity` shape). New `internal/recommendation` service runs a single CTE at request time that scores candidates from the user's likes + plays through the unmatched table. New `GET /api/discover/suggestions` handler. Frontend swaps `` between the existing search results and a new suggestion feed when the search input is empty. - -**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · existing FabledSword design tokens. - -**Spec:** [`docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`](../specs/2026-04-30-m5c-suggested-additions-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` · `.down.sql` -- `internal/recommendation/suggestions.go` — `SuggestArtists` service + types -- `internal/recommendation/suggestions_integration_test.go` -- `internal/api/suggestions.go` — `GET /api/discover/suggestions` handler -- `internal/api/suggestions_test.go` - -### Backend — modify - -- `internal/db/queries/similarity.sql` — add `UpsertArtistSimilarityUnmatched` -- `internal/db/queries/recommendation.sql` — add `SuggestArtistsForUser` -- `internal/db/dbq/*` — regenerated by `sqlc generate` -- `internal/scrobble/listenbrainz/client.go` — add `Name string \`json:"name"\`` to `SimilarArtist` -- `internal/similarity/worker.go` — extend `upsertArtistSimilar` to persist unmatched MBIDs -- `internal/similarity/worker_test.go` — extend with `TestUpsertArtistSimilar_PersistsUnmatchedToTable` -- `internal/api/api.go` — register `/api/discover/suggestions` route - -### Frontend — create - -- `web/src/lib/api/suggestions.ts` — client (`listSuggestions`, `createSuggestionsQuery`) -- `web/src/lib/api/suggestions.test.ts` -- `web/src/lib/components/SuggestionFeed.svelte` — subcomponent for the suggestion grid -- `web/src/lib/components/SuggestionFeed.test.ts` - -### Frontend — modify - -- `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` -- `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` -- `web/src/lib/components/DiscoverResultCard.svelte` — add optional `attribution?: string` prop -- `web/src/lib/components/DiscoverResultCard.test.ts` — assert attribution rendering -- `web/src/routes/discover/+page.svelte` — empty-input branches to ``; tabs hide when input is empty -- `web/src/routes/discover/discover.test.ts` — extend with suggestion-feed tests - ---- - -## Task list - -### Task 1 — Migration 0012 + worker upsert query - -**Files:** -- Create: `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` -- Create: `internal/db/migrations/0012_artist_similarity_unmatched.down.sql` -- Modify: `internal/db/queries/similarity.sql` -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0012_artist_similarity_unmatched.up.sql`: - -```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); -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0012_artist_similarity_unmatched.down.sql`: - -```sql -DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx; -DROP TABLE IF EXISTS artist_similarity_unmatched; -``` - -- [ ] **Step 1.3: Append the worker upsert query** - -Append to `internal/db/queries/similarity.sql`: - -```sql --- name: UpsertArtistSimilarityUnmatched :exec --- Persists an out-of-library similar-artist MBID. Idempotent on --- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the --- name/score and bump fetched_at. -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(); -``` - -- [ ] **Step 1.4: Add a `dbtest.ResetDB` entry for the new table** - -Modify `internal/dbtest/reset.go` — append `"artist_similarity_unmatched"` to the `dataTables` slice between `track_similarity` and `scrobble_queue` so M5c integration tests don't inherit residual rows. - -```go -var dataTables = []string{ - "artist_similarity", - "track_similarity", - "artist_similarity_unmatched", // M5c - "scrobble_queue", - // ... rest unchanged ... -} -``` - -- [ ] **Step 1.5: Regenerate sqlc** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -go vet ./... -``` - -Expected: clean build. New method `UpsertArtistSimilarityUnmatched` appears in `internal/db/dbq/similarity.sql.go`. - -- [ ] **Step 1.6: Apply migration to verify it runs** - -```bash -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS artist_similarity_unmatched;" -# Migration applies on next test run / server start. -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/db/... -count=1 -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d artist_similarity_unmatched" -``` - -Expected: `\d` shows the table with all columns + the seed_score index. - -- [ ] **Step 1.7: Commit** - -```bash -git add internal/db/migrations/0012_artist_similarity_unmatched.up.sql \ - internal/db/migrations/0012_artist_similarity_unmatched.down.sql \ - internal/db/queries/similarity.sql \ - internal/db/dbq/ \ - internal/dbtest/reset.go -git commit -m "feat(db): add artist_similarity_unmatched schema (migration 0012)" -``` - ---- - -### Task 2 — Extend `SimilarArtist` with `Name` field - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` — add `Name` to the struct -- Modify: `internal/scrobble/listenbrainz/client_test.go` (if existing tests break) — already-passing tests should still pass since adding a field is additive - -The current struct at `internal/scrobble/listenbrainz/client.go:241-245`: - -```go -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Score float64 `json:"score"` -} -``` - -ListenBrainz's `/1/explore/similar-artists/{mbid}` endpoint returns each row with `artist_mbid`, `name`, `score`, plus other fields we don't care about. Adding `Name` is a purely additive struct change; existing JSON unmarshal still works for everything else. - -- [ ] **Step 2.1: Add the field** - -Modify `internal/scrobble/listenbrainz/client.go:241-245`: - -```go -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Name string `json:"name"` - Score float64 `json:"score"` -} -``` - -- [ ] **Step 2.2: Verify existing tests still pass** - -```bash -go test ./internal/scrobble/listenbrainz/... -count=1 -``` - -Expected: all green. The unmarshal already ignored the `name` field; capturing it doesn't break anything. - -- [ ] **Step 2.3: Commit** - -```bash -git add internal/scrobble/listenbrainz/client.go -git commit -m "feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions" -``` - ---- - -### Task 3 — Extend similarity worker to persist unmatched MBIDs - -**Files:** -- Modify: `internal/similarity/worker.go` — extend `upsertArtistSimilar` -- Modify: `internal/similarity/worker_test.go` — add `TestUpsertArtistSimilar_PersistsUnmatchedToTable` - -The current `upsertArtistSimilar` filters returned MBIDs to those in `idByMBID` (in-library only) and discards the rest. M5c keeps the matched-path unchanged but adds a parallel unmatched-persist loop with the same top-K cap. - -- [ ] **Step 3.1: Read the current `upsertArtistSimilar` shape** - -Read `internal/similarity/worker.go:170-200` (function body) before editing. It mirrors `upsertTrackSimilar` exactly — same sort, same `idByMBID`, same top-K pattern. The matched-loop pattern is the template. - -- [ ] **Step 3.2: Extend `upsertArtistSimilar`** - -Replace the function body (`internal/similarity/worker.go:170` onwards). The matched loop stays exactly as it was; we add a second loop that walks the same sorted `results` and persists unmatched rows up to `w.topK`: - -```go -func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { - if len(results) == 0 { - return - } - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetArtistsByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - // Matched: in-library similars → artist_similarity (existing path). - takenMatched := 0 - for _, r := range results { - if takenMatched >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == artistAID { - continue // defensive — DB CHECK constraint also catches self-edges - } - if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ - ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, Source: "listenbrainz", - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) - continue - } - takenMatched++ - } - - // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). - // Same top-K cap as the matched path; missing-name rows are skipped (we - // can't render a suggestion without an artist name). - takenUnmatched := 0 - for _, r := range results { - if takenUnmatched >= w.topK { - break - } - if _, inLib := idByMBID[r.MBID]; inLib { - continue - } - if r.Name == "" { - w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) - continue - } - if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: artistAID, - CandidateMbid: r.MBID, - CandidateName: r.Name, - Score: r.Score, - Source: "listenbrainz", - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) - continue - } - takenUnmatched++ - } -} -``` - -Note: the existing function used a single `taken` variable; the new version splits into `takenMatched` and `takenUnmatched` so each path is bounded independently. Verify the existing call to `UpsertArtistSimilarity` in your repo matches the signature you're substituting — sqlc may name the params struct differently. - -- [ ] **Step 3.3: Write `TestUpsertArtistSimilar_PersistsUnmatchedToTable`** - -Append to `internal/similarity/worker_test.go`: - -```go -func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - pool := newPool(t) // existing helper - q := dbq.New(pool) - ctx := context.Background() - - // Seed one in-library artist that will be the in-library match. - inLibMBID := "in-lib-mbid-123" - inLibArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, - }) - if err != nil { - t.Fatalf("seed in-lib artist: %v", err) - } - - // Seed a "seed" artist (the one whose similars we're processing). - seedMBID := "seed-artist-mbid" - seedArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "Seed Artist", SortName: "Seed Artist", Mbid: &seedMBID, - }) - if err != nil { - t.Fatalf("seed artist: %v", err) - } - - w := &Worker{pool: pool, logger: newTestLogger(), topK: 10} - - similars := []listenbrainz.SimilarArtist{ - {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, - {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, - {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, - {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, - {MBID: "out-mbid-4", Name: "", Score: 0.60}, // missing name — should be skipped - } - w.upsertArtistSimilar(ctx, q, seedArtist.ID, similars) - - // Matched path: 1 row in artist_similarity. - var matchedCount int - if err := pool.QueryRow(ctx, - "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", - seedArtist.ID, - ).Scan(&matchedCount); err != nil { - t.Fatalf("count matched: %v", err) - } - if matchedCount != 1 { - t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) - } - - // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. - var unmatchedCount int - if err := pool.QueryRow(ctx, - "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", - seedArtist.ID, - ).Scan(&unmatchedCount); err != nil { - t.Fatalf("count unmatched: %v", err) - } - if unmatchedCount != 3 { - t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) - } - - // Verify a specific row's name + score round-tripped correctly. - var name string - var score float64 - if err := pool.QueryRow(ctx, - "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", - seedArtist.ID, "out-mbid-1", - ).Scan(&name, &score); err != nil { - t.Fatalf("fetch out-mbid-1: %v", err) - } - if name != "Outsider One" || score != 0.85 { - t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) - } - - // suppress unused warning if inLibArtist isn't otherwise referenced - _ = inLibArtist -} -``` - -If `internal/similarity/worker_test.go` doesn't already have a `newPool(t)` and `newTestLogger()` helper, mirror the pattern from `internal/lidarrquarantine/service_test.go` — those have working examples. - -- [ ] **Step 3.4: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 ./internal/similarity/... -``` - -Expected: existing tests still pass + new test passes. - -- [ ] **Step 3.5: Commit** - -```bash -git add internal/similarity/worker.go internal/similarity/worker_test.go -git commit -m "feat(similarity): persist unmatched similar-artist MBIDs for M5c" -``` - ---- - -### Task 4 — `internal/recommendation` `SuggestArtists` service - -**Files:** -- Create: `internal/recommendation/suggestions.go` -- Create: `internal/recommendation/suggestions_integration_test.go` -- Modify: `internal/db/queries/recommendation.sql` — add the suggestion CTE query -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 4.1: Append the suggestion query** - -Append to `internal/db/queries/recommendation.sql`: - -```sql --- name: SuggestArtistsForUser :many --- M5c: per-user artist suggestions ranked by signal × similarity. The --- seeds CTE collects the user's likes (×5) plus recency-decayed plays --- (exp(-age_days / $2)). The contributions CTE joins those seeds against --- artist_similarity_unmatched and filters out candidates already in --- library or already in a non-terminal lidarr_request. The outer SELECT --- aggregates per candidate, returning the top-3 contributing seeds for --- attribution. $1=user_id, $2=half_life_days, $3=limit. -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, - (gla.artist_id IS NOT NULL) AS is_liked, - COUNT(pe.id) AS play_count - 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, gla.artist_id -), -contributions AS ( - SELECT u.candidate_mbid, - u.candidate_name, - seeds.artist_id AS seed_id, - seeds.is_liked, - seeds.play_count, - 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)::float8 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, - (array_agg(is_liked ORDER BY contribution DESC))[1:3] AS top_is_liked, - (array_agg(play_count ORDER BY contribution DESC))[1:3] AS top_play_counts -FROM contributions -GROUP BY candidate_mbid, candidate_name -ORDER BY total_score DESC -LIMIT $3; -``` - -The extra arrays (`top_is_liked`, `top_play_counts`) let the SPA pick "liked"/"played" verbiage per attribution-line slot. - -- [ ] **Step 4.2: Regenerate sqlc** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -``` - -Expected: clean. New method `SuggestArtistsForUser` in `internal/db/dbq/recommendation.sql.go`. Note the row type may have field names like `TopSeedIds` (plural-suffix suppressed by sqlc) — read the generated file before relying on names in the next step. - -- [ ] **Step 4.3: Write `internal/recommendation/suggestions.go`** - -```go -// suggestions.go is the M5c per-user artist-suggestion service. Reads -// the user's likes + plays, projects them through artist_similarity_unmatched -// via a single CTE, returns top-N candidates with top-3 attribution seeds -// resolved to artist names. -package recommendation - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. -type ArtistSuggestion struct { - MBID string - Name string - Score float64 - Attribution []SeedContribution -} - -// SeedContribution is one of the top-3 contributing seeds for a candidate. -type SeedContribution struct { - ArtistID pgtype.UUID - Name string - Contribution float64 - IsLiked bool - PlayCount int64 -} - -// SuggestArtists returns top-N artist suggestions for the user. limit is -// capped at 50; halfLifeDays is the recency-decay half-life for plays -// (default 30, operator-tunable). -func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { - if limit <= 0 || limit > 50 { - limit = 12 - } - if halfLifeDays <= 0 { - halfLifeDays = 30 - } - q := dbq.New(pool) - rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ - UserID: userID, - Column2: halfLifeDays, // sqlc names unbound positional params Column2/3 — verify - Limit: int32(limit), - }) - if err != nil { - return nil, fmt.Errorf("suggest: query: %w", err) - } - if len(rows) == 0 { - return []ArtistSuggestion{}, nil - } - - // Collect the union of top-3 seed IDs across all rows for one batched - // name lookup. - seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) - for _, r := range rows { - for _, sid := range r.TopSeedIds { - seedSet[sid] = struct{}{} - } - } - seedIDs := make([]pgtype.UUID, 0, len(seedSet)) - for id := range seedSet { - seedIDs = append(seedIDs, id) - } - artists, err := q.GetArtistsByIDs(ctx, seedIDs) - if err != nil { - return nil, fmt.Errorf("suggest: resolve seeds: %w", err) - } - nameByID := make(map[pgtype.UUID]string, len(artists)) - for _, a := range artists { - nameByID[a.ID] = a.Name - } - - out := make([]ArtistSuggestion, 0, len(rows)) - for _, r := range rows { - attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) - for i, sid := range r.TopSeedIds { - if i >= len(r.TopContributions) { - break - } - attribution = append(attribution, SeedContribution{ - ArtistID: sid, - Name: nameByID[sid], - Contribution: r.TopContributions[i], - IsLiked: r.TopIsLiked[i], - PlayCount: r.TopPlayCounts[i], - }) - } - out = append(out, ArtistSuggestion{ - MBID: r.CandidateMbid, - Name: r.CandidateName, - Score: r.TotalScore, - Attribution: attribution, - }) - } - return out, nil -} -``` - -Two things to verify against the actual generated code in `internal/db/dbq/recommendation.sql.go`: -1. The param struct may name `$2` something other than `Column2` (sqlc occasionally renames). Look at `SuggestArtistsForUserParams` and use whatever's there. -2. `GetArtistsByIDs` may not exist — check `internal/db/queries/artists.sql`. If absent, add a query in this same task: - -```sql --- name: GetArtistsByIDs :many -SELECT * FROM artists WHERE id = ANY($1::uuid[]); -``` - -(If `GetArtistsByMBIDs` exists but not `GetArtistsByIDs`, add the IDs variant; sqlc-regenerate.) - -- [ ] **Step 4.4: Write integration tests** - -Create `internal/recommendation/suggestions_integration_test.go`: - -```go -package recommendation - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user: %v", err) - } - return u -} - -func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { - t.Helper() - var mbidPtr *string - if mbid != "" { - mbidPtr = &mbid - } - a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: name, SortName: name, Mbid: mbidPtr, - }) - if err != nil { - t.Fatalf("seed artist: %v", err) - } - return a -} - -func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { - t.Helper() - if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: seedID, - CandidateMbid: candMBID, - CandidateName: candName, - Score: score, - Source: "listenbrainz", - }); err != nil { - t.Fatalf("seed unmatched: %v", err) - } -} - -func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seedA := seedArtist(t, pool, "Seed Liked", "") - seedB := seedArtist(t, pool, "Seed Played", "") - - // alice likes seedA. - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seedA.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - // alice played seedB. (Need a play_event with the right artist via tracks.) - seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") - seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") - insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) - - // Both seeds point at the same out-of-library candidate. - seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) - seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1", len(out)) - } - s := out[0] - if s.MBID != "out-mbid" || s.Name != "Outsider" { - t.Errorf("got = %+v", s) - } - if s.Score <= 0 { - t.Errorf("score = %v, want > 0", s.Score) - } - if len(s.Attribution) != 2 { - t.Errorf("attribution len = %d, want 2", len(s.Attribution)) - } -} - -func TestSuggestArtists_Top12Cap(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - for i := 0; i < 30; i++ { - seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) - } - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 12 { - t.Errorf("len = %d, want 12", len(out)) - } - if out[0].MBID != "mbid-00" { - t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) - } -} - -func TestSuggestArtists_AttributionTopThree(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - // 5 seed artists all liked, all pointing at the same candidate but - // with descending similarity scores so contributions order is clean. - seeds := make([]dbq.Artist, 5) - for i := 0; i < 5; i++ { - seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seeds[i].ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) - } - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) - } - if got := len(out[0].Attribution); got != 3 { - t.Errorf("attribution len = %d, want 3", got) - } - // Verify ordering: highest contribution first (seed 0 with score 0.9). - if out[0].Attribution[0].Name != "Seed 0" { - t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) - } -} - -func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - recentSeed := seedArtist(t, pool, "Recent", "") - oldSeed := seedArtist(t, pool, "Old", "") - - rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") - rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") - insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) - - oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") - oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") - insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) - - // Both seeds point at the same candidate with the same similarity score. - seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) - seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1", len(out)) - } - if len(out[0].Attribution) != 2 { - t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) - } - // Recent seed contributes more than old seed. - if out[0].Attribution[0].Name != "Recent" { - t.Errorf("top attribution = %q, want Recent (1d-old play decays less than 90d)", out[0].Attribution[0].Name) - } - if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { - t.Errorf("recent contribution (%v) should exceed old (%v)", - out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) - } -} - -func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - // Candidate that's already in library. - inLibMBID := "in-lib-mbid" - seedArtist(t, pool, "InLib", inLibMBID) - seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) - } -} - -func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) - if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ - UserID: user.ID, - Kind: dbq.LidarrRequestKindArtist, - LidarrArtistMbid: "req-mbid", - ArtistName: "Pending Request", - }); err != nil { - t.Fatalf("CreateLidarrRequest: %v", err) - } - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) - } -} - -func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) - req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ - UserID: user.ID, - Kind: dbq.LidarrRequestKindArtist, - LidarrArtistMbid: "rej-mbid", - ArtistName: "Rejected Once", - }) - if err != nil { - t.Fatalf("CreateLidarrRequest: %v", err) - } - rejNotes := "wrong artist" - if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ - ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, - }); err != nil { - t.Fatalf("RejectLidarrRequest: %v", err) - } - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) - } -} - -func TestSuggestArtists_EmptyForNewUser(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "newbie") - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) - } -} -``` - -Helper functions `seedAlbumForArtist`, `seedTrackOnAlbum`, `insertPlayEvent` are needed. Mirror the same helpers from `internal/lidarrquarantine/service_test.go`'s `seedTrack` (but parameterize artist): - -```go -func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { - t.Helper() - a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: title, SortTitle: title, ArtistID: artistID, - }) - if err != nil { - t.Fatalf("seed album: %v", err) - } - return a -} - -func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { - t.Helper() - tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, AlbumID: albumID, ArtistID: artistID, - DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", - FileSize: 1, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("seed track: %v", err) - } - return tr -} - -func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { - t.Helper() - if _, err := pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, started_at, was_skipped) VALUES ($1, $2, $3, false)`, - userID, trackID, startedAt, - ); err != nil { - t.Fatalf("insert play_event: %v", err) - } -} -``` - -The exact `play_events` column set may differ from this minimal insert — read the migration `0005_events.up.sql` and add any required NOT-NULL columns (e.g. `client_id`, `session_id`) with sensible defaults if the insert fails. - -- [ ] **Step 4.5: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 -p 1 ./internal/recommendation/... -``` - -Expected: 7 tests pass. - -- [ ] **Step 4.6: Commit** - -```bash -git add internal/recommendation/suggestions.go \ - internal/recommendation/suggestions_integration_test.go \ - internal/db/queries/recommendation.sql \ - internal/db/queries/artists.sql \ - internal/db/dbq/ -git commit -m "feat(recommendation): SuggestArtists service for M5c" -``` - -(Include `artists.sql` if you added `GetArtistsByIDs` to it in Step 4.3.) - ---- - -### Task 5 — `/api/discover/suggestions` handler + route mount - -**Files:** -- Create: `internal/api/suggestions.go` -- Create: `internal/api/suggestions_test.go` -- Modify: `internal/api/api.go` — add the route - -- [ ] **Step 5.1: Write the handler** - -Create `internal/api/suggestions.go`: - -```go -package api - -import ( - "net/http" - "strconv" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// suggestionView is the wire shape returned by GET /api/discover/suggestions. -type suggestionView struct { - MBID string `json:"mbid"` - Name string `json:"name"` - Score float64 `json:"score"` - Attribution []seedContributionView `json:"attribution"` -} - -type seedContributionView struct { - ArtistID pgtype.UUID `json:"artist_id"` - Name string `json:"name"` - Contribution float64 `json:"contribution"` - IsLiked bool `json:"is_liked"` - PlayCount int64 `json:"play_count"` -} - -// handleListSuggestions implements GET /api/discover/suggestions. -// -// Query params: -// - limit (default 12, capped at 50) -// - half_life_days (default 30, no max) -// -// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. -func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - limit := 12 - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - halfLife := 30.0 - if v := r.URL.Query().Get("half_life_days"); v != "" { - f, err := strconv.ParseFloat(v, 64) - if err != nil || f <= 0 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") - return - } - halfLife = f - } - - suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) - if err != nil { - h.logger.Error("api: list suggestions", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") - return - } - - out := make([]suggestionView, 0, len(suggestions)) - for _, s := range suggestions { - attr := make([]seedContributionView, 0, len(s.Attribution)) - for _, a := range s.Attribution { - attr = append(attr, seedContributionView{ - ArtistID: a.ArtistID, - Name: a.Name, - Contribution: a.Contribution, - IsLiked: a.IsLiked, - PlayCount: a.PlayCount, - }) - } - out = append(out, suggestionView{ - MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -- [ ] **Step 5.2: Mount the route** - -In `internal/api/api.go`, find the authed group (after `r.Get("/api/radio", ...)` line) and add: - -```go -authed.Get("/discover/suggestions", h.handleListSuggestions) -``` - -- [ ] **Step 5.3: Write handler tests** - -Create `internal/api/suggestions_test.go`: - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" -) - -func TestSuggestions_HappyPath(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "alice", "pw", false) - - // Seed: alice likes a seed artist; that seed has one out-of-library - // similar in artist_similarity_unmatched. - seedA, err := dbq.New(h.pool).UpsertArtist(t.Context(), dbq.UpsertArtistParams{ - Name: "Seed", SortName: "Seed", - }) - if err != nil { - t.Fatalf("UpsertArtist: %v", err) - } - if _, err := dbq.New(h.pool).LikeArtist(t.Context(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seedA.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - if err := dbq.New(h.pool).UpsertArtistSimilarityUnmatched(t.Context(), dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: seedA.ID, - CandidateMbid: "out-mbid", - CandidateName: "Outsider", - Score: 0.9, - Source: "listenbrainz", - }); err != nil { - t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) - } - var got []suggestionView - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) - } - if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { - t.Errorf("got = %+v", got[0]) - } - if len(got[0].Attribution) != 1 { - t.Errorf("attribution len = %d, want 1", len(got[0].Attribution)) - } - if got[0].Attribution[0].Name != "Seed" { - t.Errorf("attribution name = %q, want Seed", got[0].Attribution[0].Name) - } -} - -func TestSuggestions_EmptyForNewUser(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "newbie", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - if got := w.Body.String(); got != "[]\n" && got != "[]" { - t.Errorf("body = %q, want []", got) - } -} - -func TestSuggestions_BadLimit(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "alice", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions?limit=not-a-number", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -// suppress unused imports in some test layouts -var _ = dbtest.TestUserPrefix -``` - -`testHandlers`, `seedUser`, `setUserCtx` are existing helpers in the test layout — see `internal/api/auth_test.go` and `internal/api/requests_test.go`. Use whatever pattern those tests use to seed a user and inject it into the request context. - -- [ ] **Step 5.4: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 -p 1 ./internal/api/... -run Suggestions -``` - -Expected: 3 tests pass. - -- [ ] **Step 5.5: Commit** - -```bash -git add internal/api/suggestions.go internal/api/suggestions_test.go internal/api/api.go -git commit -m "feat(api): /api/discover/suggestions handler" -``` - ---- - -### Task 6 — Frontend API client + types + qk - -**Files:** -- Create: `web/src/lib/api/suggestions.ts` -- Create: `web/src/lib/api/suggestions.test.ts` -- Modify: `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` -- Modify: `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` - -- [ ] **Step 6.1: Add types** - -Append to `web/src/lib/api/types.ts`: - -```ts -export type SeedContribution = { - artist_id: string; - name: string; - contribution: number; - is_liked: boolean; - play_count: number; -}; - -export type ArtistSuggestion = { - mbid: string; - name: string; - score: number; - attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC -}; -``` - -- [ ] **Step 6.2: Add query key** - -Append to the `qk` object in `web/src/lib/api/queries.ts`: - -```ts -suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, -``` - -- [ ] **Step 6.3: Write `suggestions.ts`** - -Create `web/src/lib/api/suggestions.ts`: - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk } from './queries'; -import type { ArtistSuggestion } from './types'; - -export async function listSuggestions(limit = 12): Promise { - return api.get(`/api/discover/suggestions?limit=${limit}`); -} - -export function createSuggestionsQuery(limit = 12) { - return createQuery({ - queryKey: qk.suggestions(limit), - queryFn: () => listSuggestions(limit), - staleTime: 5 * 60_000 // 5 minutes - }); -} -``` - -- [ ] **Step 6.4: Write tests** - -Create `web/src/lib/api/suggestions.test.ts`: - -```ts -import { describe, expect, test, vi, afterEach } from 'vitest'; - -vi.mock('./client', () => ({ - api: { get: vi.fn() } -})); - -import { listSuggestions } from './suggestions'; -import { qk } from './queries'; -import { api } from './client'; -import type { ArtistSuggestion } from './types'; - -afterEach(() => vi.clearAllMocks()); - -describe('suggestions client', () => { - test('listSuggestions hits the right URL with default limit', async () => { - const fixture: ArtistSuggestion[] = [ - { - mbid: 'm1', - name: 'Outsider', - score: 1.5, - attribution: [ - { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } - ] - } - ]; - (api.get as ReturnType).mockResolvedValueOnce(fixture); - const got = await listSuggestions(); - expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12'); - expect(got).toEqual(fixture); - }); - - test('listSuggestions honors a custom limit', async () => { - (api.get as ReturnType).mockResolvedValueOnce([]); - await listSuggestions(20); - expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20'); - }); - - test('qk.suggestions key shape', () => { - expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]); - expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]); - }); -}); -``` - -- [ ] **Step 6.5: Verify** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run suggestions -cd .. -``` - -Expected: 0 errors, 3 tests pass. - -- [ ] **Step 6.6: Commit** - -```bash -git add web/src/lib/api/suggestions.ts web/src/lib/api/suggestions.test.ts \ - web/src/lib/api/types.ts web/src/lib/api/queries.ts -git commit -m "feat(web): API client for /api/discover/suggestions" -``` - ---- - -### Task 7 — Extend `` with `attribution` prop - -**Files:** -- Modify: `web/src/lib/components/DiscoverResultCard.svelte` -- Modify: `web/src/lib/components/DiscoverResultCard.test.ts` - -The card already has a `$props()` block accepting `kind`, `title`, `subtitle?`, `imageUrl?`, `state`, `onRequest?`. Add `attribution?: string` and render it in italic Vellum below the title (above the reserved badge slot). - -- [ ] **Step 7.1: Add the prop** - -In `DiscoverResultCard.svelte`, modify the `$props()` destructure: - -```ts -let { - kind, - title, - subtitle, - imageUrl, - state, - attribution, - onRequest, -}: { - kind: DiscoverCardKind; - title: string; - subtitle?: string; - imageUrl?: string; - state: DiscoverCardState; - attribution?: string; - onRequest?: () => void; -} = $props(); -``` - -- [ ] **Step 7.2: Render the attribution line** - -Find the section rendering the title + subtitle (search for `class="title"` and `class="subtitle"`). Add the attribution line between subtitle and `.badge-row`: - -```svelte -
-
{title}
- {#if subtitle} -
{subtitle}
- {/if} - {#if attribution} -
- {attribution} -
- {/if} -
- {#if state === 'kept'} - Kept - {/if} -
-
-``` - -- [ ] **Step 7.3: Add tests** - -Append to `DiscoverResultCard.test.ts`: - -```ts -test('renders attribution line when prop is set', () => { - render(DiscoverResultCard, { - props: { - kind: 'artist', - title: 'Outsider', - state: 'requestable', - attribution: 'Because you liked Boards of Canada and played Aphex Twin.' - } - }); - expect(screen.getByTestId('attribution')).toHaveTextContent('Because you liked Boards of Canada and played Aphex Twin.'); -}); - -test('omits attribution line when prop is absent', () => { - render(DiscoverResultCard, { - props: { kind: 'artist', title: 'Outsider', state: 'requestable' } - }); - expect(screen.queryByTestId('attribution')).not.toBeInTheDocument(); -}); -``` - -- [ ] **Step 7.4: Verify + commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run DiscoverResultCard -cd .. -git add web/src/lib/components/DiscoverResultCard.svelte web/src/lib/components/DiscoverResultCard.test.ts -git commit -m "feat(web): DiscoverResultCard attribution prop for M5c suggestions" -``` - ---- - -### Task 8 — `` subcomponent + `/discover` integration - -**Files:** -- Create: `web/src/lib/components/SuggestionFeed.svelte` -- Create: `web/src/lib/components/SuggestionFeed.test.ts` -- Modify: `web/src/routes/discover/+page.svelte` — branch to `` when search is empty -- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios - -- [ ] **Step 8.1: Write ``** - -Create `web/src/lib/components/SuggestionFeed.svelte`: - -```svelte - - -
-
-

Suggested for you

-

Out-of-library artists drawn from what you've liked and played.

-
- - {#if !query.isPending && suggestions.length === 0} -

Listen to something or like an artist to start getting suggestions.

- {:else if suggestions.length > 0} -
- {#each suggestions.filter(visible) as s (s.mbid)} - onRequest(s)} - /> - {/each} -
- {/if} -
-``` - -- [ ] **Step 8.2: Write `` tests** - -Create `web/src/lib/components/SuggestionFeed.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { mockQuery } from '../../test-utils/query'; - -const invalidateMock = vi.fn(); -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; -}); - -vi.mock('$lib/api/suggestions', () => ({ - createSuggestionsQuery: vi.fn() -})); - -vi.mock('$lib/api/requests', () => ({ - createRequest: vi.fn().mockResolvedValue({}) -})); - -import SuggestionFeed from './SuggestionFeed.svelte'; -import { createSuggestionsQuery } from '$lib/api/suggestions'; -import { createRequest } from '$lib/api/requests'; -import type { ArtistSuggestion } from '$lib/api/types'; - -const oneSeed: ArtistSuggestion = { - mbid: 'mb1', name: 'Outsider', score: 1.0, - attribution: [{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }] -}; - -const twoSeeds: ArtistSuggestion = { - mbid: 'mb2', name: 'Outsider Two', score: 2.0, - attribution: [ - { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, - { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } - ] -}; - -const threeSeeds: ArtistSuggestion = { - mbid: 'mb3', name: 'Outsider Three', score: 3.0, - attribution: [ - { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, - { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, - { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } - ] -}; - -afterEach(() => vi.clearAllMocks()); - -describe('SuggestionFeed', () => { - test('renders one card per suggestion', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed, twoSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText('Outsider')).toBeInTheDocument(); - expect(screen.getByText('Outsider Two')).toBeInTheDocument(); - }); - - test('attribution copy: 1 seed → "Because you liked X."', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); - }); - - test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [twoSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); - }); - - test('attribution copy: 3 seeds → Oxford comma', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [threeSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); - }); - - test('Request button calls createRequest with artist-kind body', async () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed] }) - ); - render(SuggestionFeed); - await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); - expect(createRequest).toHaveBeenCalledWith({ - kind: 'artist', - lidarr_artist_mbid: 'mb1', - artist_name: 'Outsider' - }); - expect(invalidateMock).toHaveBeenCalled(); - }); - - test('empty state when data is []', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); - render(SuggestionFeed); - expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 8.3: Wire `` into `/discover`** - -Modify `web/src/routes/discover/+page.svelte`. Read it first — the existing structure runs the search query when `debouncedQ.length > 0`. Add the feed branch: - -```svelte - - - - -
- - - - {#if debouncedQ === ''} - - {:else} - - {existing markup unchanged} - {/if} -
-``` - -The search-input element stays at the top level (visible in both branches). The header copy ("Add music to the library" vs "Suggested for you") is now owned by the respective branch — `` renders its own header; the search branch keeps the existing one. - -If the existing `+page.svelte` has its header above the input, you'll need to move it inside the search branch. Pattern: - -```svelte - - -{#if debouncedQ === ''} - -{:else} -
-

Add music to the library

-

...

-
- -{/if} -``` - -- [ ] **Step 8.4: Update `discover.test.ts`** - -The existing tests assume `inputValue === ''` shows the initial-copy state. Now it shows the suggestion feed. Update the relevant test and add new ones: - -Add a mock for `$lib/api/suggestions`: - -```ts -vi.mock('$lib/api/suggestions', () => ({ - createSuggestionsQuery: vi.fn() -})); -``` - -Update the existing "initial state shows search prompt copy" test (or add a replacement): - -```ts -test('empty input shows the suggestion feed', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [] }) - ); - render(DiscoverPage); - expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); -}); - -test('typing replaces feed with search', async () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [] }) - ); - // mock the lidarr search query as before - // ... fire input event to trigger debounced search ... - // ... advance fake timers ... - expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); - expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); -}); -``` - -The existing tests that drive the search flow stay — they always provide a non-empty query. The empty-input case becomes a suggestion-feed test. - -- [ ] **Step 8.5: Verify** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run SuggestionFeed discover -npm run build -cd .. -``` - -Expected: 0 errors, all tests pass, build clean. - -- [ ] **Step 8.6: Commit** - -```bash -git add web/src/lib/components/SuggestionFeed.svelte \ - web/src/lib/components/SuggestionFeed.test.ts \ - web/src/routes/discover/+page.svelte \ - web/src/routes/discover/discover.test.ts -git commit -m "feat(web): suggestion feed on /discover (search-empty default)" -``` - ---- - -### Task 9 — Final verification + branch finish - -- [ ] **Step 9.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented (`project_scanner_flake.md`) and not blocking. - -- [ ] **Step 9.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -Expected: no output. - -- [ ] **Step 9.3: Coverage check** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - bash -c 'go test -race -p 1 -coverprofile=/tmp/cov.out \ - ./internal/recommendation/... ./internal/similarity/... ./internal/api/... && \ - go tool cover -func=/tmp/cov.out | tail -1' -``` - -Expected: combined ≥ 80% on the new code per spec §8. - -- [ ] **Step 9.4: Frontend full check** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run -npm run build -cd .. -``` - -Expected: 0 errors, all tests pass, build succeeds. - -- [ ] **Step 9.5: Manual smoke** - -- Like an artist (or play a few tracks). -- Open `/discover` with the search input empty. -- Verify the "Suggested for you" header + grid renders. -- Verify each card has an attribution line that reads naturally. -- Click Request on a suggestion → confirm it disappears (optimistic) and a row appears at `/requests`. -- Type a search term → confirm the feed swaps out for search results. -- Clear the search input → confirm the feed comes back (cached, instant). -- For a fresh user with no likes/plays, the empty-state copy renders. - -- [ ] **Step 9.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. - ---- - -## Self-review checklist - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 1 (table), 2 (LB client), 3 (worker), 4 (service), 5 (handler), 6-8 (frontend) -- §4 Schema: Task 1 -- §5 API surface: Task 5 -- §6 UI surfaces: Tasks 7 (DiscoverResultCard), 8 (SuggestionFeed + /discover integration) -- §7 Error handling: distributed across Tasks 3 (worker WARN), 5 (handler 500), 8 (frontend silent-on-failure) -- §8 Testing: every Task includes tests; Task 9 verifies coverage targets -- §9 Decisions ledger: not directly implemented, referenced in commit messages -- §10 Out of scope: explicitly excluded — no album/track suggestions, no realtime invalidation, no cross-user CF, no pagination, no materialization -- §11 Open questions: Task 2 verifies the `Name` field on `SimilarArtist`; cold-start sparseness is documented behavior - -**Placeholder scan:** the per-task detail level drops after Task 5 (frontend tasks become standard SvelteKit page work) — intentional for navigability. No "TBD" or "TODO" remains. - -**Type consistency:** -- Service method: `SuggestArtists` consistent across plan -- Types: `ArtistSuggestion`, `SeedContribution` consistent across Go and TS -- API path: `/api/discover/suggestions` consistent -- Component name: `` consistent -- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests - -Plan is complete. diff --git a/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md b/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md deleted file mode 100644 index 010b2b23..00000000 --- a/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md +++ /dev/null @@ -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 `
slot -│ └── Shell.test.ts -├── routes/ -│ ├── +layout.ts # load() → await auth.bootstrap() -│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare -│ ├── +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 `` (authed) or `` (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 { - 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: (p: string) => apiFetch(p) as Promise, - post: (p: string, b: unknown) => - apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise, - del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise, -}; -``` - -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(null); -export const user = { get value() { return _user; } }; - -export async function bootstrap(): Promise { - try { _user = await api.get('/api/me'); } - catch { _user = null; } -} - -export async function login(username: string, password: string): Promise { - const res = await api.post('/api/auth/login', { username, password }); - _user = res.user; -} - -export async function logout(opts: { silent?: boolean } = {}): Promise { - 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.** - -``` - -``` - -- 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 `` 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=`; 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 `` 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/` while signed out lands on `/login?returnTo=`; after login, lands on ``. -- 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. diff --git a/docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md b/docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md deleted file mode 100644 index a2991654..00000000 --- a/docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md +++ /dev/null @@ -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; ``. -- `AlbumCard` — one card in the albums grid. Cover image, title, year. ``. -- `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 -├── 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>( - `/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(`/api/artists/${id}`), - }); -} - -export function createAlbumQuery(id: string) { - return createQuery({ - queryKey: qk.album(id), - queryFn: () => api.get(`/api/albums/${id}`), - }); -} -``` - -`types.ts` — new file mirroring the backend `internal/api/types.go` shapes (`ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page`). Lives next to `client.ts` for discoverability. - -## Per-page layout - -### Artists list — `/` - -- **Top bar**: left "Library" title (h1); right, `` change triggers `goto('?sort=newest', ...)`. - - Pending renders ``; error renders ``. - - Empty state (total === 0) renders the empty-library message. -- `artist.test.ts` - - Renders `` 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 `` 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. diff --git a/docs/superpowers/specs/2026-04-24-web-ui-player-design.md b/docs/superpowers/specs/2026-04-24-web-ui-player-design.md deleted file mode 100644 index d0bb44bc..00000000 --- a/docs/superpowers/specs/2026-04-24-web-ui-player-design.md +++ /dev/null @@ -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 `` 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 ` - -
- - -{/if} + + {#each folders.data ?? [] as f (f.path)} + + {/each} + + -{#if toast} -
- {toast} +
+ +
-{/if} + + + +

+ Notes are shown to the requester. +

+ +
+ + +
+