Release v2026.05.08.1 — DRY pass + cover-art HTTP base (#31)

Merges ~50 commits from dev: full DRY pass round 1 + round 2, plus PR3 cover-art HTTP base.
This commit was merged in pull request #31.
This commit is contained in:
2026-05-08 17:45:41 +00:00
594 changed files with 45195 additions and 41792 deletions
+31
View File
@@ -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/
+101
View File
@@ -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"
+8
View File
@@ -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:
+56
View File
@@ -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 ./...
+50
View File
@@ -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
-68
View File
@@ -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 ./...
+24
View File
@@ -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
+9
View File
@@ -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"]
+1 -1
View File
@@ -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
+85 -28
View File
@@ -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.
<!-- TODO: screenshot of the home page -->
## 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_<SECTION>_<FIELD>` 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 (`:<version>`).
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).
+83 -4
View File
@@ -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(),
+26 -8
View File
@@ -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 <data_dir>/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:
+11 -6
View File
@@ -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:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,954 +0,0 @@
# Web UI Scaffold Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Scaffold a SvelteKit SPA at `web/`, embed its build output into the Go binary, and update the Dockerfile + CI so a single container image serves the SPA at `/` alongside the existing `/api/*` and `/rest/*` surfaces.
**Architecture:** SvelteKit with `@sveltejs/adapter-static` (SPA fallback to `index.html`) builds to `web/build/`. A thin `web` Go package `//go:embed`s that directory and returns an `http.Handler` that serves static assets and falls back to `index.html` for unknown paths. The handler is wired as the server's `NotFound` route so explicit routes (`/api`, `/rest`, `/healthz`) are unaffected; the `NotFound` wrapper also returns a JSON 404 for unmatched `/api/*` and `/rest/*` paths so the SPA fallback never produces HTML bodies for API consumers. A hand-crafted placeholder `web/build/index.html` is committed (via negated gitignore) so `go build` works in clean checkouts before anyone runs `npm run build`.
**Tech Stack:** SvelteKit 2.x, Svelte 5, TypeScript 5, Vite 5, Tailwind CSS 3, Vitest, Go 1.23, chi/v5, Docker multi-stage builds, Forgejo Actions.
**Reference spec:** `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`. This plan implements only the Stack / Packaging / Dev-workflow / Build sections. Feature UI (login, library views, player) is out of scope and lands in follow-up plans.
---
## File structure
| File | Action | Purpose |
|---|---|---|
| `web/package.json` | Create | npm config + scripts |
| `web/tsconfig.json` | Create | TypeScript config extending SvelteKit's generated one |
| `web/svelte.config.js` | Create | `adapter-static` with `fallback: 'index.html'` |
| `web/vite.config.ts` | Create | Vite config + dev proxy for `/api` and `/rest` |
| `web/vitest.config.ts` | Create | Vitest + jsdom config |
| `web/tailwind.config.js` | Create | Tailwind content globs + dark palette tokens |
| `web/postcss.config.cjs` | Create | PostCSS pipeline (tailwindcss + autoprefixer) |
| `web/src/app.html` | Create | HTML template |
| `web/src/app.css` | Create | Tailwind directives + CSS custom properties |
| `web/src/routes/+layout.svelte` | Create | Imports `app.css`, renders outlet |
| `web/src/routes/+page.svelte` | Create | Placeholder landing page |
| `web/src/lib/example.test.ts` | Create | Vitest smoke test |
| `web/static/favicon.png` | Create | 1×1 transparent PNG placeholder |
| `web/build/index.html` | Create | Hand-crafted placeholder committed so `go:embed` works pre-build |
| `web/.gitignore` | Create | Ignore `node_modules/`, `.svelte-kit/`, `build/*` with `!build/index.html` |
| `web/embed.go` | Create | `//go:embed` build tree + `Handler()` with SPA fallback (must live next to `web/build/``go:embed` cannot reach parent directories) |
| `web/embed_test.go` | Create | Unit tests for the handler |
| `internal/server/server.go` | Modify | Wire `web.Handler()` into `NotFound`; JSON-404 for `/api/*` and `/rest/*` |
| `internal/server/server_test.go` | Modify | Assert `/` returns HTML; `/api/anything` does not return HTML |
| `Dockerfile` | Rewrite | Three-stage build: `node``golang``debian:slim` |
| `.forgejo/workflows/test.yml` | Modify | Install Node, build web, run `npm test` before Go steps |
| `README.md` | Modify | Document two-process dev workflow |
---
## Task 1: SvelteKit project skeleton
**Files:**
- Create: `web/package.json`, `web/tsconfig.json`, `web/svelte.config.js`, `web/vite.config.ts`, `web/tailwind.config.js`, `web/postcss.config.cjs`
- Create: `web/src/app.html`, `web/src/app.css`, `web/src/routes/+layout.svelte`, `web/src/routes/+page.svelte`
- Create: `web/.gitignore`, `web/static/favicon.png`, `web/build/index.html`
- [ ] **Step 1: Create `web/package.json`**
```json
{
"name": "minstrel-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "svelte-kit sync && vitest run"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"svelte": "^5.1.9",
"svelte-check": "^4.0.5",
"tailwindcss": "^3.4.14",
"tslib": "^2.8.0",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}
```
- [ ] **Step 2: Create `web/tsconfig.json`**
```json
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
```
- [ ] **Step 3: Create `web/svelte.config.js`**
```js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false
}),
files: {
assets: 'static'
}
}
};
```
- [ ] **Step 4: Create `web/vite.config.ts`**
```ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4533',
changeOrigin: true
},
'/rest': {
target: 'http://localhost:4533',
changeOrigin: true
}
}
}
});
```
- [ ] **Step 5: Create `web/tailwind.config.js`**
```js
export default {
content: ['./src/**/*.{html,js,ts,svelte}'],
darkMode: 'class',
theme: {
extend: {
colors: {
surface: {
900: '#14161a',
800: '#1a1d22',
700: '#2a2f36'
},
text: {
primary: '#e8ecf2',
secondary: '#cdd3db'
}
}
}
},
plugins: []
};
```
- [ ] **Step 6: Create `web/postcss.config.cjs`**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
```
- [ ] **Step 7: Create `web/src/app.html`**
```html
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
%sveltekit.head%
</head>
<body class="bg-surface-900 text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
- [ ] **Step 8: Create `web/src/app.css`**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body {
height: 100%;
margin: 0;
}
```
- [ ] **Step 9: Create `web/src/routes/+layout.svelte`**
```svelte
<script lang="ts">
import '../app.css';
</script>
<slot />
```
- [ ] **Step 10: Create `web/src/routes/+page.svelte`**
```svelte
<main class="flex h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-semibold">Minstrel</h1>
<p class="mt-2 text-text-secondary">
Scaffold &mdash; UI features land in subsequent plans.
</p>
</div>
</main>
```
- [ ] **Step 11: Create `web/.gitignore`**
```
node_modules/
.svelte-kit/
build/*
!build/index.html
```
- [ ] **Step 12: Create placeholder `web/static/favicon.png`**
Run:
```bash
mkdir -p web/static
python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png
```
Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes).
- [ ] **Step 13: Create placeholder `web/build/index.html`**
This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed.
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Minstrel</title>
</head>
<body>
<p>
Minstrel SPA has not been built. Run
<code>cd web &amp;&amp; npm install &amp;&amp; npm run build</code> or
build the container image, which runs the web build during
<code>docker build</code>.
</p>
</body>
</html>
```
- [ ] **Step 14: Install dependencies**
Run: `cd web && npm install`
Expected: `node_modules/` populated, `package-lock.json` written, exit 0.
- [ ] **Step 15: Verify SvelteKit sync + build**
Run: `cd web && npm run build`
Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.)
Undo the local overwrite so the committed placeholder remains:
Run: `git checkout -- web/build/index.html`
Expected: placeholder restored.
- [ ] **Step 16: Commit**
```bash
git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \
web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \
web/src/ web/static/ web/build/index.html
git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static
- SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5
- adapter-static with fallback:'index.html' for SPA deep-link routing
- Tailwind configured with dark-only palette matching spec
- Placeholder landing page at /
- Committed web/build/index.html placeholder (via .gitignore negation) so
go:embed works before the SvelteKit build has been run"
```
---
## Task 2: Vitest smoke test
**Files:**
- Create: `web/vitest.config.ts`
- Create: `web/src/lib/example.test.ts`
- [ ] **Step 1: Create `web/vitest.config.ts`**
```ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts']
}
});
```
- [ ] **Step 2: Write failing smoke test**
Create `web/src/lib/example.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
describe('scaffold smoke', () => {
it('runs vitest', () => {
expect(1 + 1).toBe(2);
});
});
```
- [ ] **Step 3: Run tests**
Run: `cd web && npm test`
Expected: 1 test passes. Exit 0.
- [ ] **Step 4: Commit**
```bash
git add web/vitest.config.ts web/src/lib/example.test.ts
git commit -m "test(web): add Vitest smoke test and jsdom config"
```
---
## Task 3: Go embed package + SPA fallback handler
**Files:**
- Create: `web/embed.go`
- Create: `web/embed_test.go`
- [ ] **Step 1: Write failing tests**
Create `web/embed_test.go`:
```go
package web
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandler_ServesIndexAtRoot(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(strings.ToLower(string(body)), "<html") {
t.Errorf("body missing <html tag; got: %q", string(body))
}
}
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
}
}
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `go test ./web/ -v`
Expected: FAIL — `Handler` undefined / package missing.
- [ ] **Step 3: Implement `web/embed.go`**
```go
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
// Requests for real files under build/ are served verbatim; anything else —
// deep links like /artists/abc, missing asset paths — returns build/index.html
// so the SPA can resolve the route client-side.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:build
var buildFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA.
//
// Behavior:
// - GET / -> build/index.html
// - GET /<path that exists in build/> -> that file, via http.FileServer
// - GET /<anything else> -> build/index.html (SPA fallback)
//
// Callers are expected to register this as the router's NotFound/catch-all so
// explicitly-routed paths (like /api/* and /rest/*) take precedence.
func Handler() http.Handler {
sub, err := fs.Sub(buildFS, "build")
if err != nil {
// fs.Sub on a valid embed path can only fail if the embed directive
// is malformed, which the compile would have caught.
panic("web: fs.Sub(build) failed: " + err.Error())
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
panic("web: embedded build/index.html missing: " + err.Error())
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clean := path.Clean(r.URL.Path)
if clean == "/" || clean == "." {
serveIndex(w, index)
return
}
name := strings.TrimPrefix(clean, "/")
if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
serveIndex(w, index)
})
}
func serveIndex(w http.ResponseWriter, index []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
}
```
- [ ] **Step 4: Run tests — expect PASS**
Run: `go test ./web/ -v`
Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS.
- [ ] **Step 5: Commit**
```bash
git add web/
git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler"
```
---
## Task 4: Wire web handler into server router
**Files:**
- Modify: `internal/server/server.go`
- Modify: `internal/server/server_test.go`
- [ ] **Step 1: Write failing tests**
Add to `internal/server/server_test.go` (also add `"strings"` to the imports):
```go
func TestRouter_ServesSPAAtRoot(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET /: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
}
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatalf("GET deep link: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
}
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/does-not-exist")
if err != nil {
t.Fatalf("GET /api/does-not-exist: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
}
}
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/rest/does-not-exist")
if err != nil {
t.Fatalf("GET /rest/does-not-exist: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `go test ./internal/server/ -v`
Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes).
- [ ] **Step 3: Wire `web.Handler()` into `Router()`**
Modify `internal/server/server.go`:
a) Add imports (preserving alphabetical order within the group):
```go
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
```
b) Replace the `Router()` method with:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
api.Mount(r, s.Pool, s.Logger)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin(s.Pool))
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
}
spa := web.Handler()
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
return
}
spa.ServeHTTP(w, req)
})
return r
}
```
- [ ] **Step 4: Run tests — expect PASS**
Run: `go test ./internal/server/ -v`
Expected: all tests PASS, including the existing `TestHealthz` and the four new ones.
- [ ] **Step 5: Run full test suite**
Run: `go test -short -race ./...`
Expected: PASS.
- [ ] **Step 6: Run linter**
Run: `golangci-lint run ./...`
Expected: no issues.
- [ ] **Step 7: Commit**
```bash
git add internal/server/
git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out
- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
- Everything else falls through to the embedded web handler, which
resolves static assets or returns index.html for SPA deep links"
```
---
## Task 5: Multi-stage Dockerfile
**Files:**
- Rewrite: `Dockerfile`
- [ ] **Step 1: Replace `Dockerfile` contents**
```dockerfile
# syntax=docker/dockerfile:1.6
FROM node:22-bookworm-slim AS web
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 minstrel \
&& useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel
COPY --from=builder /out/minstrel /usr/local/bin/minstrel
COPY config.example.yaml /etc/smartmusic/config.yaml
USER minstrel
EXPOSE 4533
ENTRYPOINT ["/usr/local/bin/minstrel"]
CMD ["--config", "/etc/smartmusic/config.yaml"]
```
- [ ] **Step 2: Build image locally**
Run: `docker build -t minstrel:scaffold-smoke .`
Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`.
- [ ] **Step 3: Inspect image for SPA assets**
Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
Expected: output `ok` (verifies the binary was copied in and is executable).
- [ ] **Step 4: Commit**
```bash
git add Dockerfile
git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA
Web assets are built in the node stage, copied into the go stage before
go build so //go:embed picks them up, then the minimal slim runtime
carries only the final binary and ffmpeg."
```
---
## Task 6: CI — install Node and build web before Go
**Files:**
- Modify: `.forgejo/workflows/test.yml`
- [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents**
```yaml
name: test
# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run
# locally via docker-compose; they should guard with testing.Short().
on:
push:
branches: [dev]
pull_request:
branches: [main]
jobs:
test:
runs-on: go-ci
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install web deps
working-directory: web
run: npm ci
- name: Web build (populates web/build/ for go:embed)
working-directory: web
run: npm run build
- name: Web test (vitest)
working-directory: web
run: npm test
- name: Detect Go project
id: gomod
shell: bash
run: |
if [ -f go.mod ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands"
fi
- name: Toolchain versions
if: steps.gomod.outputs.present == 'true'
run: |
go version
golangci-lint --version
- name: go vet
if: steps.gomod.outputs.present == 'true'
run: go vet ./...
- name: golangci-lint
if: steps.gomod.outputs.present == 'true'
run: golangci-lint run ./...
- name: go test (short, race)
if: steps.gomod.outputs.present == 'true'
run: go test -short -race ./...
```
- [ ] **Step 2: Commit**
```bash
git add .forgejo/workflows/test.yml
git commit -m "ci: install Node + build web before Go steps
Go's //go:embed needs web/build/ to exist at compile time. CI now
runs 'npm ci && npm run build && npm test' ahead of the Go steps
so the embed directive sees real, freshly-built assets."
```
---
## Task 7: README dev-workflow section
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Read current README**
Run: `cat README.md`
Note the existing structure (headings, sections).
- [ ] **Step 2: Append or replace a "Development" section**
Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file.
```markdown
## Development
Minstrel has two concurrent dev processes:
1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`.
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR.
The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`,
so session cookies work correctly when the SPA is loaded from the Vite origin.
### Production build
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage,
copies the output into the `golang` stage, and `//go:embed`s it into the
final binary. The resulting container serves the SPA from `/` alongside the
API surfaces. No separate static-file server is required.
```
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: document two-process dev workflow with Vite proxy"
```
---
## Task 8: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Full Go suite**
Run: `go test -short -race ./...`
Expected: PASS.
- [ ] **Step 2: Go linter**
Run: `golangci-lint run ./...`
Expected: no issues.
- [ ] **Step 3: Web tests + build**
Run: `cd web && npm test && npm run build`
Expected: PASS. Verify `web/build/index.html` was regenerated; then:
Run: `git checkout -- web/build/index.html`
Expected: committed placeholder restored (the fresh build output stays out of version control).
- [ ] **Step 4: Docker build smoke**
Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'`
Expected: image builds; size printed (bytes).
- [ ] **Step 5: Local end-to-end**
Bring up the stack:
```bash
docker compose up --build -d
```
Then in another terminal:
```bash
curl -sI http://localhost:4533/
curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001
curl -sI http://localhost:4533/healthz
curl -sI http://localhost:4533/api/nonexistent
```
Expected responses:
- `GET /``200 OK`, `Content-Type: text/html; charset=utf-8`.
- `GET /artists/…``200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback).
- `GET /healthz``200 OK`, `Content-Type: application/json`.
- `GET /api/nonexistent``401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**.
Tear down:
```bash
docker compose down
```
- [ ] **Step 6: Finish the branch**
Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,319 +0,0 @@
# Web UI Library Reads — Design
**Status:** approved (2026-04-20)
**Follows:** `2026-04-20-web-ui-scaffold-design.md`, `2026-04-20-web-ui-server-auth-foundation` (shipped)
**Precedes:** Plan 3 — stream + cover endpoints
## Goal
Add the read-only library surface to Minstrel's native JSON API at `/api/*`. Five endpoints: paginated artist list (two sort modes), artist detail, album detail, track detail, and unified search. This is the data the SvelteKit SPA needs to render browse and search views; streaming and cover art follow in Plan 3.
Scope explicitly excludes: stream, cover, write operations (favorites, play history), admin endpoints beyond what's already mounted.
## Routes
All gated by the existing `RequireUser` middleware inside `api.Mount`:
```
GET /api/artists?sort={alpha|newest}&limit=&offset=
GET /api/artists/{id}
GET /api/albums/{id}
GET /api/tracks/{id}
GET /api/search?q=&limit=&offset=
```
Path-style IDs (chi `{id}`) — matches REST norms and the SPA router's expectations. Pagination defaults: `limit=50`, `limit` clamped to `[1,200]`, `offset=0`, `offset` clamped to `>=0`. Out-of-range values silently clamp (match subsonic ergonomics); malformed values (non-numeric) return 400.
## Response Shapes
Two-tier Ref/Detail split mirrors the subsonic package's pattern.
### Refs
Lightweight, embedded in list and detail responses.
```go
type ArtistRef struct {
ID string `json:"id"` // UUID string
Name string `json:"name"`
AlbumCount int `json:"album_count"`
}
type AlbumRef struct {
ID string `json:"id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
Year int `json:"year,omitempty"`
TrackCount int `json:"track_count"`
DurationSec int `json:"duration_sec"`
CoverURL string `json:"cover_url"` // /api/albums/{id}/cover — lands in Plan 3
}
type TrackRef struct {
ID string `json:"id"`
Title string `json:"title"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
DurationSec int `json:"duration_sec"`
StreamURL string `json:"stream_url"` // /api/tracks/{id}/stream — Plan 3
}
```
### Details
Wrap a ref with nested children.
```go
type ArtistDetail struct {
ArtistRef
Albums []AlbumRef `json:"albums"`
}
type AlbumDetail struct {
AlbumRef
Tracks []TrackRef `json:"tracks"`
}
// Track detail is just TrackRef — parent names are already embedded.
```
### Pagination Envelope
Shared generic, defined once in `types.go`:
```go
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
```
Used by `/api/artists` and each facet of `/api/search`.
### Search Response
Three independent paged facets sharing one `limit`/`offset`:
```go
type SearchResponse struct {
Artists Page[ArtistRef] `json:"artists"`
Albums Page[AlbumRef] `json:"albums"`
Tracks Page[TrackRef] `json:"tracks"`
}
```
Shared limit/offset (vs subsonic's per-facet params) keeps the SPA simple: one set of pager controls per search page.
### JSON conventions
- UUIDs serialize as strings (not `pgtype.UUID`) via a `uuidToString` helper in `convert.go`.
- Empty collections always render as `[]`, never `null` — web clients reject null. Initialize slices explicitly before encoding.
- Field names: snake_case everywhere.
## Embedded URLs
Forward-reference URLs baked into responses now, even though Plan 3 implements the endpoints:
- `AlbumRef.CoverURL` = `/api/albums/{id}/cover`
- `TrackRef.StreamURL` = `/api/tracks/{id}/stream`
Built by helpers in `convert.go`:
```go
func coverURL(albumID pgtype.UUID) string { return "/api/albums/" + uuidToString(albumID) + "/cover" }
func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" }
```
Relative paths — SPA and Flutter client resolve against configured base URL. Responses stay host-agnostic (reverse-proxy friendly). Until Plan 3, these paths 404; the SPA won't wire `<audio src>` / `<img src>` until Plan 3 ships.
## SQL Queries
All SELECT-only. No migrations. Add to `internal/db/queries/`:
### artists.sql
```sql
-- name: ListArtistsAlpha :many
SELECT * FROM artists ORDER BY sort_name, name LIMIT $1 OFFSET $2;
-- name: ListArtistsNewest :many
SELECT * FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;
-- name: CountArtists :one
SELECT COUNT(*) FROM artists;
-- name: CountArtistsMatching :one
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
```
`ListArtists` (no LIMIT) stays — subsonic's `buildArtistIndexes` still needs it. Don't rewrite that call site; add paginated siblings.
### albums.sql
```sql
-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
```
`SearchAlbums` already exists; it just needed the count sibling.
### tracks.sql
```sql
-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
```
### Notes
- Secondary ordering (`created_at DESC, id DESC`) gives newest sort a stable tiebreaker — prevents pagination drift when two artists share a timestamp.
- Two-query pattern (list + count) is acceptable at M1 sizes (thousands of rows). Revisit with window functions if artists exceed ~100k.
Regenerate sqlc output (`go generate ./...` or the Makefile step) to refresh `internal/db/dbq/*.go`.
## Data Flow
### `GET /api/artists?sort=alpha`
1. Parse `limit`/`offset`/`sort` from query; clamp and default.
2. `q.ListArtistsAlpha` or `q.ListArtistsNewest` (pick by `sort`).
3. `q.CountArtists` for `total`.
4. For each artist, `q.ListAlbumsByArtist``album_count`. N+1, but at `limit=50` acceptable. Revisit with a join query if profiling flags it.
5. Build `Page[ArtistRef]`, JSON-encode.
### `GET /api/artists/{id}`
1. Parse `{id}` as UUID (400 on bad form).
2. `GetArtistByID` (404 on `pgx.ErrNoRows`).
3. `ListAlbumsByArtist(id)` → album list.
4. For each album, `CountTracksByAlbum` (consistent with subsonic; cheap).
5. Return `ArtistDetail` with embedded `[]AlbumRef`.
### `GET /api/albums/{id}`
1. Parse `{id}` as UUID (400).
2. `GetAlbumByID` (404).
3. `GetArtistByID(album.ArtistID)` for artist name.
4. `ListTracksByAlbum(id)` → ordered by disc/track number at the query level (existing behavior).
5. Return `AlbumDetail` with embedded `[]TrackRef`.
### `GET /api/tracks/{id}`
1. Parse `{id}` as UUID (400).
2. `GetTrackByID` (404).
3. `GetAlbumByID(track.AlbumID)` + `GetArtistByID(track.ArtistID)` for parent names.
4. Return `TrackRef` directly (no wrapping detail type — all fields already present).
### `GET /api/search?q=foo`
1. Parse `q` (empty → 400), `limit`, `offset`.
2. Serial: `SearchArtists` + `CountArtistsMatching``Page[ArtistRef]`.
3. Serial: `SearchAlbums` + `CountAlbumsMatching``Page[AlbumRef]` (resolve artist names inline).
4. Serial: `SearchTracks` + `CountTracksMatching``Page[TrackRef]` (resolve album + artist names inline).
5. Return `SearchResponse`.
### Artist-name resolution in album lists
Reuse the pattern from subsonic's `resolveArtistNames`: one query per distinct artist id in the page. For a 50-album page this is ≤50 but typically fewer (same artist repeated). Duplicate the pattern inline in `internal/api` rather than importing from subsonic — tiny helper, clean package boundary.
## Error Handling
Reuse existing `writeErr(w, code, slug, message)` from `internal/api/errors.go`. The SPA's auth code already parses the `{error:{code,message}}` envelope.
| Condition | HTTP | code slug |
|---|---|---|
| No cookie/bearer | 401 | `unauthorized` (from `RequireUser`) |
| Malformed UUID in path | 400 | `bad_request` |
| Row not found | 404 | `not_found` |
| Missing `q` on search | 400 | `bad_request` |
| `limit`/`offset` non-numeric | 400 | `bad_request` |
| DB / query failure | 500 | `server_error` (logged; message generic) |
Out-of-range `limit`/`offset` silently clamp (not 400). Matches subsonic's `clampInt` ergonomics and means the SPA can send whatever value the user picks without validation.
## File Structure
Files to create:
| File | Responsibility |
|---|---|
| `internal/api/library.go` | Artist/album/track list and detail handlers |
| `internal/api/search.go` | Unified search handler |
| `internal/api/convert.go` | uuid↔string, year-from-pgdate, URL builders |
| `internal/api/library_test.go` | Integration tests for library endpoints |
| `internal/api/search_test.go` | Integration tests for search |
| `internal/api/library_fixtures_test.go` | Shared seed helpers (`seedArtist`, `seedAlbum`, `seedTrack`) |
Files to modify:
| File | Change |
|---|---|
| `internal/api/api.go` | Register new routes inside the `authed` group |
| `internal/api/types.go` | Add `Page[T]`, refs, details, `SearchResponse` |
| `internal/db/queries/artists.sql` | Add list + count queries |
| `internal/db/queries/albums.sql` | Add `CountAlbumsMatching` |
| `internal/db/queries/tracks.sql` | Add `CountTracksMatching` |
Regenerated: `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go`.
Approach A was chosen during brainstorming: fresh handlers in `internal/api`, tiny helpers duplicated inline. No shared package with subsonic. The two packages shape responses differently (subsonic wraps in `subsonic-response`; api returns bare JSON envelopes), and the duplicated helpers are ~5 lines each. Revisit extraction during Plan 3 if stream/cover surfaces real duplication.
## Testing
Integration-only, following the `auth_test.go` pattern:
- `testHandlers(t)` helper already exists (spins handlers against `MINSTREL_TEST_DATABASE_URL`, TRUNCATE between tests).
- `library_fixtures_test.go` adds `seedArtist`, `seedAlbum`, `seedTrack` shared across library + search tests.
- No unit tests for `convert.go` helpers — trivial, covered through handler tests.
### Key test cases per endpoint
**`GET /api/artists`:**
- default sort = alpha, paged correctly
- `sort=newest``created_at DESC` order
- `sort=garbage` → 400
- `limit`/`offset` respected, `total` reflects full count
- embedded `album_count` matches fixture
**`GET /api/artists/{id}`:**
- happy path with nested albums
- bad UUID → 400
- unknown UUID → 404
**`GET /api/albums/{id}`:**
- happy path with tracks + artist name + `cover_url` embedded
- tracks ordered by disc/track number
- 400 / 404 parity
**`GET /api/tracks/{id}`:**
- happy path with parent names + `stream_url` embedded
- 400 / 404
**`GET /api/search?q=foo`:**
- three paged facets populated
- empty `q` → 400
- no matches → three `[]` facets, totals = 0
- shared `limit`/`offset` applied per facet
- each facet's `total` reflects full match count
### Explicitly out of scope for tests
- Concurrency / race conditions
- Large-result performance (N+1 tolerance, profiling)
- Stream/cover endpoint behavior (Plan 3)
## Out of Scope (Plan 3 or later)
- `GET /api/tracks/{id}/stream` — audio delivery with range requests
- `GET /api/albums/{id}/cover` — cover art delivery
- Favorites, play history, playlists
- Admin endpoints (scan, user management)
- Multi-library support
- Subsonic client shim (separate track)
@@ -1,268 +0,0 @@
# Web UI Scaffold — Design
**Status:** Approved (brainstorm), ready for implementation plan
**Date:** 2026-04-20
**Milestone:** M1.5 (new) — scaffold for the web UI that M2M5 layer features onto
## Purpose
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2M5 in step with the server.
## Non-goals
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
- Light-theme support (dark theme only for first cut; light mode follows).
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
- Reusing the `/rest/*` Subsonic surface from the SPA.
## Stack
- **Framework:** SvelteKit + TypeScript + Vite.
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
- **State:** Svelte stores. No external state library.
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
### Why Svelte over React/Solid
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
## Packaging
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
- **Dockerfile:** multi-stage.
1. `FROM node:<lts> AS web``npm ci`, `npm run build`, produces `web/build/`.
2. `FROM golang:<ver> AS go``go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
## Dev workflow
- Two processes during development:
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
## API surface
### Principle
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
### First-cut endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
| GET | `/api/artists` | Paginated artist list with sort keys. |
| GET | `/api/artists/{id}` | Artist with albums array. |
| GET | `/api/albums/{id}` | Album with tracks array. |
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
### Implementation approach
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
### Deferred endpoints (land with their backend milestone)
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
- M5: Lidarr proxy + quarantine endpoints.
## Authentication
### Login flow
1. SPA `POST /api/auth/login` with `{username, password}`.
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
### Middleware
- `internal/auth.RequireUser` middleware resolves the caller in this order:
1. `Cookie: session=<token>` → sessions table lookup.
2. `Authorization: Bearer <token>` → sessions table lookup.
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
- Unauthenticated requests to `/api/*``401 {error: {code: "unauthenticated", message: "..."}}`.
### Sessions table
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
### Why this design
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
## Shell layout
### Structure
```
+-------------------------------------------------------------+
| SIDEBAR | MAIN CONTENT |
| | |
| Minstrel | (route content — artists list, |
| | album grid, search results, |
| ▸ Home | artist detail, settings, etc.) |
| ▸ Library | |
| ▸ Search | |
| ▸ Recent | |
| ▸ Settings | |
| | |
| — Admin — | |
| ▸ Users | |
| ▸ Scan | |
| ▸ Quarantine | |
| | |
+-------------------------------------------------------------+
| PLAYER BAR: art · title / artist · controls · scrub · time |
+-------------------------------------------------------------+
```
### Components
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
### Player state (Svelte store)
```typescript
interface PlayerState {
queue: Track[];
queueIndex: number;
isPlaying: boolean;
currentTime: number; // seconds
duration: number; // seconds
volume: number; // 0..1
}
```
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
- The store is the only place that writes player state; components read via `$player` subscriptions.
### Theme
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
### Keyboard shortcuts
- `Space` — play/pause (blocked when focus is in an input).
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
- `/` — focus search input.
## First-cut feature scope
- Login / logout.
- Artist list (sortable, searchable within-list).
- Artist detail → albums grid.
- Album detail → track list with inline play buttons.
- Global search: `?q=` against tracks + albums + artists, results grouped.
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
## Deferred
Tracked in Fable milestones 2730 (M2M5). Each milestone adds the corresponding UI alongside its backend work.
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
- **M3:** Smart-shuffle entry points, contextual-like controls.
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
## Testing
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
## Repo structure
```
minstrel/
├── cmd/minstrel/ # Go binary (unchanged)
├── internal/
│ ├── api/ # NEW: /api/* handlers + shared types
│ │ ├── auth.go
│ │ ├── library.go
│ │ ├── search.go
│ │ ├── stream.go
│ │ ├── types.go
│ │ └── *_test.go
│ ├── auth/ # existing; add session token logic
│ ├── subsonic/ # unchanged
│ ├── library/ # unchanged
│ ├── db/ # + migration for sessions
│ └── server/ # Router() wires /api/* alongside /rest/*
├── web/ # NEW: SvelteKit project
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # fetch wrapper
│ │ │ ├── stores/
│ │ │ │ ├── player.ts
│ │ │ │ └── auth.ts
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
│ │ ├── routes/
│ │ │ ├── +layout.svelte
│ │ │ ├── +page.svelte # Home
│ │ │ ├── login/+page.svelte
│ │ │ ├── artists/+page.svelte
│ │ │ ├── artists/[id]/+page.svelte
│ │ │ ├── albums/[id]/+page.svelte
│ │ │ ├── search/+page.svelte
│ │ │ └── settings/+page.svelte
│ │ └── app.html
│ ├── static/
│ ├── svelte.config.js # adapter-static + fallback
│ ├── vite.config.ts # /api + /rest proxy for dev
│ ├── package.json
│ └── tsconfig.json
├── docs/
├── Dockerfile # multi-stage: node → go → slim
├── docker-compose.yml
└── ...
```
## Open questions resolved
- **Framework:** Svelte (B in brainstorm).
- **Packaging:** Single binary with embedded static (A in brainstorm).
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
- **API shape:** New `/api/*` surface (B in brainstorm).
- **Scope:** Rich target, scaffold first, features woven through M2M5 (C in brainstorm with sequencing plan).
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
## Risks / decisions to revisit during implementation
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.
@@ -1,177 +0,0 @@
# Web UI Media Endpoints — Design Spec
**Date:** 2026-04-21
**Status:** Design approved
**Follows:** [Web UI Library Reads](2026-04-20-web-ui-library-reads-design.md) — this spec implements the `cover_url` and `stream_url` forward references shipped in Plan 2.
## Goal
Ship the two byte-serving endpoints the web SPA (and later the Flutter client) need to render album art and play tracks. When this lands, every `<img src>` and `<audio src>` tag that Plan 2's DTOs produced will resolve.
## Non-goals
Explicit YAGNI — documented here so they don't drift back in during implementation:
- No cover resizing (`?size=` param). Serving one size matches subsonic's current behavior; re-sizing is a future optimization when the SPA shows it's needed.
- No audio transcoding (`?format=`, `?maxBitRate=`). Raw bytes only.
- No `/download` attachment-mode variant. `/stream` is enough for the SPA; a separate download endpoint can land later if a concrete caller needs it.
- No scanner changes to populate `albums.cover_art_path`. The sidecar fallback covers the common real-world case today; improving scanner coverage is its own plan.
## Architecture
Two endpoints, both under `RequireUser` in the existing `/api` Mount:
- `GET /api/albums/{id}/cover` → album cover image
- `GET /api/tracks/{id}/stream` → raw audio bytes, Range-capable
**Package isolation.** `internal/subsonic/stream.go` already implements the same two behaviors for `/rest/*`. Per project direction, subsonic is long-term legacy and stays frozen. The protocol-agnostic helpers (mime tables, sidecar lookup, `http.ServeContent` glue) are duplicated into `internal/api/media.go` rather than extracted into a shared package. The only shared layer is `internal/db/dbq` (already) and `http.ServeContent` from stdlib.
**Auth.** No new middleware. `auth.RequireUser` (in `internal/auth/session.go`) already accepts both the `minstrel_session` cookie and `Authorization: Bearer <token>`. Browser `<img>` and `<audio>` tags send cookies automatically; the Flutter client will carry the bearer token.
**Range / ETag / If-Modified-Since.** Delegated to `http.ServeContent`, which reads headers from the request and writes the correct 206/304/200 response plus `Content-Range` / `ETag` / `Last-Modified`. No hand-rolled Range parsing.
## Routes
| Method | Path | Auth | Success response |
|---|---|---|---|
| GET | `/api/albums/{id}/cover` | `RequireUser` | 200 (or 304) with image bytes; `Content-Type` inferred from file extension (`image/jpeg`, `image/png`, `image/webp`, `image/gif`) |
| GET | `/api/tracks/{id}/stream` | `RequireUser` | 200 / 206 / 304 with audio bytes; `Content-Type` from `track.file_format` (e.g. `audio/mpeg`, `audio/flac`, `audio/ogg`); `Accept-Ranges: bytes`; `Content-Length` set by `http.ServeContent` |
Both routes register inside the existing `authed` chi group in `internal/api/api.go` (alongside the Plan 2 library routes). Production `Mount` wires them; the test-only `newLibraryRouter` helper is extended to include them so integration tests can exercise the routes end-to-end.
## Data flow
### `GET /api/albums/{id}/cover`
1. Parse `{id}` via `parseUUID` (the helper from `internal/api/convert.go`). Malformed UUID → 400 `bad_request` "invalid album id".
2. `q.GetAlbumByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "album not found". Other err → 500 `server_error`.
3. `resolveAlbumCoverPath(ctx, q, album)`:
- If `album.CoverArtPath != nil` and the file stats successfully, return that path.
- Otherwise, list tracks for the album (`q.ListTracksByAlbum`, which orders by disc/track number). For the first track in that ordered result, look in its directory for `cover.jpg`, `cover.jpeg`, `cover.png`, `folder.jpg`, `folder.jpeg`, `folder.png` in that order. Return the first file that exists; else return `""`.
4. Empty path return → 404 `not_found` "cover not found".
5. `os.Open` + `f.Stat`. File vanished since resolve → 404 `not_found` "cover not found". Stat err → 500.
6. Set `Content-Type` from `imageContentType(path)` (file-extension-based mapping). Call `http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)`.
### `GET /api/tracks/{id}/stream`
1. Parse `{id}`. Malformed UUID → 400 `bad_request` "invalid track id".
2. `q.GetTrackByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "track not found". Other err → 500.
3. `os.Open(track.FilePath)`. File vanished → 404 `not_found` "track file not found". Other err → 500.
4. `f.Stat`. Err → 500.
5. Set `Content-Type` from `audioContentType(track.FileFormat)`. Set `Accept-Ranges: bytes`. Call `http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)`.
## Error shape
All errors use the existing `writeErr` helper → `{"error":{"code":"...","message":"..."}}`, matching the rest of `/api/*`. Codes reused from Plan 2: `bad_request`, `not_found`, `server_error`.
| Condition | Status | Code | Message |
|---|---|---|---|
| Malformed UUID in path | 400 | `bad_request` | `invalid album id` / `invalid track id` |
| Unknown album / track | 404 | `not_found` | `album not found` / `track not found` |
| Album has no resolvable cover | 404 | `not_found` | `cover not found` |
| Track file missing on disk | 404 | `not_found` | `track file not found` |
| DB lookup failure | 500 | `server_error` | `server error` |
| `os.Stat` failure on known path | 500 | `server_error` | `server error` |
## Components
Single new file: `internal/api/media.go`. Contains both handlers and their duplicated helpers.
```
handlers
handleGetCover(w, r)
handleGetStream(w, r)
free functions (package-private)
resolveAlbumCoverPath(ctx, q, album) string
findSidecarCover(trackDir string) string
audioContentType(format string) string
imageContentType(path string) string
```
`audioContentType` table (mirrors subsonic's `contentTypeForFormat`, duplicated verbatim):
| file_format | Content-Type |
|---|---|
| `mp3` | `audio/mpeg` |
| `flac` | `audio/flac` |
| `ogg` | `audio/ogg` |
| `opus` | `audio/ogg` |
| `m4a` | `audio/mp4` |
| `aac` | `audio/aac` |
| `wav` | `audio/wav` |
| *(other)* | `application/octet-stream` |
`imageContentType` table:
| extension (lowercase) | Content-Type |
|---|---|
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.png` | `image/png` |
| `.webp` | `image/webp` |
| `.gif` | `image/gif` |
| *(other)* | `application/octet-stream` |
Sidecar lookup order (first match wins):
```
cover.jpg, cover.jpeg, cover.png,
folder.jpg, folder.jpeg, folder.png
```
## Testing
Integration tests in `internal/api/media_test.go`. They reuse the existing `testHandlers(t)`, `truncateLibrary(t, pool)`, `seedArtist`, `seedAlbum`, `seedTrack` helpers from `library_fixtures_test.go`, and add a new helper:
- `seedTrackWithFile(t, pool, albumID, artistID, title string, fileBody []byte, ext string) (dbq.Track, string)` — writes `fileBody` into `t.TempDir()` with the given extension, inserts a Track row whose `file_path` points at it, returns the track and the dir.
### Cover tests
- **Happy path (sidecar):** seed album + track, drop a known-bytes `cover.jpg` in the track's dir. GET cover → 200, `Content-Type: image/jpeg`, body equals the file bytes.
- **Happy path (explicit `cover_art_path`):** seed album with `cover_art_path` pointing at a separate image file (no sidecar). GET → 200 with the right file's bytes.
- **Explicit path missing on disk falls through to sidecar:** set `cover_art_path` to a non-existent path, drop a sidecar. GET → 200 with the sidecar bytes.
- **No art anywhere:** seed album + track, no sidecar, no `cover_art_path`. GET → 404 `not_found` "cover not found".
- **Bad UUID:** `GET /api/albums/not-a-uuid/cover` → 400 `bad_request` "invalid album id".
- **Unknown album:** `GET /api/albums/<random UUID>/cover` → 404 `not_found` "album not found".
- **Content-Type per extension:** table-driven test invoking `imageContentType` on `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, and a bogus extension.
### Stream tests
- **Happy path:** seed track with a known-bytes payload (32 KB of random-but-fixed bytes, `.mp3` extension, `file_format="mp3"`). GET → 200, `Content-Type: audio/mpeg`, `Accept-Ranges: bytes`, body equals the file bytes, `Content-Length` matches.
- **Range request:** `Range: bytes=0-99` → 206 Partial Content, body is exactly the first 100 bytes, `Content-Range: bytes 0-99/32768`.
- **Range at end:** `Range: bytes=32000-` → 206, body is the tail, `Content-Range: bytes 32000-32767/32768`.
- **If-Modified-Since pass-through:** second GET with `If-Modified-Since` set to the file's mod time → 304.
- **Bad UUID:** `GET /api/tracks/not-a-uuid/stream` → 400 `bad_request` "invalid track id".
- **Unknown track:** random UUID → 404 `not_found` "track not found".
- **Vanished file:** seed track pointing at `filepath.Join(tempDir, "does-not-exist.mp3")` → 404 `not_found` "track file not found".
- **Content-Type per format:** table-driven test invoking `audioContentType` on all seven known formats plus `"unknown"`.
### Route registration regression
Extend `TestRoutesRegisteredInMount` (from Plan 2's Task 11) to also assert the two new paths return 401 (not 404) unauthenticated:
```
"/api/albums/00000000-0000-0000-0000-000000000001/cover",
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
```
## File structure
| File | Change | Purpose |
|---|---|---|
| `internal/api/media.go` | Create | Both handlers + locally-duplicated helpers |
| `internal/api/media_test.go` | Create | Integration tests for both endpoints, unit tests for mime helpers |
| `internal/api/library_fixtures_test.go` | Modify | Add `seedTrackWithFile` helper |
| `internal/api/api.go` | Modify | Register both new routes inside the `authed` group in `Mount` |
| `internal/api/library_test.go` | Modify | Extend `TestRoutesRegisteredInMount` to include the two new paths; extend the `newLibraryRouter` helper to register the media routes so integration tests can exercise them through the same test harness |
No migrations. No new sqlc queries (existing `GetAlbumByID`, `GetTrackByID`, `ListTracksByAlbum` are sufficient). No package-level dependencies beyond the stdlib and what `/api` already imports.
## Success criteria
- Both endpoints reachable through the production `Mount` under `RequireUser`.
- Browser `<img src="/api/albums/{id}/cover">` renders the art (or triggers `onerror` cleanly on 404).
- Browser `<audio src="/api/tracks/{id}/stream">` plays and can seek (verifies Range).
- Integration test suite green, no flakes.
- Manual smoke: log in, curl each endpoint, verify returns + Range behavior.
- PR merges to `main` via the existing `dev``main` flow.
@@ -1,256 +0,0 @@
# Web UI Auth — Design Spec
**Date:** 2026-04-22
**Status:** Design approved
**Follows:** [Web UI Scaffold](2026-04-20-web-ui-scaffold-design.md) — this spec is the first SPA feature on top of the scaffold.
## Goal
Let a browser visitor sign in with a username and password, reach a protected app shell, and sign out again. After this lands, every subsequent sub-plan (library views, search, player) builds on top of a known-authenticated `user` store, a persistent Shell component, and a typed HTTP client.
## Non-goals
Explicit YAGNI — kept out of this plan so the scope stays tight:
- No self-service sign-up or password reset. Users are admin-provisioned; a forgotten password is a DBA problem for now.
- No "remember me" / persistent-session checkbox. The server cookie already has a 30-day `MaxAge`; every login is long-lived.
- No OAuth / OIDC / third-party sign-in. Deferred (see memory — OIDC is pushed to post-v1).
- No multi-tab logout propagation via `BroadcastChannel`. If you log out in one tab, the other tab will discover it on the next API call (401 → silent logout). Good enough.
- No rate-limiting UX on the login form. The backend already 401s on bad creds; brute-force mitigation is a server-side concern tracked elsewhere.
## Architecture
Three layers stacked under the SvelteKit app:
1. **Transport (`lib/api/`).** `apiFetch(path, init)` hits the relative `/api/*` origin, sets `Content-Type: application/json`, parses the JSON response, and throws a typed `ApiError{code, message, status}` on any non-2xx. A thin facade `api.get<T>`, `api.post<T>`, `api.del` wraps it. 401 responses trigger `auth.logout({silent: true})` before the error propagates.
2. **Auth state (`lib/auth/`).** A Svelte 5 rune-based store — `let _user = $state<User|null>(null)` plus helpers `bootstrap()`, `login(u, p)`, `logout()`. The store is the single synchronous source of truth for "is the current user authenticated, and if so who."
3. **Query layer (`lib/query/`).** TanStack Query's Svelte 5 adapter. A `QueryClient` is instantiated once in the root layout and installed via `<QueryClientProvider>`. Components consume data with `createQuery({queryKey, queryFn: () => api.get<T>(path)})`. `auth.logout()` calls `queryClient.clear()` so stale data doesn't leak to the next user.
**Bootstrap timing is load-time, not render-time.** The root `+layout.ts` `load()` awaits `auth.bootstrap()` before SvelteKit renders, so `+layout.svelte` sees the store already populated. There is no "flash of login screen" on refresh.
## File structure
New files under `web/`:
```
src/
├── lib/
│ ├── api/
│ │ ├── client.ts # apiFetch + api.{get,post,del} + ApiError + User/LoginResponse types
│ │ └── client.test.ts
│ ├── auth/
│ │ ├── store.svelte.ts # $user rune, bootstrap/login/logout
│ │ └── store.test.ts
│ ├── query/
│ │ └── client.ts # export const queryClient = new QueryClient({...})
│ └── components/
│ ├── Shell.svelte # header + sidebar + <main> slot
│ └── Shell.test.ts
├── routes/
│ ├── +layout.ts # load() → await auth.bootstrap()
│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare <slot/>
│ ├── +page.svelte # placeholder "Library" — replaced by the library plan
│ ├── login/
│ │ ├── +page.svelte # LoginForm
│ │ └── +page.test.ts
│ ├── search/+page.svelte # "Coming soon" placeholder
│ └── playlists/+page.svelte # "Coming soon" placeholder
└── app.d.ts # (untouched)
```
Files that change:
- `web/package.json` — add `@tanstack/svelte-query`, `@testing-library/svelte`, `@testing-library/jest-dom`.
- `web/vitest.config.ts` — include `./vitest.setup.ts` to install jest-dom matchers.
- `web/src/routes/+page.svelte` — replace the scaffold placeholder with a bare "Library" placeholder that survives into the library plan.
## Routes
| Path | Guard | Component |
|---|---|---|
| `/login` | public; if already authenticated, navigate to `/` | `routes/login/+page.svelte` |
| `/` | guarded | Shell + `routes/+page.svelte` (Library placeholder) |
| `/search` | guarded | Shell + placeholder |
| `/playlists` | guarded | Shell + placeholder |
| (anything else) | guarded | Shell + whatever the URL resolves to, or 404 |
The guard lives in `+layout.svelte` (not per-page `+page.ts`). On every render, it checks `user.value`. If `null` and path is not `/login`, it calls `goto('/login?returnTo=' + encodeURIComponent(path), {replaceState: true})`. If non-null and path is `/login`, it calls `goto('/', {replaceState: true})`. Otherwise it renders `<Shell><slot/></Shell>` (authed) or `<slot/>` (bare `/login`).
## `apiFetch` contract
```ts
// lib/api/client.ts
export type ApiError = { code: string; message: string; status: number };
export type User = { id: string; username: string; is_admin: boolean };
export type LoginResponse = { token: string; user: User };
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
});
const body = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) {
if (res.status === 401) {
// Avoid a circular import: lazy-require the auth store.
const { logout } = await import('$lib/auth/store.svelte');
logout({ silent: true });
}
const err = (body && body.error) || { code: 'unknown', message: res.statusText };
throw { ...err, status: res.status } as ApiError;
}
return body;
}
export const api = {
get: <T>(p: string) => apiFetch(p) as Promise<T>,
post: <T>(p: string, b: unknown) =>
apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise<T>,
del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise<void>,
};
```
Notes:
- `credentials: 'same-origin'` is correct for both the Vite proxy (dev) and the embedded production serve — both are same-origin. Cross-origin would need `'include'` plus a CORS policy, which the backend does not currently implement.
- `body.error` matches the backend's error envelope: `{"error":{"code":"...","message":"..."}}` (see `internal/api/errors.go`). Non-JSON error bodies degrade to `{code:'unknown', message: statusText}` — pragmatic.
- Lazy-importing `$lib/auth/store.svelte` inside the 401 branch avoids a circular import (`auth/store` imports `api`).
## `auth` store contract
```ts
// lib/auth/store.svelte.ts
import { api, type User, type LoginResponse } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
let _user = $state<User | null>(null);
export const user = { get value() { return _user; } };
export async function bootstrap(): Promise<void> {
try { _user = await api.get<User>('/api/me'); }
catch { _user = null; }
}
export async function login(username: string, password: string): Promise<void> {
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
_user = res.user;
}
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
if (!opts.silent) {
try { await api.post('/api/auth/logout', {}); } catch { /* best effort */ }
}
_user = null;
queryClient.clear();
}
```
The `silent: true` path is used only by `apiFetch`'s 401 interceptor. Without it, a 401 response would trigger a logout POST that itself 401s — pointless round-trip. Everywhere else (the Shell's "Log out" button), `logout()` is called without flags and does the full round-trip.
## Login page
**Layout.** Centered card on the dark palette. Minstrel wordmark above the card. The card contains the form: label+input for username, label+input for password, a full-width "Sign in" button, and a slot for error text below the button.
**Behavior.**
1. Username and password are bound to local `$state` with `$state('')`.
2. On submit (button click or Enter in either field):
- Set `pending = true`. Disable the button and set `aria-busy="true"`.
- `await login(username, password)`.
- On success: read `returnTo` from `$page.url.searchParams`; validate it matches `/^\/(?!\/)/` (starts with exactly one `/`, not `//`, which would be a protocol-relative URL) AND does not start with `/login`; `goto(validatedReturnTo || '/', {replaceState: true})`.
- On `ApiError{code: 'invalid_credentials', status: 401}`: set `error = 'Invalid username or password.'`, clear password.
- On any other error: set `error = 'Sign-in unavailable. Try again in a moment.'`, leave fields intact.
- Finally: `pending = false`.
3. Enter in either input triggers form `submit`.
4. The error region has `role="alert"` so screen readers announce it on change.
## Shell component
**Structure.**
```
<div class="h-screen grid grid-cols-[auto_1fr] grid-rows-[auto_1fr]">
<header class="col-span-2 ...">
Minstrel [username ▾] (dropdown has "Log out")
</header>
<nav class="hidden md:block ...">
<a href="/">Library</a>
<a href="/search">Search</a>
<a href="/playlists">Playlists</a>
</nav>
<main class="overflow-y-auto">{@render children()}</main>
</div>
```
- Sidebar hides below `md:`. (Mobile gets a hamburger button in a later plan — not this one.)
- Active nav item uses `aria-current="page"` so the active-styling CSS selector is semantic.
- The user-menu dropdown is a minimal disclosure: click to toggle, outside-click closes, Escape closes. Inside it is a single `<button>Log out</button>` that calls `auth.logout()` then `goto('/login', {replaceState: true})`.
## Error handling summary
| Source | Shape | User sees |
|---|---|---|
| Login, bad creds | `ApiError{code:'invalid_credentials', status:401}` | Inline text under form: "Invalid username or password." |
| Login, server 500 / network | `ApiError{status:500}` or fetch rejection | Inline text: "Sign-in unavailable. Try again in a moment." |
| 401 mid-session on any API call | `ApiError{code:'unauthorized', status:401}` | Store clears; guard navigates to `/login?returnTo=<current>`; no visible error |
| Non-2xx elsewhere | `ApiError` thrown into TanStack Query | TanStack Query `error` state; each consuming component renders its own UI (out of scope for this plan — the library plan handles it) |
## Testing
**Unit (Vitest, jsdom).**
- `api/client.test.ts``fetch` stubbed via `vi.stubGlobal`.
- `api.get` resolves with parsed JSON on 200.
- `api.post` serializes body and sets `Content-Type`.
- Non-2xx throws `ApiError` with `code`, `message`, `status`.
- 401 calls `auth.logout({silent: true})` — spy on the dynamically-imported `logout` via module mocking.
- 204 resolves to `null` without calling `.json()`.
- JSON parse failure on an error body degrades to `{code:'unknown', message: statusText}`.
- `auth/store.test.ts``api` module mocked.
- `bootstrap()` sets `user` on success; leaves `null` on rejection.
- `login()` stores the returned `user`.
- `logout()` calls `api.post('/api/auth/logout', {})`, clears `user`, calls `queryClient.clear()`.
- `logout({silent: true})` does NOT call `api.post`; still clears store and cache.
- `components/Shell.test.ts` — render with a non-null `user`.
- Header shows `user.username`.
- Sidebar has exactly three `<a>` elements to `/`, `/search`, `/playlists`.
- When `$page.url.pathname === '/'`, the Library link has `aria-current="page"`.
- Clicking the user menu reveals "Log out"; outside-click hides it.
**Component test — `routes/login/+page.test.ts`** (using `@testing-library/svelte`).
- Renders two inputs + submit button.
- Invalid creds: stub `api.post` to reject with `{code:'invalid_credentials', status:401}`; submit; assert inline "Invalid username or password." is shown; password input is empty; username input is preserved.
- Server error: stub to reject with `{status:500}`; assert generic error message; both inputs preserved.
- Success: stub to resolve; set `?returnTo=/artists/abc` in `$page.url`; assert `goto` called with `/artists/abc` and `{replaceState: true}`.
- Invalid `returnTo` variants: `http://evil.com`, `//evil.com`, `/login` → each should fall back to `goto('/')`, never the original value.
- Loading: during a pending promise, `aria-busy="true"` on the button and button is disabled.
**Manual integration (Plan Task 8).**
- `npm run dev` with backend running on `:4533`. Seed a user via the existing auth plan's flow.
- Visit `/artists/abc` → redirected to `/login?returnTo=%2Fartists%2Fabc`.
- Sign in → land on `/artists/abc` (which 404s from SPA fallback — that's expected; library plan fixes it).
- Hard-refresh `/` while authenticated → Shell renders immediately, no login flash.
- Click user menu → "Log out" → redirected to `/login`. Visit `/` → redirected back to `/login`.
- In a second tab, also authenticated: open dev tools, delete the `minstrel_session` cookie manually, navigate in the SPA → auto-redirected to `/login`.
## Dependencies added
| Package | Kind | Why |
|---|---|---|
| `@tanstack/svelte-query` | runtime | Data-layer caching/invalidation per design section 1. |
| `@testing-library/svelte` | dev | Component test rendering. |
| `@testing-library/jest-dom` | dev | `toBeInTheDocument`, `toHaveAttribute`, etc. |
Versions pinned to latest Svelte-5-compatible majors at plan-writing time.
## Success criteria
- A user can sign in with valid creds, see the Shell, navigate to `/`, `/search`, `/playlists`, and log out.
- Deep link `/artists/<any>` while signed out lands on `/login?returnTo=<path>`; after login, lands on `<path>`.
- Refreshing any guarded page while authenticated renders immediately with no login flash.
- A 401 on any API call silently clears auth and redirects to `/login`.
- All unit and component tests pass in CI (`npm test` in the `web/` directory).
- No backend changes required; the plan is pure frontend.
@@ -1,320 +0,0 @@
# Web UI Library Views — Design Spec
**Date:** 2026-04-23
**Status:** Design approved
**Follows:** [Web UI Auth](2026-04-22-web-ui-auth-design.md) — this spec is the first data-consuming feature on top of the auth foundation.
## Goal
Let an authenticated user browse the server's music library — artists, artist detail with albums, album detail with tracks — entirely in the SPA. After this lands, `/` is a real library index, deep links like `/artists/abc-123` resolve to pages with data, and the `cover_url`/`stream_url` forward references emitted by the JSON API finally have consumers.
## Non-goals
Explicit YAGNI — documented so they don't drift back in:
- No track detail page. Track metadata is already visible inline in the album view; a dedicated `/tracks/:id` page would show identical fields with no action affordances (the player plan adds playback; that's where click-to-play belongs).
- No search UI. `/search` keeps the auth plan's "Coming soon" placeholder. Search deserves its own sub-plan — different UX concerns (debouncing, faceted results, empty states).
- No playback. All track rows are non-interactive; `<audio>` never mounts. The player sub-plan wires everything.
- No infinite scroll, virtualization, or hover-play previews. "Load more" is enough; we upgrade when the library actually feels slow.
- No "Recently Added" / recommendation surfaces. We don't have play signals yet.
- No theme switcher. Dark-only.
- No artist images / avatars from MusicBrainz or similar. Initial-letter circle is the placeholder forever (or until we add something better in a dedicated plan).
## Architecture
**Three routes, three queries.** Route-per-view matches every widely-used music browser (Navidrome, Plex, Spotify web):
| Route | Page responsibility | Query |
|---|---|---|
| `/` | Artists list with sort + pagination | `createInfiniteQuery(['artists', {sort}])` |
| `/artists/:id` | Artist detail — name header, albums grid | `createQuery(['artist', id])` |
| `/albums/:id` | Album detail — hero with cover, track list | `createQuery(['album', id])` |
Modal-detail layouts (single-page with overlays) and three-pane split views were considered and rejected — they either break deep-linking or fight the existing sidebar Shell.
**Data layer.** TanStack Query owns all fetches and caching. A small `web/src/lib/api/queries.ts` module owns the query-key convention and exports `createArtistsQuery`, `createArtistQuery`, `createAlbumQuery` helpers so every page imports the same plumbing.
Query-key convention:
```ts
['artists', { sort: 'alpha' | 'newest' }] // infinite query; pageParam = offset
['artist', id] // single artist + nested albums
['album', id] // single album + nested tracks
```
**Component decomposition.** Each repeating visual unit is its own small Svelte component. Pages stay short; components stay testable in isolation.
- `ArtistRow` — one row in the artists list. 40×40 avatar (first letter), name, album count, chevron. Whole row is an `<a href="/artists/:id">`.
- `AlbumCard` — one card in the albums grid. Cover image, title, year. `<a href="/albums/:id">`.
- `TrackRow` — one row in the album track table. Non-interactive until the player plan.
- `LibrarySkeleton` — shimmer placeholder with `variant="list" | "grid" | "album"` and `count` props.
- `ApiErrorBanner` — retry-capable error card shared by all three pages.
**Cache lifecycle.** `queryClient.clear()` already runs on logout (auth plan), so library data from user A never leaks to user B. `staleTime: 30_000` from the existing query-client defaults means tab-switching back within 30s avoids refetch; anything older is background-refreshed on mount.
## File structure
**New files under `web/src/`:**
```
lib/
├── api/
│ ├── queries.ts # qk.* helpers + createArtistsQuery/createArtistQuery/createAlbumQuery
│ └── types.ts # ArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>
├── components/
│ ├── ArtistRow.svelte
│ ├── ArtistRow.test.ts
│ ├── AlbumCard.svelte
│ ├── AlbumCard.test.ts
│ ├── TrackRow.svelte
│ ├── TrackRow.test.ts
│ ├── LibrarySkeleton.svelte
│ ├── LibrarySkeleton.test.ts
│ ├── ApiErrorBanner.svelte
│ └── ApiErrorBanner.test.ts
├── media/
│ ├── covers.ts # FALLBACK_COVER data URL + coverUrl(id) helper
│ └── duration.ts # formatDuration(seconds): '3:42' or '1:02:03'
└── utils/
└── useDelayed.svelte.ts # delayed-boolean rune helper for skeleton suppression
routes/
├── +page.svelte # Library root — artists list (REPLACE existing placeholder)
├── artists.test.ts # tests for the artists list page
├── artists/
│ └── [id]/
│ ├── +page.svelte
│ └── artist.test.ts
└── albums/
└── [id]/
├── +page.svelte
└── album.test.ts
test-utils/
└── query.ts # shared vi.mock helpers for @tanstack/svelte-query
```
**Modified files:**
| File | Change |
|---|---|
| `web/src/routes/+page.svelte` | Replace "Library" placeholder with the real artists list. |
Nothing touches the `lib/api/client.ts` transport — the api facade from the auth plan is sufficient. Nothing touches `lib/auth/` — auth is a done surface.
## `queries.ts` shape
```ts
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
import { api } from './client';
import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types';
export type ArtistSort = 'alpha' | 'newest';
const ARTIST_PAGE_SIZE = 50;
export const qk = {
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
artist: (id: string) => ['artist', id] as const,
album: (id: string) => ['album', id] as const,
};
export function createArtistsQuery(sort: ArtistSort) {
return createInfiniteQuery({
queryKey: qk.artists(sort),
queryFn: ({ pageParam = 0 }) =>
api.get<Page<ArtistRef>>(
`/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`
),
initialPageParam: 0,
getNextPageParam: (last) => {
const loaded = last.offset + last.items.length;
return loaded >= last.total ? undefined : loaded;
},
});
}
export function createArtistQuery(id: string) {
return createQuery({
queryKey: qk.artist(id),
queryFn: () => api.get<ArtistDetail>(`/api/artists/${id}`),
});
}
export function createAlbumQuery(id: string) {
return createQuery({
queryKey: qk.album(id),
queryFn: () => api.get<AlbumDetail>(`/api/albums/${id}`),
});
}
```
`types.ts` — new file mirroring the backend `internal/api/types.go` shapes (`ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page<T>`). Lives next to `client.ts` for discoverability.
## Per-page layout
### Artists list — `/`
- **Top bar**: left "Library" title (h1); right, `<select>` bound to `?sort=alpha|newest` via `goto('?sort=...', {replaceState: true})`. Query key re-derives from URL; TanStack Query fetches the new sort. Subtitle below: "1,247 artists" (pulled from the first page's `total`).
- **Body**: stacked `<ArtistRow>` items. Dark row background with lighter hover. Each row ~60px tall: 40×40 circle avatar (first letter), name, muted-color `N albums`, chevron.
- **Footer**: full-width `<button class="Load more">`. Disabled + internal spinner while `isFetchingNextPage`. Becomes `<p>End of library</p>` when `hasNextPage === false`.
- **Empty state** (total === 0): "No artists yet — scan a library folder via the server's config."
- **Loading state** (isPending): `<LibrarySkeleton variant="list" count={12} />`.
- **Error state**: `<ApiErrorBanner error={query.error} onRetry={query.refetch} />`.
### Artist detail — `/artists/:id`
- **Top**: back chevron link "← Library" that routes to `/`. h1 with artist name; subtitle `N albums`.
- **Body**: responsive grid of `<AlbumCard>`. Tailwind breakpoints — `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5`. Each card ~200px: cover (square), title, year below; hover scales the cover via `hover:scale-[1.03] transition-transform`.
- **Empty state**: "This artist has no albums in the library." (Rare — backend only surfaces artists that have at least one album; included for defense in depth.)
- **Not-found state** (ApiError `{code: 'not_found', status: 404}`): "Artist not found" with a back link.
- **Loading / error**: standard skeleton + banner patterns.
### Album detail — `/albums/:id`
- **Top**: back chevron link "← {artistName}" routing to `/artists/:artistId`.
- **Hero**: flex row on `md:+`, column stack on mobile. Left/top: 240px square cover. Right/bottom: h1 title, artist name as a link to `/artists/:artistId`, year (if present), `N tracks · H:MM:SS` total duration.
- **Body**: track table. Columns: `#` (32px right-aligned), title, duration (right-aligned). Track rows alternate subtle background tint. No play icons, no hover effects — player plan adds those. Duration renders via `formatDuration(seconds)``mm:ss` when under an hour (`242 → "4:02"`), `h:mm:ss` at or above (`3723 → "1:02:03"`).
- **Empty state**: "This album has no tracks." (Rare; defense in depth.)
- **Not-found state**: "Album not found" with a back link to `/`.
- **Loading**: skeleton with cover-sized gray square + title/metadata bars + ~10 gray track rows.
## Cover rendering
```svelte
<img
src="/api/albums/{id}/cover"
alt=""
class="aspect-square w-full object-cover"
loading="lazy"
onerror={(e) => {
// fallback: inline SVG data URL with a music-note glyph on the surface color
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}}
/>
```
- Same-origin in dev (Vite proxy) and prod (embedded SPA) — the browser attaches the `minstrel_session` cookie automatically.
- `loading="lazy"` means off-screen cards in the artist grid don't issue requests until scrolled.
- Alt text is empty because the visible label (album title) is adjacent and screen readers read both; doubling the announcement is worse than skipping.
- `FALLBACK_COVER` is a module-level data-URL constant exported from a small `covers.ts` helper.
## Loading/error patterns
**Delayed skeleton.** A small shared helper prevents flash-of-skeleton on cache hits:
```ts
// lib/utils/useDelayed.svelte.ts
export function useDelayed(getValue: () => boolean, delayMs = 100) {
let delayed = $state(false);
let timeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const v = getValue();
if (v) {
timeout = setTimeout(() => { delayed = true; }, delayMs);
} else {
if (timeout) clearTimeout(timeout);
delayed = false;
}
return () => { if (timeout) clearTimeout(timeout); };
});
return { get value() { return delayed; } };
}
```
Use: `const showSkeleton = useDelayed(() => query.isPending);` — skeleton renders only if `isPending` persists beyond 100ms. Cache-hit navigation feels instant; genuinely-loading views still show feedback.
**Error banner.** Single component reused across pages:
```svelte
<!-- ApiErrorBanner.svelte -->
<script lang="ts">
import type { ApiError } from '$lib/api/client';
let { error, onRetry }: { error: unknown; onRetry: () => void } = $props();
const apiErr = error as ApiError | undefined;
const message = apiErr?.message ?? 'Something went wrong.';
</script>
<div role="alert" class="rounded border border-danger/40 bg-surface p-4">
<p class="text-text-primary">{message}</p>
<button type="button" class="mt-2 rounded bg-accent px-3 py-1 text-sm text-background" onclick={onRetry}>
Try again
</button>
</div>
```
**404 branch.** Detail pages check `apiErr?.code === 'not_found'` specifically and render a non-retryable "not found" card with a back link — retry buttons on 404s just confuse users.
## Testing
**Component unit tests (Vitest + Testing Library + jest-dom):**
- `ArtistRow.test.ts` — renders initial (`"alice"``"A"`), name, `12 albums`, href `/artists/{id}`.
- `AlbumCard.test.ts` — renders `<img src="/api/albums/{id}/cover">`, title, year, href `/albums/{id}`. Broken-cover handler swaps src to the fallback data URL.
- `TrackRow.test.ts` — renders `#` (track number), title, duration — with `formatDuration(242)``"4:02"`, `formatDuration(3723)``"1:02:03"`.
- `LibrarySkeleton.test.ts` — variants `list | grid | album` each render a distinctive structure; `count` prop controls item count.
- `ApiErrorBanner.test.ts` — shows `error.message` when present; falls back to "Something went wrong"; clicking the button calls `onRetry`.
**Page integration tests (mocked TanStack Query):**
Shared test util at `src/test-utils/query.ts`:
```ts
// Returns a minimal synthetic infinite-query store.
export function mockInfiniteQuery<T>(opts: {
pages?: T[];
isPending?: boolean;
isError?: boolean;
error?: unknown;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}) { /* ... */ }
export function mockQuery<T>(opts: {
data?: T;
isPending?: boolean;
isError?: boolean;
error?: unknown;
refetch?: () => void;
}) { /* ... */ }
```
Then:
- `artists.test.ts`
- Renders `<ArtistRow>` for each artist in mocked pages.
- Clicking "Load more" calls `fetchNextPage`.
- `hasNextPage === false` renders "End of library".
- Sort `<select>` change triggers `goto('?sort=newest', ...)`.
- Pending renders `<LibrarySkeleton variant="list">`; error renders `<ApiErrorBanner>`.
- Empty state (total === 0) renders the empty-library message.
- `artist.test.ts`
- Renders `<AlbumCard>` for each album in `ArtistDetail.albums`.
- Back link `href="/"` with text "← Library".
- 404 error renders "Artist not found" with back link and NO retry button.
- Pending renders skeleton; non-404 error renders banner with retry.
- `album.test.ts`
- Renders cover (src pointing at `/api/albums/:id/cover`), title, artist link, year, metadata summary.
- Renders `<TrackRow>` for each track.
- Back link `href="/artists/:artistId"` with text `"← {artistName}"`.
- 404 error renders "Album not found" with back link.
**Manual browser check** (Plan Task N verification step):
1. `docker compose up --build -d` + login with seeded user.
2. `/` shows the artists list; counts visible; "Load more" appends pages without reloading.
3. Change sort dropdown → URL updates to `?sort=newest`; list reorders (oldest-added artists move to the bottom).
4. Click an artist → albums grid renders with covers. Broken covers (if any) show the fallback glyph.
5. Click an album → hero + track list. Back link returns to the artist (not the root).
6. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found".
7. Refresh `/artists/abc` while authenticated → no flash of login; data re-fetches and renders.
## Success criteria
- A user can start at `/`, drill into any artist, then into any album, then back — all via deep-linkable URLs.
- Cover images render; broken covers fall back gracefully.
- "Load more" extends the artist list; sort dropdown changes order and reflects in the URL.
- 404s (not-found artist / album) render a specific not-found card, not a retry banner.
- Cache-hit navigation (e.g., pressing Back) is instant — no skeleton flash.
- All unit and page tests pass in CI (`npm test` in `web/`).
- No backend changes required; this is pure frontend consumption of the existing `/api/artists`, `/api/artists/{id}`, `/api/albums/{id}`, `/api/albums/{id}/cover` endpoints.
@@ -1,423 +0,0 @@
# Web UI Player — Design Spec
**Date:** 2026-04-24
**Status:** Design approved
**Follows:** [Web UI Library Views](2026-04-23-web-ui-library-views-design.md) — tracks surfaced by the library plan finally become playable.
## Goal
Let an authenticated user click any track on an album page and hear it — plus skip/seek/shuffle/repeat/volume, a persistent bottom-bar UI, and OS-level media controls via the MediaSession API. After this lands, the web client is a functional music player for sequential playback through the library.
## Non-goals
Explicit YAGNI — kept out of this plan so scope stays tight:
- **No server-side listen-history recording.** The spec's `play_events` / `sessions` tables exist in the server design but no `/api/*` endpoints are implemented yet. Once those land, a follow-up "session reporting" sub-plan wires the player to POST events.
- **No scrobbling to ListenBrainz.** Dependent on server event endpoints + scrobble queue — later plan.
- **No contextual likes or "like this vibe".** Out of scope until session reporting exists.
- **No cross-refresh persistence.** Reload drops playback state (queue, position, current track). Volume is the only thing that persists.
- **No queue UI / "up next" panel.** The user sees the current track in the bar; they don't get a separate list view of what's coming. Queue visibility is a natural follow-up.
- **No keyboard shortcuts.** Space/arrow keys don't control playback in this plan — a separate "keyboard shortcuts" sub-plan will add them.
- **No crossfade, gapless playback, replay-gain, equalizer.** Baseline HTMLAudio only.
- **No cast / AirPlay / Chromecast.** Browser-local playback only.
- **No keyboard-driven seeking beyond the native `<input type="range">` behavior.**
## Architecture
**Single global player store** as a Svelte 5 rune module — imported by `+layout.svelte`, `Shell`, `PlayerBar`, and `TrackRow`. The store is pure state + action functions; it never touches the `<audio>` element directly.
The `<audio>` element lives once in `+layout.svelte` (survives navigation because the root layout never unmounts). The layout drives the element FROM the store via `$effect`:
- `audioEl.src` follows `player.current?.stream_url`
- `audioEl.volume` follows `player.volume`
- `audioEl.play()` / `pause()` follows `player.isPlaying`
And reports back INTO the store via DOM event handlers: `timeupdate`, `loadedmetadata`, `playing`, `pause`, `waiting`, `ended`, `error`. This inversion makes the store pure-logic testable — unit tests don't need jsdom to implement `HTMLMediaElement`.
**Seek writes go directly** via a small `registerAudioEl(el)` helper the layout calls once at mount. `seekTo(sec)` then writes `audioEl.currentTime = sec` without going through a `$effect` on `position` (avoids a write-loop where audio `timeupdate` → store `position``$effect` → audio `currentTime``timeupdate` again).
**MediaSession integration** (`useMediaSession()`) is a thin `$effect`-based module imported once from the root layout. It reads the store and writes `navigator.mediaSession.metadata`, registers action handlers (play/pause/previous/next/seekto), and keeps `playbackState` + `setPositionState` in sync.
**Click-to-play lifecycle:**
```
User clicks <TrackRow> for album tracks[5]
→ store.playQueue(album.tracks, 5)
→ _queue=tracks, _index=5, _state='loading', _position=0
→ Shell mounts <PlayerBar> (current !== undefined)
→ layout's src $effect → audio.src = tracks[5].stream_url
→ layout's isPlaying $effect → audio.play()
→ 'loadedmetadata' → reportDuration
→ 'playing' → reportStateFromAudio('playing') → _state='playing'
→ PlayerBar updates
```
## File structure
**New files under `web/src/`:**
```
lib/
├── player/
│ ├── store.svelte.ts # Rune state + action functions
│ ├── store.test.ts # Pure state-machine tests
│ ├── mediaSession.svelte.ts # navigator.mediaSession glue
│ └── mediaSession.test.ts # MediaSession spy-based tests
└── components/
├── PlayerBar.svelte # Bottom bar UI
└── PlayerBar.test.ts # Controls wired correctly; disabled states
```
**Modified files:**
| File | Change |
|---|---|
| `lib/components/Shell.svelte` | Grid grows a 3rd row. `<PlayerBar>` slot spans both columns. Mounted only when `player.current !== undefined`. |
| `lib/components/TrackRow.svelte` | Becomes a `<button>` that calls `playQueue(tracks, index)`. New props `tracks: TrackRef[]` and `index: number`. |
| `lib/components/TrackRow.test.ts` | Updated — clicks fire `playQueue` (mocked). |
| `routes/albums/[id]/+page.svelte` | Passes `tracks={album.tracks}` and `index={i}` to each `<TrackRow>`. |
| `routes/+layout.svelte` | Adds `<audio>` element, `$effect` bindings that drive it from the store, and a `useMediaSession()` call. |
## State shape
```ts
// lib/player/store.svelte.ts
import type { TrackRef } from '$lib/api/types';
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume()); // initial from localStorage
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
export const player = {
get queue() { return _queue; },
get index() { return _index; },
get current() { return _queue[_index] as TrackRef | undefined; },
get state() { return _state; },
get isPlaying() { return _state === 'playing'; },
get position() { return _position; },
get duration() { return _duration; },
get volume() { return _volume; },
get shuffle() { return _shuffle; },
get repeat() { return _repeat; },
get error() { return _error; },
};
```
## Action contract
```ts
// From outside the module (UI components and the layout call these).
export function playQueue(tracks: TrackRef[], startIndex = 0): void;
export function togglePlay(): void;
export function skipNext(): void;
export function skipPrev(): void;
export function seekTo(sec: number): void;
export function setVolume(v: number): void;
export function toggleShuffle(): void;
export function cycleRepeat(): void;
// Audio-event reports FROM the layout's <audio> element into the store.
export function reportTimeUpdate(sec: number): void;
export function reportDuration(sec: number): void;
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void;
// Wiring for seek writes (avoids $effect loop on position).
export function registerAudioEl(el: HTMLAudioElement | null): void;
```
## Per-control semantics
**Skip-previous (3-second rule).** Skip-prev never wraps to the end of the queue — repeat modes only affect auto-advance and skip-next.
- If `_position < 3` AND `_index > 0`: `_index--`, position=0, play.
- Else (position ≥ 3, OR at index 0): seek to 0 of current track.
- Disabled visually when `_index === 0` AND `_position < 3` — nothing useful left to do.
**Skip-next.**
- `_index + 1 < _queue.length``_index++`, position=0.
- At end: consult `_repeat`:
- `off``_state='paused'`, `_index = queue.length - 1`, `_position = _duration`.
- `all``_index = 0`, keep playing.
- `one` → treated as `off` here (explicit user skip overrides one-track repeat).
- Disabled when `_index === _queue.length - 1` AND `_repeat === 'off'`.
**Auto-advance on `ended` event** (`reportStateFromAudio('ended')`):
- `repeat === 'one'``_position = 0`, `_state='loading'`; layout sees state change, `currentTime` resets (via `seekTo(0)` internally), play continues.
- `repeat === 'all'``_index = (_index + 1) % _queue.length`, `_position = 0`.
- `repeat === 'off'` → same as skip-next past end: `_state='paused'`.
**Seek.**
- UI: `<input type="range" min=0 max={duration}>` bound to `player.position` with `oninput → seekTo(newValue)`.
- `seekTo` writes `_position = sec` AND `audioEl.currentTime = sec` via `registerAudioEl`-stored reference.
**Volume.**
- UI: `<input type="range" min=0 max=1 step=0.01>`.
- `setVolume(v)` clamps to `[0, 1]`, writes `_volume = v`, persists via `localStorage.setItem('minstrel.volume', String(v))`.
- Layout's `$effect` on `player.volume` pushes to `audioEl.volume`.
- Module-load-time helper `readStoredVolume(): number` reads `localStorage.getItem('minstrel.volume')` with `1.0` fallback and numeric validation.
**Shuffle.**
- `toggleShuffle()`:
- Enabling: Fisher-Yates shuffle `_queue[_index+1..end]` in place. The played-and-current tracks stay in position; only what's ahead is randomized.
- Disabling: flag flips. Queue is not unshuffled — shuffle is a one-shot randomization; toggling back off only matters for new `skipNext`/`ended` behavior, which at the moment IS "walk through queue in order" so no difference. (Simpler than tracking an original order.)
- Test-determinism: the shuffle function takes an injectable RNG; default is `Math.random`. Tests override via a module-level setter.
**Repeat.**
- `cycleRepeat()`: `off → all → one → off`.
- UI renders three distinct glyphs.
**Click-to-play.**
- `TrackRow` `onclick``playQueue(tracks, index)`.
- Clicking a track currently playing restarts it.
- Clicking a track on a DIFFERENT album replaces queue with that album's tracks, `index=N`.
## Audio wiring in `+layout.svelte`
```svelte
<script lang="ts">
/* existing imports unchanged */
import {
player,
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
seekTo
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
let audioEl: HTMLAudioElement | undefined = $state();
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url ?? '';
if (audioEl.src !== new URL(src, window.location.origin).href && src) {
audioEl.src = src;
} else if (!src) {
audioEl.removeAttribute('src');
audioEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
$effect(() => {
if (!audioEl) return;
if (player.isPlaying) audioEl.play().catch(() => {/* play() rejected (autoplay / interrupted) — store will receive a 'pause' event */});
else audioEl.pause();
});
useMediaSession();
</script>
<!-- existing layout markup -->
<audio
bind:this={audioEl}
preload="metadata"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => audioEl && reportDuration(audioEl.duration)}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
```
## MediaSession glue
```ts
// lib/player/mediaSession.svelte.ts
import { player, togglePlay, skipNext, skipPrev, seekTo } from './store.svelte';
export function useMediaSession(): void {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
$effect(() => {
const t = player.current;
if (!t) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: t.title,
artist: t.artist_name,
album: t.album_title,
artwork: [{
src: `/api/albums/${t.album_id}/cover`,
sizes: '512x512',
type: 'image/jpeg'
}]
});
});
navigator.mediaSession.setActionHandler('play', () => togglePlay());
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
navigator.mediaSession.setActionHandler('previoustrack', () => skipPrev());
navigator.mediaSession.setActionHandler('nexttrack', () => skipNext());
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
});
$effect(() => {
if (player.duration > 0 && 'setPositionState' in navigator.mediaSession) {
try {
navigator.mediaSession.setPositionState({
duration: player.duration,
position: Math.min(player.position, player.duration),
playbackRate: 1
});
} catch { /* setPositionState throws if numbers are NaN/Infinity briefly between tracks */ }
}
});
$effect(() => {
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
});
}
```
## Bottom-bar layout
```
┌────────────────────────────────────────────────────────────────┐
│ [cover] Title 2:14 [════════▓═══════] 4:02 🔀 🔁 🔊 │
│ Artist ⏮ ⏯ ⏭ │
└────────────────────────────────────────────────────────────────┘
```
- **Left** (fixed 240px, collapses below `sm:`): 48px cover thumbnail (click → `/albums/:id`), title, artist name linked to `/artists/:id`. Broken cover → `FALLBACK_COVER` from library plan.
- **Center** (flex-1, stacked):
- Top row: `{elapsedTime} {seek slider (flex-1)} {totalDuration}` — single row, elapsed/total use `tabular-nums` and a fixed `ch` width so the slider doesn't jitter as `position` ticks.
- Bottom row: `⏮ ⏯ ⏭` centered under the slider. Play/pause glyph swaps based on `isPlaying`. During `loading`/`waiting`, swap play icon for a small spinner.
- **Right** (~220px): shuffle button, repeat button (badge for 'one' state), volume slider (150px wide).
- Total bar height ~80px. Grid spans both columns (sidebar + main).
Error variant: the center column's content is replaced with an inline error card — "Playback failed." + "Try again" button that calls `playQueue(_queue, _index)`. Transport controls disabled.
## Shell modifications
`Shell.svelte` grid grows a 3rd row:
```
grid grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto]
```
The last row spans both columns. PlayerBar renders there only when `player.current` is defined. When nothing has played yet, the row collapses (zero height).
## Loading / error / empty states
- `state === 'loading' | 'waiting'` — play/pause button shows spinner glyph. Other controls unchanged.
- `state === 'error'` — error card replaces transport controls; retry button calls `playQueue(_queue, _index)`.
- `current === undefined` — PlayerBar doesn't render. Shell's 3rd grid row collapses.
401 mid-playback: already handled globally by `apiFetch`'s interceptor → `auth.logout({silent: true})` → auth store clears → root layout's guard redirects to `/login`. The audio element errors out; the PlayerBar unmounts because Shell is gone. No player-specific handling needed.
## Testing
**Unit tests — `store.test.ts`:**
State-machine semantics, no audio element. All tests seed a fresh store via `vi.resetModules()` + dynamic `import('./store.svelte')` in `beforeEach` so `_*` state isn't shared across tests. Explicit cases:
- `playQueue([...], 2)` sets queue, index=2, state='loading', position=0.
- `togglePlay`: idle → no-op. paused → playing. playing → paused.
- `skipNext` mid-queue: index++, position=0.
- `skipNext` at end with `repeat='off'`: state='paused', index=queue.length-1, position=duration.
- `skipNext` at end with `repeat='all'`: index=0.
- `skipNext` at end with `repeat='one'`: same as 'off' (explicit skip overrides one-track repeat).
- `skipPrev` with position < 3 AND index > 0: index--.
- `skipPrev` with position >= 3: index unchanged; position=0.
- `skipPrev` at index 0: position=0 regardless.
- `reportStateFromAudio('ended')` with `repeat='one'`: position=0, index unchanged, state='loading'.
- `reportStateFromAudio('ended')` with `repeat='all'`: wraps `_index = (_index + 1) % _queue.length`.
- `reportStateFromAudio('ended')` with `repeat='off'`: state='paused'.
- `toggleShuffle` (with injected seeded RNG): queue[index+1..end] is reordered; queue[0..index] unchanged.
- `cycleRepeat`: off → all → one → off (3 consecutive calls).
- `setVolume(0.5)`: volume=0.5; localStorage key 'minstrel.volume' equals '0.5'.
- `setVolume(-1)` clamps to 0; `setVolume(2)` clamps to 1.
- Module load with `localStorage['minstrel.volume']='0.3'`: initial `_volume === 0.3`.
- Module load with absent / invalid localStorage value: initial `_volume === 1.0`.
- `reportStateFromAudio('error', 'foo')`: state='error', error='foo'.
- `reportStateFromAudio('playing')`: state='playing'; clears `_error`.
- `seekTo(30)`: position=30; if `registerAudioEl` has been called with a mock element, that element's `currentTime === 30`.
**Unit tests — `mediaSession.test.ts`:**
Fakes `navigator.mediaSession` with a spy object containing `setActionHandler`, `setPositionState`, `metadata`, `playbackState`. Calls `useMediaSession()` inside `$effect.root(() => {...})`.
- Metadata: when `player.current` is set, `navigator.mediaSession.metadata` has title/artist/album/artwork matching the current track.
- Metadata cleared to `null` when `player.current` becomes undefined.
- All four action handlers registered with callables that dispatch to the corresponding store action.
- `playbackState` updates when `isPlaying` flips.
- `setPositionState` called with duration/position whenever duration>0 changes.
**Unit tests — `PlayerBar.test.ts`:**
Mocks `$lib/player/store.svelte` to control state and spy on actions.
- Renders nothing when `player.current === undefined` (or Shell doesn't mount it — tests render PlayerBar directly with a current track).
- Renders cover, title, artist link (`href='/artists/:id'`), cover link (`href='/albums/:id'`).
- Play button click calls `togglePlay`.
- Skip-next/prev click call `skipNext`/`skipPrev`.
- Skip-prev disabled when index=0 AND position < 3.
- Skip-next disabled when index=length-1, repeat='off'.
- Seek slider input calls `seekTo(value)`.
- Volume slider input calls `setVolume(value)`.
- Shuffle button click calls `toggleShuffle`; active state reflects `player.shuffle`.
- Repeat button click calls `cycleRepeat`; three visual states render distinctly (assert on `aria-label` variants).
- State='loading' or 'waiting': play icon replaced by spinner (assert on a `data-testid` or SVG class).
- State='error': transport region replaced by retry card; clicking retry calls `playQueue` with `player.queue` + `player.index`.
- Elapsed + total use `formatDuration` (reuse from library plan).
**Unit tests — `TrackRow.test.ts` (updated):**
Keep existing tests; add:
- Clicking the row calls `playQueue(tracksProp, indexProp)` — mock the store module.
- Row renders as a `<button>` (or `<div role="button" tabindex="0">` — spec requires a keyboard-activatable element; `<button>` is simpler).
**Manual integration (Plan Task N verification):**
1. Sign in, navigate to an album, click track 3 → bar appears, track plays.
2. OS-level check: lock-screen / media notification shows the track metadata and allows play/pause.
3. Press media keys on keyboard → play/pause toggles.
4. Drag seek slider mid-track → audio jumps to new position.
5. Click skip-next through end of album → stops.
6. Turn on repeat=all → skip-next past end wraps to track 1.
7. Turn on repeat=one → track loops at end.
8. Turn on shuffle → future skips land on random remaining tracks.
9. Adjust volume; refresh the page; navigate to a new track — volume restored from localStorage.
10. Disconnect network mid-track → error card appears; click "Try again" → recovers when network returns.
11. Navigate between artists / albums / the library root while a track is playing — playback continues, bar stays put.
## Dependencies added
None. Everything uses the existing stack (SvelteKit 2, Svelte 5 runes, Vitest, Testing Library, jsdom). The MediaSession API is native to browsers; no polyfill.
## Success criteria
- Clicking any track starts playback with the rest of the album queued.
- All controls (play/pause, skip ±1, seek, volume, shuffle, repeat) behave per Section 3 of this spec.
- MediaSession metadata and action handlers are populated; OS media controls work.
- Playback survives navigation within the SPA.
- Volume persists across page loads.
- Broken streams show the error card; retry recovers.
- All new unit + page tests pass (`npm test` in `web/`).
- No backend changes — the plan is pure frontend consumption of the existing `/api/tracks/:id/stream`.
@@ -1,221 +0,0 @@
# M2 Events Sub-Plan — Design Spec
**Status:** approved 2026-04-25
**Slice:** M2 (Events, sessions, general likes), spec §13 step 5. General likes (step 6) deferred to a follow-up sub-plan.
**Fable tasks:** #322 (schema), #323 (session service), #324 (events API), #325 (web UI wiring) — bundled into a single PR for this slice.
## Goal
Ship the play-event ingestion pipeline end-to-end: schema, session service, `POST /api/events` handler, Subsonic `/rest/scrobble` integration, and the SvelteKit player wiring that emits the events. Output is a populated `play_events` / `skip_events` / `sessions` set that M3's recommendation engine can read from. No recommendation logic in this slice; this is the data foundation.
## Non-goals
- General likes table, `/api/likes` endpoint, Subsonic `star/unstar` wiring — own M2 sub-plan.
- `contextual_likes`, `artist_preferences` — M3 (algorithm consumers).
- `session_vector_at_play` JSONB population — M3 (the algorithm computes it). The column ships in this slice as nullable.
- Background janitor for abandoned `ended_at IS NULL` rows. Auto-close-on-next-play handles the common case; user-never-comes-back leaves at most one open row per user, which doesn't impact recommendation queries (they filter `ended_at IS NOT NULL`).
- Multi-device session merging. Each `play_started` opens a new row regardless of whether one is already open elsewhere; M3 decides what to do when interleaved.
- Listen-history UI. The Web UI surfaces nothing about events in this slice — the wiring is invisible to the user.
## Architecture
A new database migration adds `play_events`, `skip_events`, and `sessions` per spec §5. A small Go package `internal/sessions` exposes a `FindOrCreate` helper implementing the 30-minute rolling-window rule from spec §6. A second package `internal/playevents` owns the write paths (start, end, skip, synthetic-completed) so both the JSON API handler and the existing Subsonic scrobble handler share one source of truth. A new HTTP handler `POST /api/events` accepts a discriminated-union JSON payload covering the three client-side event types. The web SPA gains a sibling module to the player store that watches state transitions and dispatches events.
**Event lifecycle:** start + end (option 1 from brainstorm). Each track produces one `play_events` row inserted at start (`ended_at` NULL) and updated at end. The skip-classification rule (spec §6) runs at end-time: a play is a SKIP if `completion_ratio < 0.5 AND duration_played_ms < 30000`. Both conditions must fail (i.e., either ≥50% OR ≥30s) for it to count as a play.
**Subsonic scrobble integration:** Third-party Subsonic clients call `/rest/scrobble` to record plays. Today the handler is a no-op stub. After this slice, `submission=false` writes a `play_started` (replacing the in-memory `nowPlayingMap`); `submission=true` writes a synthetic completed play (`play_started + play_ended` in one transaction). This puts mobile/desktop Subsonic clients on equal footing with the web SPA in M3's recommendation pipeline.
**Abandoned plays:** The web SPA uses `navigator.sendBeacon('/api/events', play_skipped)` on `pagehide` so graceful tab-close still completes the row. For ungraceful cases (network drop, browser crash, OS kill), the events handler's `play_started` path begins by auto-closing any prior `ended_at IS NULL` row for the same user. The close uses `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)` (in ms), and `was_skipped = true`. The completion ratio is recomputed and the skip rule from spec §6 is *not* applied (auto-closed rows are flagged as skipped regardless, since we don't know how much the user actually heard). At most one open row per user at any time.
## Schema (migration `0005_events.up.sql`)
```sql
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
last_event_at timestamptz NOT NULL,
track_count integer NOT NULL DEFAULT 0,
client_id text
);
CREATE INDEX sessions_user_last_event_idx ON sessions (user_id, last_event_at DESC);
CREATE TABLE play_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_played_ms integer,
completion_ratio double precision,
was_skipped boolean NOT NULL DEFAULT false,
client_id text,
session_vector_at_play jsonb,
scrobbled_at timestamptz
);
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
CREATE TABLE skip_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
skipped_at timestamptz NOT NULL,
position_ms integer NOT NULL
);
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
```
`session_vector_at_play` lands now as nullable so M3 doesn't need a follow-up migration; M2 writes NULL.
## API contracts
### `POST /api/events`
```ts
type EventRequest =
| { type: 'play_started'; track_id: string; at?: string; client_id?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number; at?: string }
| { type: 'play_skipped'; play_event_id: string; position_ms: number; at?: string };
type EventResponse =
| { play_event_id: string; session_id: string } // for play_started
| { ok: true }; // for play_ended / play_skipped
```
`at` defaults to server `now()` if omitted. `client_id` defaults to null.
**Errors:**
- `400 bad_request` — missing/invalid `type`; required fields missing or malformed; UUIDs not parseable; `duration_played_ms` or `position_ms` negative.
- `404 not_found``track_id` doesn't exist, or `play_event_id` doesn't exist.
- `403 forbidden``play_event_id` belongs to a different user (event UUIDs aren't secrets, but writes must respect ownership).
- `500 server_error` — DB issue.
### `/rest/scrobble` (Subsonic — existing endpoint, behavior change only)
Request shape unchanged. Behavior:
- `submission=false&id=X&time=T` → calls `playevents.RecordPlayStarted(userID, trackID, clientID=c, at=T)` then returns the standard Subsonic `ok` envelope. Replaces the in-memory `nowPlayingMap`.
- `submission=true&id=X&time=T` → calls `playevents.RecordSyntheticCompletedPlay(userID, trackID, clientID=c, at=T)` which writes `play_started` and `play_ended` together in a single transaction. `started_at = T`, `ended_at = T + track.duration_ms`, `duration_played_ms = track.duration_ms`, `was_skipped = false` (Subsonic clients don't tell us if a play was skipped — assume completion).
- Other `submission` values: ignored, ack with `ok` (matches current behavior).
Response stays the standard Subsonic `ok` envelope; no new fields.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0005_events.up.sql` + `.down.sql` | Schema for `play_events`, `skip_events`, `sessions`. |
| `internal/db/dbq/events.sql` (sqlc input) + generated `events.sql.go` | `InsertSession`, `UpdateSessionLastEvent`, `GetMostRecentSessionForUser`, `InsertPlayEvent`, `UpdatePlayEventEnded`, `InsertSkipEvent`, `CloseAbandonedPlayEventsForUser`, `GetOpenPlayEventByUser`. |
| `internal/sessions/service.go` | `FindOrCreate(ctx, tx, userID, eventTime, clientID, timeout) (sessionID, error)`. Composable into a larger transaction. |
| `internal/sessions/service_test.go` | Live-DB tests covering the four scenarios above. |
| `internal/playevents/writer.go` | `RecordPlayStarted`, `RecordPlayEnded`, `RecordPlaySkipped`, `RecordSyntheticCompletedPlay`. Owns the auto-close-prior step, skip classification rule, skip_events writes. |
| `internal/playevents/writer_test.go` | Direct tests of rule edges (49/50% × 29/30s), auto-close logic, synthetic write. |
| `internal/api/events.go` | `handleEvents` HTTP handler — JSON decode, dispatch by `type`, call into `playevents.writer`. |
| `internal/api/events_test.go` | HTTP-level coverage for happy path, validation errors, ownership-mismatch. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/api.go` | Register `authed.Post("/events", h.handleEvents)`. |
| `internal/subsonic/stream.go` | `handleScrobble` calls `playevents.writer` instead of `nowPlayingMap`. The `nowPlayingMap` struct + `newNowPlayingMap` + the `nowPlaying` field are deleted. The `mediaHandlers` constructor signature simplifies. |
| `internal/subsonic/stream_test.go` | Update existing scrobble test for the new behavior; add a `submission=true` case. |
| `internal/config/config.go` + `config.example.yaml` | New `events:` section: `session_timeout_minutes` (default 30), `skip_max_completion_ratio` (default 0.5), `skip_max_duration_played_ms` (default 30000). |
### New web files
| Path | Responsibility |
|---|---|
| `web/src/lib/player/events.svelte.ts` | `useEventsDispatcher()``$effect`-based watcher on `player.current` / `player.state`; owns `_playEventId` state; calls `api.post`; installs `pagehide` listener that uses `navigator.sendBeacon`. |
| `web/src/lib/player/events.svelte.test.ts` | State-machine tests with mocked `api.post` and spied `navigator.sendBeacon`. |
| `web/src/lib/player/clientId.ts` | `getOrCreateClientId(): string` — sessionStorage-backed UUID per tab. |
### Modified web files
| Path | Change |
|---|---|
| `web/src/lib/api/types.ts` | Add `EventRequest`, `EventResponse` types matching the server contracts. |
| `web/src/lib/api/client.ts` | Add `api.post<T>(path, body)` helper if not already present. |
| `web/src/routes/+layout.svelte` | Call `useEventsDispatcher()` once at mount, alongside the existing `useMediaSession()`. |
## Data flow
**Web SPA path:**
1. User clicks track → store mutates: `_state = 'loading'`, `_queue = tracks`, `_index = 0`, `_position = 0`.
2. `<audio>` loads → fires `playing` → store transitions to `_state = 'playing'`.
3. Events module's `$effect` sees `(player.current, player.state === 'playing')` for the first time on this track → `POST /api/events { type: 'play_started', track_id, client_id, at }` → server returns `{ play_event_id }` → cached in module state.
4. Track plays. No emissions during playback.
5. Track ends naturally → audio fires `ended` → store advances to next track or pauses → events module sees the transition out of `playing` for THIS track → `POST { type: 'play_ended', play_event_id, duration_played_ms, at }` → server updates the row, applies skip classification, optionally writes a `skip_events` row.
6. Cycle repeats.
**Skip mid-track:** `skipNext()` advances queue → audio src changes → events module sees `player.current` change while `_playEventId` is still set → `POST { type: 'play_skipped', play_event_id, position_ms = _position, at }` → then on the new track's `playing` event, fires `play_started` for the new track.
**Tab close:** `pagehide` listener fires → `navigator.sendBeacon('/api/events', { type: 'play_skipped', play_event_id, position_ms = _position, at })`.
**Crash / network drop:** No `play_ended` ever sent. Row stays `ended_at IS NULL`. Next time the user starts ANY track on any client, the events handler's auto-close step closes the abandoned row before inserting the new one. Per the formula above: `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)`, `was_skipped = true`.
**Subsonic path:**
- `scrobble?submission=false` → handler calls `playevents.RecordPlayStarted`. Server has live "now playing" via `play_events WHERE ended_at IS NULL` (not used yet but available for M3+).
- `scrobble?submission=true` → handler calls `playevents.RecordSyntheticCompletedPlay` which writes both rows in one transaction.
**Idempotency:** `play_ended` / `play_skipped` are naturally idempotent (each carries the row id). `play_started` is not — re-sending creates a new row. The auto-close-prior step protects the table from leaking open rows.
## Testing
### Server (`go test`)
- **Migration smoke** (`internal/db/db_test.go` extension): applying `0005_events` on a fresh test DB succeeds; round-trip insert+select for each new table.
- **Session service** (`internal/sessions/service_test.go`):
- No prior session → creates one with `started_at = eventTime`, `track_count = 0`.
- Within window → returns existing session id, updates `last_event_at`.
- Beyond window → creates a new session.
- Concurrent inserts (`t.Parallel` with shared user, transactional fixture) don't double-create.
- Uses a `Clock` interface so tests fast-forward without `time.Sleep`.
- **playevents writer** (`internal/playevents/writer_test.go`):
- Skip rule boundaries: `completion=0.49, duration=29s` → skip; `completion=0.5, duration=29s` → not skip; `completion=0.49, duration=30s` → not skip.
- Auto-close-prior: prior open row gets closed with computed `ended_at` and `was_skipped=true` when a new `play_started` arrives.
- Synthetic completed play writes both rows + the `was_skipped=false` flag in a single transaction.
- **Events handler** (`internal/api/events_test.go`):
- Happy path for all three event types.
- 400 on missing `type`; 400 on `play_ended` referencing a malformed UUID; 400 on negative `duration_played_ms`.
- 404 on `track_id` that doesn't exist; 404 on `play_event_id` that doesn't exist.
- 403 on `play_event_id` belonging to a different user.
- **Subsonic scrobble update** (`internal/subsonic/stream_test.go` extension):
- Existing `submission=false` test now asserts a `play_events` row was inserted.
- New `submission=true` test asserts both `play_started + play_ended` rows are written and the response is the standard Subsonic envelope.
- Test-only code that referenced the deleted `nowPlayingMap` is removed.
### Web (`vitest`)
- **`events.svelte.test.ts`** with `vi.mock('$lib/api/client', ...)` and `vi.spyOn(navigator, 'sendBeacon')`:
- Track 1 starts → exactly one `POST` with `type: 'play_started'`.
- Server returns `play_event_id: 'pe1'`, then track ends → exactly one POST with `play_ended` carrying `pe1`.
- User skips mid-track 2 → POST with `play_skipped` carrying that row's id.
- Loading a new track via `playRadio()` while track 2 is still playing → POSTs `play_skipped` for track 2 BEFORE `play_started` for the new track.
- `pagehide` event with an active play → `navigator.sendBeacon` called once with `play_skipped` payload.
### End-to-end manual
Final task in the implementation plan, against `docker compose up --build -d`:
1. Sign in to web SPA. Play a track. `psql -c "SELECT * FROM play_events"` shows one row, `ended_at IS NULL`, correct user/track/session.
2. Let track finish. Same row now has `ended_at`, `was_skipped=false`.
3. Skip mid-track (within 30s of start). New row created on next track; previous row's `was_skipped=true` and `skip_events` has the matching row.
4. Wait > 30 minutes. Play. New `sessions` row with different id from the first session.
5. Open Feishin/Symfonium pointed at the same instance. Play a track to completion. `play_events` shows a synthetic row.
6. Close the web tab mid-track. `play_events` last row was closed via sendBeacon (timestamp ≈ tab close).
7. Disconnect network mid-play, force-quit browser. Reconnect, sign back in, start a new track. Previous abandoned row gets auto-closed.
## Risks & mitigations
- **Event spam from buggy clients.** A misbehaving client could fire `play_started` in a loop, leaking rows. Mitigation: the auto-close-prior step caps each user at one open row at a time. Worst-case is one closed-row insert per `play_started` call, which is bounded by client-side sanity. Future M2.5 could add per-user rate limiting.
- **Clock skew between client and server.** The client sends `at` timestamps; if the client's clock is wildly off, sessions could be assigned incorrectly (same user might see a "new session" for events that should extend the current one). Mitigation: server uses its own `now()` if `at` is omitted, and the events module always omits `at` (lets the server timestamp). The `at` field exists for the Subsonic compatibility path (Subsonic clients send `time=` and we honor it).
- **Subsonic synthetic completed plays are too coarse.** A client that scrobbles after a 90% play is treated identically to a 100% play. Mitigation: Subsonic protocol doesn't expose finer info; this is a known limitation. M3's recommendation engine should weight Subsonic-sourced plays slightly lower if it ever matters in practice.
- **`nowPlayingMap` deletion breaks an unknown caller.** Verified there are no consumers of the existing in-memory map outside `handleScrobble` itself; the constructor change is local. Migration is safe.
- **JSONB `session_vector_at_play` ships nullable but is referenced in M3 plans.** No M3 task starts in this slice; the column is purely forward-compatibility. Documented in the schema comment.
@@ -1,189 +0,0 @@
# Web UI Search — Design Spec
**Status:** approved 2026-04-25
**Slice:** M6 sub-plan #5 (after Player). Backend `GET /api/search` already exists; this slice ships the SPA UI plus a small `/api/radio` stub.
## Goal
Replace the `/search` placeholder with a working search experience: a persistent header search box, live debounced results across artists/albums/tracks, per-facet overflow pages, click-to-play-as-radio for tracks, and an "add to queue" affordance on tracks and albums.
## Non-goals
- Real radio recommendations. The `/api/radio` endpoint ships as a stub returning `{ tracks: [seed] }`. The full M4 implementation (similarity-driven candidate pool + scoring + 80% queue refresh) lands later; only the request/response contract is locked here.
- Search-result pre-fetch / typeahead suggestions / autocomplete. Plain results page only.
- Mobile-specific layout polish. Header bar fits on desktop; mobile responsive pass is its own future slice.
- Keyboard shortcuts (e.g. Cmd-K to focus search). Out of scope.
- Cross-page search history / recent-searches list.
## Architecture
A persistent `SearchInput` component lives in `Shell.svelte`'s header. It binds a local `value` state, debounces 250 ms, and uses `goto()` to keep `/search?q=…` synced. First nav from elsewhere is a normal push; subsequent typing while on `/search` uses `replaceState` so back-navigation skips the typing log. Empty input on `/search` clears the param.
`/search/+page.svelte` reads `?q=` reactively and runs a TanStack Query against `GET /api/search?q=…&limit=10`, which returns three small paged facets. The page renders up to three sections (Artists / Albums / Tracks), each reusing the existing library components. Each section also renders a "See all N →" link to its overflow page when `total > items.length`. Empty facets are not rendered.
Three overflow pages — `/search/artists/`, `/search/albums/`, `/search/tracks/` — each read `?q=` and run their own `createInfiniteQuery` against the same `/api/search` endpoint with `limit=50`. They render only their own facet, ignoring the other two facets in the response (minor over-fetch, simpler than three new endpoints). "Load more" pulls the next page.
The Track click handler on the search results page calls a new player-store action `playRadio(seedTrackId)` which fetches `/api/radio?seed_track=…` and pipes the response into `playQueue(tracks, 0)`. On the album-detail page, Track clicks keep their current behavior (queue the album).
A new `+ queue` button on each track row and album card calls `enqueueTrack` / `enqueueTracks`, which append to the existing `_queue` rune state without disturbing the current playback or `_index`.
## API contracts
### `GET /api/search?q=&limit=&offset=` (existing — no change)
```ts
type SearchResponse = {
artists: Page<ArtistRef>;
albums: Page<AlbumRef>;
tracks: Page<TrackRef>;
};
type Page<T> = { items: T[]; total: number; limit: number; offset: number };
```
Server applies the shared `limit/offset` to each facet's `LIMIT/OFFSET` query independently. `q` is required; empty/whitespace is `400`. Default `limit` is bounded server-side; this slice sends `10` from the main `/search` page and `50` from each overflow page.
### `GET /api/radio?seed_track=<uuid>` (new — stub)
```ts
type RadioResponse = { tracks: TrackRef[] };
```
Stub behavior: `200` with `{ tracks: [<seed_track>] }` (one entry, the seed itself). `404 { code: 'not_found' }` if the track id doesn't exist. `400 { code: 'bad_request' }` if `seed_track` is missing or empty. Same `TrackRef` shape used everywhere else; `resolveTrackRefs` builds the response.
When M4 fills the endpoint, the response shape is final: extra entries appended after the seed, same `TrackRef` shape.
## Components & files
### New
| Path | Responsibility |
|---|---|
| `web/src/lib/components/SearchInput.svelte` | Header search box; debounced URL sync via `goto`; pre-fills from `page.url.searchParams` |
| `web/src/lib/components/SearchSkeleton.svelte` | Three-section placeholder (artist list / album grid / track list shapes) for `/search` loading state |
| `web/src/routes/search/+page.svelte` | Main results page — reads `?q=`, fires search query, renders three sections |
| `web/src/routes/search/artists/+page.svelte` | Artists overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/albums/+page.svelte` | Albums overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/tracks/+page.svelte` | Tracks overflow with `createInfiniteQuery` + Load more |
| `web/src/lib/components/SearchInput.test.ts` | Debounce / pre-fill / clear behavior |
| `web/src/routes/search/search.test.ts` | Page integration: 3 sections, empty hide, "See all" link, no-results, empty `?q=` prompt |
| `web/src/routes/search/artists/artists.test.ts` | Overflow tests (analogous for `albums.test.ts`, `tracks.test.ts`) |
| `internal/api/radio.go` | Stub handler |
| `internal/api/radio_test.go` | Stub tests (200/404/400) |
### Modified
| Path | Change |
|---|---|
| `web/src/lib/components/Shell.svelte` | Insert `<SearchInput />` in the header between brand and user-menu |
| `web/src/lib/components/TrackRow.svelte` | Refactor outer element from `<button>` to `<div role="button" tabindex="0">` with keyboard handlers (Enter/Space) so a real `<button>` for `+ queue` can nest inside without invalid HTML. Add optional `onPlay?: (track: TrackRef, index: number) => void` prop; default behavior remains `playQueue(tracks, index)` |
| `web/src/lib/components/TrackRow.test.ts` | Update for `role=button` keyboard activation; add `+ queue` click test; add `onPlay` override test |
| `web/src/lib/components/AlbumCard.svelte` | Wrap `<a href="/albums/:id">` around cover+title; add an absolutely-positioned `+ queue` button as a sibling. Click on the link still navigates; click on `+` calls `event.preventDefault()` + `event.stopPropagation()` then `api.get<AlbumDetail>('/api/albums/' + album_id)` (one-shot fetch, not the live `createAlbumQuery` store) and calls `enqueueTracks(detail.tracks)` |
| `web/src/lib/components/AlbumCard.test.ts` | Add `+ queue` click test |
| `web/src/lib/api/types.ts` | Add `SearchResponse`, `RadioResponse` |
| `web/src/lib/api/queries.ts` | Add `createSearchQuery(q)`, `createSearchArtistsInfiniteQuery(q)`, `createSearchAlbumsInfiniteQuery(q)`, `createSearchTracksInfiniteQuery(q)` |
| `web/src/lib/player/store.svelte.ts` | Add `enqueueTrack(t)`, `enqueueTracks(ts)`, `playRadio(seedTrackId)` actions |
| `web/src/lib/player/store.test.ts` | Cover `enqueueTrack` / `enqueueTracks` / `playRadio` |
| `internal/api/api.go` | Register `r.Get("/api/radio", h.handleRadio)` |
## URL & state behavior
- `SearchInput` debounces 250 ms before navigating. Each settled value either pushes (first nav from elsewhere) or replaces (already on `/search`) the URL.
- Empty input on `/search``goto('/search')` (drops `?q=`).
- `Enter` blurs the input; no extra nav.
- `Escape` clears `value` (which then navigates per the rules above).
- `/search/+page.svelte` reads `q` via `$derived((page.url.searchParams.get('q') ?? '').trim())`. When `q === ''`, no query is created (empty-state copy renders).
- Overflow pages read `q` the same way; they show their own empty state copy when `q === ''`.
- TanStack Query caches per-`q` results, so backspacing through previous values returns instantly.
## States
| Condition | Render |
|---|---|
| `q === ''` (main page) | "Start typing to search artists, albums, and tracks." |
| `q` non-empty AND `useDelayed(() => query.isPending) === true` AND no cached data | `SearchSkeleton` |
| `query.isError` | `ApiErrorBanner` with `onRetry={query.refetch}` |
| All three facets `items.length === 0` | "No matches for '<q>'." |
| Otherwise | Up to three sections; empty facets are skipped |
Overflow pages: same skeleton/error/empty pattern (using `LibrarySkeleton` for the per-facet variant). "Load more" button hides when `hasNextPage === false`.
## Player-store additions
```ts
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}
```
`enqueueTrack`/`enqueueTracks` deliberately do NOT change `_state` — appending while playing should not pause; appending while paused should not auto-play. The `idle → 0` reset only matters when the queue was previously empty so that `_index` points at a valid position; the rest of the playback machinery (`+layout.svelte` audio effects, MediaSession glue) reacts to `_state` and `player.current` and continues to work unchanged.
## Testing
**Server (`internal/api/radio_test.go`):**
- 200 success path with valid `seed_track` (response shape, single-item array, full `TrackRef`).
- 404 on nonexistent track id.
- 400 on missing/empty `seed_track`.
**Player store (`store.test.ts`):**
- `enqueueTrack` appends; preserves `_index` when queue was non-empty; sets `_index=0` when state was `'idle'`.
- `enqueueTracks([])` no-ops; non-empty concatenates.
- `playRadio` (mocked `api.get`) hits `/api/radio?seed_track=<id>` and forwards response to `playQueue`.
**`SearchInput.test.ts`:**
- Single keystroke fires `goto` once after 250 ms (`vi.useFakeTimers` + `flushSync`).
- Burst of keystrokes in <250 ms fires `goto` once after settle.
- Empty input on `/search` calls `goto('/search')`.
- Mount pre-fills `value` from `page.url.searchParams.get('q')`.
**`search/search.test.ts`:**
- All three sections render when all three facets have items.
- Empty facets are hidden.
- "See all N →" links render when `total > items.length`.
- "No matches" copy renders when all three facets are empty.
- Empty `?q=` shows "Start typing" prompt and fires NO query.
**`search/artists/artists.test.ts`** (and analogous `albums.test.ts`, `tracks.test.ts`):
- Items render.
- "Load more" calls `fetchNextPage`.
- Button hides when `hasNextPage === false`.
**`TrackRow.test.ts` (updates):**
- `role=button` Enter/Space activation calls the play handler.
- Click on the nested `+` button calls `enqueueTrack` and does NOT trigger the row's play handler.
- `onPlay` prop overrides the default `playQueue` behavior.
**`AlbumCard.test.ts` (additions):**
- Click on `+` calls `enqueueTracks(album.tracks)` after fetching album detail; does NOT navigate.
- Click on the card area still navigates to `/albums/:id`.
**Manual end-to-end** (verified during the plan's final task):
1. From any page, type "miles" in the header bar → URL becomes `/search?q=miles`, three sections appear.
2. Click a track → radio plays (one-track queue today via the stub); the bottom bar shows the seed.
3. Click `+` on another track → appended to queue (verify queue length increments via `player.queue.length` in dev tools).
4. Click `+` on an album card → all the album's tracks append; current playback uninterrupted.
5. Click "See all 47 artists →" → overflow page loads; "Load more" pulls additional pages.
6. Click an artist → navigates to `/artists/:id`; the header search bar still shows "miles".
7. Browser back → returns to `/search?q=miles` with cached results.
8. Backspace through the input → URL clears; main page returns to "Start typing" prompt.
## Risks & mitigations
- **Radio stub looks broken.** A click on a track currently produces a one-track queue, which feels like "click did nothing meaningful." Mitigation: skeleton MVP framing; the contract is forward-compatible and the M4 task will deliver the real candidate-pool logic. The fallback playback works; the limitation is honesty about scope.
- **TrackRow refactor regresses album page.** The `<button>``<div role="button">` change touches an already-shipped surface. Mitigation: existing TrackRow tests run as part of the slice; the album-page tests (`src/routes/albums/[id]/album.test.ts`) re-run; manual click + keyboard activation verified before the slice ships.
- **Header search bar squeezes mobile layout.** Mitigation: the input gets `flex-1 min-w-0` so it shrinks gracefully; deeper mobile polish is a separate slice.
- **Per-keystroke `goto` history pollution.** Already mitigated via `replaceState` once the user is on `/search`.
- **Over-fetch on overflow pages.** Each overflow page receives all three facets in the response. Acceptable: the request is one round-trip and the unused fields are bounded by `limit=50` × 2 facets = ≤ 100 unused records. Migrating to per-facet endpoints later is a backward-compatible move if it ever becomes load-bearing.
@@ -1,242 +0,0 @@
# M2 Likes Sub-Plan — Design Spec
**Status:** approved 2026-04-26
**Slice:** M2 (Events, sessions, general likes), spec §13 step 6 + scope extension. The original spec only covers track-level `general_likes`; this slice extends to album and artist starring for full Subsonic compatibility.
**Fable tasks:** #326 (server + Subsonic) and #327 (web UI heart + Liked view); closes M2 along with #328 (milestone gate verification).
## Goal
Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page.
## Non-goals
- Per-context likes, session vector capture on like (`contextual_likes`) — that's M3 territory; the table already exists from migration `0005_events`.
- Aggregate `artist_preferences` (computed weighted signal) — M3.
- Liked-state propagation via WebSocket / SSE. Subsonic-driven changes only show up after the next refetch (manual reload or TanStack's window-focus refetch).
- Bulk operations (like-all-from-album, etc.). Single-entity like/unlike only.
- Sorting controls on `/library/liked`. Default `liked_at DESC` is the only order in v1.
- Importing likes from Last.fm / ListenBrainz / Spotify. v1 only persists what the user does in this server.
## Architecture
Three new tables — one per entity type — keyed on `(user_id, entity_id)` with `liked_at timestamptz`. Three native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus a single `/api/likes/ids` endpoint that returns flat id sets for client-side heart-rendering. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` handlers route through the same per-table writes.
The web client exposes one `LikeButton.svelte` component that takes `(entityType, entityId)` and reads from a single `createLikedIdsQuery()` TanStack Query whose data shape is `{ track_ids: string[], album_ids: string[], artist_ids: string[] }`. Mutations do optimistic `setQueryData` updates with rollback on error. Heart placements: `TrackRow`, `AlbumCard`, `ArtistRow`, `PlayerBar`. New `/library/liked` page with three sections backed by infinite queries.
**No `liked` flag on entity refs.** Server doesn't add a join to every track/album/artist response. Liked state is computed on the client via O(1) Set lookups from the cached `likedIdsQuery`. Single source of truth; cache is shared across all hearts in the app; mutations invalidate it once and every component re-renders.
## Schema (migration `0006_likes.up.sql`)
```sql
CREATE TABLE general_likes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
CREATE TABLE general_likes_albums (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, album_id)
);
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
CREATE TABLE general_likes_artists (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, artist_id)
);
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
```
Down migration drops all three tables.
## API contracts
### Native `/api/likes/...`
```ts
// Idempotent upsert. 204 on success, 404 if entity doesn't exist, 400 on bad UUID.
POST /api/likes/tracks/:track_id 204
POST /api/likes/albums/:album_id 204
POST /api/likes/artists/:artist_id 204
// Idempotent delete. 204 regardless of prior state.
DELETE /api/likes/tracks/:track_id 204
DELETE /api/likes/albums/:album_id 204
DELETE /api/likes/artists/:artist_id 204
// Paginated detail listings, sorted by liked_at DESC.
GET /api/likes/tracks?limit=&offset= Page<TrackRef>
GET /api/likes/albums?limit=&offset= Page<AlbumRef>
GET /api/likes/artists?limit=&offset= Page<ArtistRef>
// Flat id set for heart-button rendering.
GET /api/likes/ids { track_ids: string[], album_ids: string[], artist_ids: string[] }
```
All gated by the existing `RequireUser` middleware. `user_id` comes from request context; nothing in the URL or body identifies the user. Cross-user isolation is enforced by the queries (`WHERE user_id = $1`).
### Subsonic `/rest/...`
```
GET /rest/star?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/unstar?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
GET /rest/getStarred → <starred> with song/album/artist children
GET /rest/getStarred2 → <starred2> (OpenSubsonic JSON variant)
```
`star`/`unstar` accept any combination of `id`, `albumId`, `artistId` (zero or more of each). For each present param, upsert (or delete) the matching row in the matching table. Missing-entity → standard Subsonic `<error code="70">` (data not found). Repeated params (e.g. `id=A&id=B`) — accept the first occurrence only for v1; document as known limitation.
`getStarred` and `getStarred2` return the user's stars under a `<starred>` (or `starred2` JSON) wrapper containing arrays of `song`, `album`, `artist` children, each sorted `liked_at DESC`. The existing `TrackRef`/`AlbumRef`/`ArtistRef` Subsonic serialization (used by search3, getAlbum, getArtist) is reused here.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Three tables + indexes. |
| `internal/db/queries/likes.sql` | sqlc queries: `Insert*Like`, `Delete*Like`, `List*Likes` (paginated), `Count*Likes`, `List*LikedIds` for each of the three tables. |
| `internal/db/dbq/likes.sql.go` | Generated bindings. |
| `internal/api/likes.go` | Handlers for all seven native routes. |
| `internal/api/likes_test.go` | HTTP-level coverage. |
| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. |
| `internal/subsonic/star_test.go` | Subsonic coverage. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/api.go` | Register `/api/likes/{tracks,albums,artists}/:id` (POST + DELETE), `/api/likes/{tracks,albums,artists}` (GET, paginated), `/api/likes/ids` (GET). Inside the authed group. |
| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2` in the Mount table. |
### New web files
| Path | Responsibility |
|---|---|
| `web/src/lib/api/likes.ts` | TanStack Query helpers: `createLikedIdsQuery()` and per-entity infinite-query factories. Mutation factories: `likeTrack(id)`, `unlikeTrack(id)`, plus album and artist variants. Each mutation does optimistic update via `setQueryData` and rolls back on error. |
| `web/src/lib/api/likes.test.ts` | Cache-update unit tests. |
| `web/src/lib/components/LikeButton.svelte` | Heart icon. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. Reads `createLikedIdsQuery()`; click toggles via the right mutation; `event.stopPropagation()` so it doesn't bubble to `TrackRow`'s row-click. |
| `web/src/lib/components/LikeButton.test.ts` | Renders correct fill state; click toggles correctly per entity type; click stops propagation. |
| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists / Albums / Tracks) backed by infinite queries. |
| `web/src/routes/library/liked/liked.test.ts` | Page integration. |
### Modified web files
| Path | Change |
|---|---|
| `web/src/lib/api/types.ts` | Add `LikedIdsResponse = { track_ids: string[]; album_ids: string[]; artist_ids: string[] }`. |
| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks(q)`, `qk.likedAlbums(q)`, `qk.likedArtists(q)` keys. Re-export from `likes.ts` (or leave them in their own module — match prior convention). |
| `web/src/lib/components/TrackRow.svelte` | Insert `<LikeButton entityType="track" entityId={track.id} />` next to the existing `+ queue` button. Grid template grows to `[32px_1fr_auto_auto_auto]`. |
| `web/src/lib/components/AlbumCard.svelte` | Add a second overlay button row beneath the existing `+ queue` overlay (or stack them vertically) with `<LikeButton entityType="album" entityId={album.id} />`. |
| `web/src/lib/components/ArtistRow.svelte` | Add `<LikeButton entityType="artist" entityId={artist.id} />` to the row's right side, before the chevron. |
| `web/src/lib/components/PlayerBar.svelte` | Add `<LikeButton entityType="track" entityId={current.id} />` next to the title in the left cluster (cover + title + artist link). |
| `web/src/lib/components/Shell.svelte` | Add a "Liked" entry to `navItems` pointing at `/library/liked`. |
## Data flow
**Like a track from the album page:**
1. User clicks heart on a `TrackRow`.
2. `LikeButton` reads `createLikedIdsQuery()` data; sees `track_ids` doesn't include this id; fires `likeTrack(id)` mutation.
3. Mutation does optimistic `setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))` so heart fills instantly. Then `POST /api/likes/tracks/:id`.
4. Server inserts into `general_likes` with `ON CONFLICT DO NOTHING`. Returns 204.
5. Mutation `onSuccess` invalidates `qk.likedIds` and `qk.likedTracks` (so `/library/liked` refetches if visible). `onError` rolls back the optimistic update from a `previousIds` snapshot captured in `onMutate`.
**Star from a Subsonic client:**
1. Client POSTs `/rest/star?id=<track_uuid>&u=admin&t=...`.
2. `internal/subsonic/star.go::handleStar` resolves auth, parses any combination of `id`/`albumId`/`artistId`, upserts each non-empty into the matching table, returns `ok` envelope.
3. Web SPA's `createLikedIdsQuery()` doesn't see it until next refetch (TanStack default refetch-on-window-focus or manual invalidation). Acceptable — Subsonic-driven changes flow through eventually.
**View liked items:**
1. User clicks "Liked" → `/library/liked`.
2. Page mounts three `createInfiniteQuery` instances. Each fires `GET /api/likes/{type}?limit=50&offset=0`.
3. Server SQL: e.g. `SELECT t.* FROM tracks t JOIN general_likes l ON l.track_id = t.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC LIMIT $2 OFFSET $3`. Plus `SELECT count(*) FROM general_likes WHERE user_id = $1` for the `total`.
4. Returns `Page<TrackRef>` (and analogous for albums/artists).
5. Page renders three sections; each has its own "Load more" per the existing infinite-query convention. Empty section shows "No liked X yet".
**Heart on PlayerBar:**
`<LikeButton entityType="track" entityId={current.id} />` — same component as TrackRow's heart. The fill state is reactive: when `player.current` changes, the derived id-membership check re-runs against the cached id set.
**Liked-state propagation across the app:** every heart subscribes to the same `qk.likedIds` query. A mutation invalidation triggers all of them to re-render. No prop drilling or pub-sub needed.
## States
| Condition | Render |
|---|---|
| `likedIdsQuery.isPending` | Heart is disabled and shows a neutral state (outline). Action defers; quick to load. |
| `likedIdsQuery.isError` | Heart shows outline; clicks are no-ops; no error toast (likes are non-critical). |
| Set contains entity id | Filled heart, `aria-pressed="true"`. |
| Set does NOT contain entity id | Outline heart, `aria-pressed="false"`. |
`/library/liked` page states (per section, reusing patterns from search):
- `q.isPending && items.length === 0``LibrarySkeleton` for that section
- `q.isError``ApiErrorBanner`
- `total === 0` → "No liked X yet" hint
- otherwise → list/grid + "Load more" if `hasNextPage`
## Testing
### Server (`go test`)
- **Migration smoke** — applying `0006_likes` round-trips the three tables; insert+select for each.
- **Native handlers** (`internal/api/likes_test.go`):
- Like-track happy path (204; row exists; idempotent — 204 on repeat).
- Like with malformed UUID → 400; nonexistent UUID → 404.
- Unlike happy path (204; row gone; idempotent).
- List endpoint returns paginated `Page<TrackRef>` sorted `liked_at DESC`; `total` reflects user's full liked set; `limit`/`offset` work.
- `GET /api/likes/ids` returns three string arrays containing exactly the ids of the user's rows.
- Cross-user isolation — Alice likes track X, Bob's `/api/likes/ids` does not contain X.
- One coverage case per entity type (album, artist) on the like+unlike+list+ids paths.
- **Subsonic handlers** (`internal/subsonic/star_test.go`):
- `star?id=<track>` writes to `general_likes`, returns `ok` envelope.
- `star?albumId=<album>&artistId=<artist>` writes to both album and artist tables in one call.
- `star?id=<unknown>` returns Subsonic error 70 (data not found).
- `unstar` removes the row, returns `ok`. Idempotent.
- `getStarred2` returns artist/album/song arrays correctly populated, sorted `liked_at DESC`. Cross-user isolation verified.
### Web (`vitest`)
- **`likes.test.ts`** — TanStack Query factory + mutations:
- `createLikedIdsQuery` shape (`{ track_ids, album_ids, artist_ids }`).
- `likeTrack(id)` optimistic update inserts id into `track_ids`; mocked failure rolls back.
- `unlikeTrack(id)` removes id; rollback restores.
- Same coverage for album/artist mutations.
- **`LikeButton.test.ts`**:
- Renders empty heart when id is NOT in the matching set; filled heart when it IS.
- Click on empty heart fires the like mutation (mocked).
- Click on filled heart fires the unlike mutation.
- Click stops propagation (doesn't bubble to parent `<div role="button">` on TrackRow).
- **`liked.test.ts`** (`/library/liked` page):
- Three sections render with mocked infinite queries.
- Empty section shows "No liked X yet" hint (gate on `total === 0` per the search-page pattern).
- "Load more" calls `fetchNextPage` per section.
- **`TrackRow.test.ts` / `AlbumCard.test.ts` / `ArtistRow.test.ts` / `PlayerBar.test.ts`** updates: each gets one new test asserting `<LikeButton>` is rendered with the right `entityType` + `entityId`. The button's behavior is covered in `LikeButton.test.ts`; we just verify wiring.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Click heart on a track → fills instantly. Refresh page → still filled. `psql` shows the row in `general_likes`.
2. Click heart again → empties. Row gone.
3. Click heart on an album card. Click heart on an artist row. Verify `general_likes_albums` and `general_likes_artists` rows.
4. Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
5. Unstar from Subsonic. Web SPA's heart empties on next refetch.
6. Visit `/library/liked`. Three sections render with the items above. "Load more" works once you have > 50 of any type.
7. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered.
## Risks & mitigations
- **Likes diverge from Subsonic on partial failures.** If a Subsonic client sends `star?id=A&albumId=B` and the album doesn't exist, the track gets starred but album fails. Mitigation: validate all entity ids first; if any is missing, return error 70 BEFORE writing any. Atomicity matters less than predictability for clients.
- **`createLikedIdsQuery` payload grows unbounded for power users.** For 10k liked tracks, the array is ~370 KB — acceptable. For 100k+ users it becomes a concern. Mitigation: not a v1 problem; document and revisit if telemetry shows large libraries. A per-entity-type "is liked" check endpoint would be a clean upgrade path.
- **Optimistic update / refetch race.** If a mutation is in-flight when a refetch lands, the rollback might overwrite the actual server state with stale "previous" data. TanStack's mutation flow handles this — `onError` only fires on actual error, and the `setQueryData` we do in `onMutate` is the optimistic value, not stale.
- **Cross-table star with mixed presence.** `star?id=&albumId=` where one exists and the other doesn't — option A: 404 the whole call; option B: succeed for the existing one, ignore the missing one. We pick A (atomicity) per the first risk above. Validation pass before any insert.
- **`liked_at` clock skew across replicas.** Single-instance deployment for v1 — defer.
@@ -1,230 +0,0 @@
# M3 Weighted Shuffle v1 — Design Spec
**Status:** approved 2026-04-27
**Slice:** M3 sub-plan #1 of 3 (recommendation engine v1 + contextual likes). Spec §13 step 7. Step 8 (session vectors + contextual_match_score) is the next sub-plan; this slice ships the scoring foundation without the contextual term.
**Fable task:** #340.
## Goal
Replace the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. After this slice, clicking a track in the SPA's search/track-row "play radio" surface produces a meaningful 50-track queue instead of a one-track stub.
## Non-goals
- Session vectors / `contextual_match_score` (sub-plan #2 / Fable #341 covers the write path; sub-plan #3 / Fable #342 folds the score into this function).
- Real "radio" candidate pools from ListenBrainz similarity / similar artists / similar tags (M4).
- Album-seed or artist-seed radio (`?seed_album=`, `?seed_artist=`). v1 is track-seeded only; the Fable task covers that scope explicitly.
- Periodic radio refresh (the spec's "regenerate at 80% consumed"). Player-side concern; lands later.
- Persistent radio queues. Each `/api/radio` call returns a fresh selection — stateless on the server.
- Performance optimization for very large libraries (>50k tracks). At v1 scale the wide-pool scan is fine; M3.5 / M4 can add sampling if telemetry shows it matters.
- `Subsonic` star-radio compatibility. Subsonic's `getRandomSongs` is a different shape; we don't try to map weighted shuffle into it for v1.
## Architecture
A new `internal/recommendation` package owns:
- A pure scoring function `Score(inputs, weights, now, rng) → float64` with no DB dependency. The `rng` parameter is injectable so tests pin jitter to deterministic values.
- A candidate loader `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` that runs ONE SQL query joining `tracks`, `general_likes`, and aggregated `play_events` to produce the per-track stats the scoring function needs. Excludes the seed and any track played within the recently-played window.
- A `Shuffle(candidates, weights, now, rng, limit)` orchestrator that scores each candidate, sorts descending, truncates to `limit`. Pure.
The `/api/radio` handler becomes a thin shim: validate the seed → call `LoadCandidates` → call `Shuffle` → prepend the seed to the result → project to `[]TrackRef` → return JSON.
**Why split into three layers:**
- `score.go` is the math; trivially unit-testable, no infra.
- `candidates.go` is the data access; integration-tested against a live DB.
- `shuffle.go` is the composition; pure-function tests against fake candidate sets.
- The HTTP handler is the I/O glue; lives in `api/radio.go` like the existing pattern.
This separation also sets up sub-plan #3 (contextual scoring) cleanly — we'll add `LoadContextualSimilarity` next to `LoadCandidates`, extend `ScoringInputs` with `ContextualMatchScore`, and the rest of the chain doesn't change.
## Schema
No migration. All inputs are computed on demand from existing tables:
- `general_likes` for `is_general_liked`.
- `play_events` for `last_played_at`, `play_count`, `skip_count`. Aggregations done in the candidate-loader query.
- `tracks` provides the candidate set itself.
The recently-played-in-the-last-hour filter is applied at candidate-load time (a single `WHERE NOT EXISTS (... AND started_at > now() - interval '1 hour')` clause). Hard suppression — these tracks aren't even scored.
## Scoring math
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
}
type ScoringWeights struct {
BaseWeight float64 // default 1.0
LikeBoost float64 // default 2.0
RecencyWeight float64 // default 1.0
SkipPenalty float64 // default 1.0
JitterMagnitude float64 // default 0.1
}
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
s := w.BaseWeight
if in.IsGeneralLiked {
s += w.LikeBoost
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
```
**`recencyDecay(lastPlayed *time.Time, now time.Time) float64`** — returns `[0, 1]`:
- Never played (`lastPlayed == nil`) → `1.0`. Cold-start tracks compete favorably with stale ones; otherwise the recommendation engine never surfaces music the user hasn't tried.
- Played within last hour: doesn't reach this function (filtered earlier at the candidate-pool stage).
- Otherwise: `min(age_days / 30.0, 1.0)`. Linear ramp; tracks ≥ 30 days stale hit max recency boost.
**`skipRatio(plays, skips int) float64`** — returns `[0, 1]`:
- `plays == 0``0.0`. Don't penalize never-played tracks.
- Otherwise → `float64(skips) / float64(plays)`.
**Random jitter**`(rng()*2 - 1) * JitterMagnitude` produces values in `[-magnitude, +magnitude]`. The `rng` parameter is `func() float64` (matches `math/rand.Float64`); tests inject a fixed-value version for deterministic ordering.
**Score range under defaults:**
- Min (unliked, recent, all-skips): `1.0 + 0 + 0 - 1.0 - 0.1 = -0.1`
- Max (liked, ≥30d stale, never skipped): `1.0 + 2.0 + 1.0 - 0 + 0.1 = 4.1`
- Liked-track structural advantage (`LikeBoost = 2.0`) dominates the jitter band (`±0.1`), so liked tracks always rank above identical unliked tracks.
Configurable via YAML / env. Operators can crank `LikeBoost` up if their library is dominated by stuff they don't actually like, or `JitterMagnitude` up if they want more variety.
## API contracts
**Request:**
```
GET /api/radio?seed_track=<uuid>&limit=<int>
```
- `seed_track` (required) — UUID of the track that seeds the radio.
- `limit` (optional, default 50, max 200) — total number of tracks to return *including* the seed.
**Response (shape unchanged from M2 stub, only contents grew):**
```ts
type RadioResponse = { tracks: TrackRef[] };
```
- `tracks[0]` is always the seed track (so `playQueue(resp.tracks, 0)` plays the seed).
- `tracks[1..]` are the top-scored candidates from the user's library, descending. Length up to `limit - 1`.
- Cold-start (empty library beyond the seed): returns `{ tracks: [<seed>] }`.
**Errors (unchanged from M2 stub):**
- `400 bad_request` — missing `seed_track`, malformed UUID, or `limit < 1`.
- `404 not_found``seed_track` doesn't exist.
- `500 server_error` — DB issue.
**No web client changes.** The existing `playRadio(seedTrackId)` already calls this endpoint and feeds `resp.tracks` into `playQueue(tracks, 0)`. After this slice the queue is fuller; the call shape is identical.
**No Subsonic changes.** Track-seeded radio isn't part of Subsonic's surface in our v1 scope.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/recommendation/score.go` | Pure scoring function + `recencyDecay` + `skipRatio` helpers. No DB. |
| `internal/recommendation/score_test.go` | Boundary cases: every term, cold-start, jitter determinism, score ranges. |
| `internal/recommendation/candidates.go` | `LoadCandidates(...)` — single SQL query returning `[]Candidate` (`Track + ScoringInputs`). |
| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. |
| `internal/recommendation/shuffle.go` | `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. Pure. |
| `internal/recommendation/shuffle_test.go` | Pure-function tests: liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder-structural-winners, limit. |
| `internal/db/queries/recommendation.sql` | sqlc query: `LoadRadioCandidates` — SELECT tracks WITH LEFT JOIN general_likes, LEFT JOIN aggregated play_events stats. WHERE clause excludes seed_id and last-hour plays. |
| `internal/db/dbq/recommendation.sql.go` | Generated bindings. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/radio.go` | Replace stub. New flow: validate `seed_track` and `limit`, call `recommendation.LoadCandidates`, call `recommendation.Shuffle`, prepend seed, project to `[]TrackRef`, return JSON. |
| `internal/api/radio_test.go` | Replace stub tests with: cold-start, typical (seed + scored picks), 404 on unknown seed, 400 on bad seed/limit, cross-user isolation. |
| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` struct: `BaseWeight` (1.0), `LikeBoost` (2.0), `RecencyWeight` (1.0), `SkipPenalty` (1.0), `JitterMagnitude` (0.1), `RecentlyPlayedHours` (1), `RadioSize` (50), `RadioSizeMax` (200). YAML key `recommendation:`. |
| `internal/api/api.go` | `handlers` struct gains `recCfg config.RecommendationConfig`. `Mount` signature gains the config arg; constructs the handler with it; the radio handler builds `ScoringWeights` from it per request. |
| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. |
### No web changes
The existing `playRadio(seedTrackId)` already consumes `RadioResponse`. The web slice for this PR is empty — server-only.
## Data flow
1. SPA calls `playRadio(seedTrackId)` (existing player-store action). It POSTs `GET /api/radio?seed_track=<id>` (no explicit `limit`, server defaults to 50).
2. `handleRadio` validates auth + seed UUID. Looks up the track to confirm it exists (404 otherwise).
3. Reads `limit` from query (default `cfg.Recommendation.RadioSize`, clamped to `cfg.Recommendation.RadioSizeMax`).
4. Calls `recommendation.LoadCandidates(ctx, q, userID, seedID, cfg.Recommendation.RecentlyPlayedHours)`. The SQL query joins:
- `tracks t` — base set
- `LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id``is_liked`
- `LEFT JOIN LATERAL (SELECT max(started_at) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_last → last_played_at`
- `LEFT JOIN LATERAL (SELECT count(*), count(*) FILTER (WHERE was_skipped) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_stats → play_count, skip_count`
- `WHERE t.id <> seed_id AND NOT EXISTS (SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - interval '$2 hours')`
5. Iterates the candidates, calls `Score(...)` per track, sorts descending, truncates to `limit - 1`.
6. Prepends the seed track, projects each `Candidate.Track` to `TrackRef` (existing `trackRefFrom` helper), writes `RadioResponse{ Tracks: ... }`.
7. SPA receives 50 tracks, plays from index 0 (the seed).
**Stateless** — every call recomputes from scratch. No persisted radio queue, no cursor tracking, no "you already heard this" memory beyond the recently-played-hours window.
## Testing
### Server (`go test`)
**`internal/recommendation/score_test.go`** (pure unit tests):
- Base case (never played, not liked, no skip data) with deterministic RNG `() => 0.5` → score = `BaseWeight + RecencyWeight + 0` exactly.
- Liked boost: same inputs but `IsGeneralLiked=true` → score increases by `LikeBoost`.
- Recency ramp: `lastPlayedAt = now - 15d``recencyDecay = 0.5`. `lastPlayedAt = now - 60d``1.0` (capped).
- Skip ratio: `PlayCount=4, SkipCount=2` → ratio `0.5`, score loses `0.5 * SkipPenalty`.
- Cold-start skip: `PlayCount=0, SkipCount=0` → ratio `0.0`, no penalty.
- Jitter bounds: 1000 `Score(...)` calls with `math/rand.Float64`, every result within `[mid - JitterMagnitude, mid + JitterMagnitude]` where `mid` is the deterministic-RNG score.
- Determinism: same `(inputs, weights, now, rng)` returns the same score (regression guard for hidden global state).
**`internal/recommendation/shuffle_test.go`** (pure unit tests):
- Liked-vs-not: two otherwise-identical candidates → liked one ranks higher.
- High-skip rejected: candidate with `skipRatio=1.0` ranks last among otherwise-identical candidates.
- Limit truncates: 100 candidates, `limit=10` → result has 10.
- Jitter doesn't reorder structural winners: 100 random RNG seeds → liked track ALWAYS ranks above an equivalent unliked track.
- Empty input → empty output.
**`internal/recommendation/candidates_test.go`** (live DB):
- Seed exclusion: 5 tracks in library, ask for radio with one as seed → returns the other 4.
- Recently-played exclusion: seed a `play_events` row with `started_at = now - 30 minutes` for track A → radio excludes A.
- Stat join: track with one play + one skip → `play_count=1, skip_count=1`. Liked track → `is_liked=true`. Never-played → `last_played_at IS NULL`.
- Cross-user: Alice's plays / likes do NOT appear in Bob's stats.
**`internal/api/radio_test.go`** (HTTP integration, replacing existing stub tests):
- Cold-start: seed only in library → response is `{ tracks: [seed] }`.
- Typical: seed + 5 other tracks → response is `[seed, 5 ranked]`. Seed always at index 0.
- 404 on unknown seed.
- 400 on missing/malformed seed.
- 400 on `limit=0` or `limit < 0`.
- `limit > RadioSizeMax` clamped (returns at most `RadioSizeMax`, no error).
- Cross-user isolation: Alice has many plays + likes → Bob's radio uses Bob's clean stats.
**Coverage:** `go test -coverprofile=cover.out ./internal/recommendation/...` → ≥ 70% per the M3 milestone description. The pure-function tests should hit ~100% on `score.go` + `shuffle.go`; `candidates.go` reaches similar coverage via the integration tests.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Play 3 tracks all the way through (build `play_count` history).
2. Skip a 4th track within 10 seconds (creates a high `skip_ratio`).
3. Like 2 tracks via the heart button.
4. Click radio on a 5th track.
5. Inspect the response in dev tools network tab — 50 tracks, seed first.
6. The 2 liked tracks appear early in the list (within first ~20).
7. The just-skipped track appears late or absent.
8. The 3 just-played tracks are absent (recently-played exclusion).
9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently.
## Risks & mitigations
- **Wide-pool scan performance.** For a 50k-track library: 50k rows × scoring overhead × sort ≈ a few hundred ms in Go. Below the user-perceptible threshold for a click-to-play. If telemetry later shows >1s P95 latency, M3.5 / M4 can add sampling (random-N candidate pre-filter) or a partial index. The candidate-pool function is the swap point; the scoring function and HTTP handler don't change.
- **Recently-played window edge effects.** If the user plays 50 tracks in an hour, all of them get excluded from the next radio call. Library < 100 tracks could leave the candidate pool too thin to fill `RadioSize`. Mitigation: handler returns whatever it can fit (length might be < `RadioSize`); SPA's `playQueue` works on any non-empty array. Document as acceptable degradation; users with tiny libraries get short radios.
- **Skip-ratio gaming.** A user who skips a track once (during initial library discovery) gets a `skipRatio=1.0` for that track. With `SkipPenalty=1.0` that's a `-1.0` hit — could permanently demote the track even if they like it. Mitigation: weights are tunable; user can lower `SkipPenalty`. Future: smoothing (e.g. `(skips + α) / (plays + β)`) instead of raw ratio. Out of scope for v1.
- **Cold-start RNG dominance.** A brand-new user with zero plays / likes gets every track scored at `BaseWeight + RecencyWeight + jitter`. Top-N is essentially random. That's fine — it matches user expectation ("I haven't told you anything about me, give me random music"). The recency-decay max-for-never-played avoids amplifying new tracks just because they're new.
- **Score formula evolution.** Adding `contextual_match_score` in sub-plan #3 means changing `ScoringInputs` and the `Score` function signature. Mitigation: the function is internal-package only; sub-plan #3 changes both ends of the call atomically. No external consumers.
@@ -1,301 +0,0 @@
# M3 sub-plan #3 — Session similarity + contextual_match_score in scoring
**Status:** Spec draft, 2026-04-27
**Tracking:** Fable #342
**Closes:** M3 milestone (recommendation engine v1)
**Builds on:**
- #340 — weighted shuffle baseline (`internal/recommendation` package, `Score`/`Shuffle`, `LoadRadioCandidates`)
- #341 — session vectors at play_started + `contextual_likes` capture on like
## 1. Goal
Add the `contextual_match_score` term to the recommendation scoring formula. For each
candidate track, compute the maximum weighted-Jaccard similarity between the user's
*current* session vector and the session vectors stored on that track's active
`contextual_likes` rows. Fold that scalar into `Score()` as
`+ contextual_match_score * ContextWeight`.
When this slice ships, `/api/radio` produces context-aware recommendations: tracks the
user has previously liked while in similar musical contexts get an additive boost on
top of the v1 weighted-shuffle baseline.
## 2. Non-goals
- No new UI surface — `/api/radio` response shape is unchanged.
- No tag enrichment beyond `tracks.genre` (MBID tags / BPM remain post-v1).
- No similarity-axis weight exposure in YAML (hardcoded `0.7 / 0.3` for v1).
- No caching of the current session vector across requests.
- No "why this track?" debug endpoint.
- No ListenBrainz / external-similarity retrieval (M4).
- No GIN index on `play_events.session_vector_at_play` — we read the user's most
recent play by id, not by similarity. Existing `(user_id, started_at)` access
pattern is sufficient.
## 3. Architecture overview
Three additions to `internal/recommendation`, two adjustments to existing files,
one new sqlc query.
### 3.1 New: `internal/recommendation/similarity.go` (pure)
```go
type SimilarityWeights struct {
TagsWeight float64
ArtistsWeight float64
}
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
// Hardcoded; not exposed via YAML — operators can't tune this for v1.
var DefaultSimilarityWeights = SimilarityWeights{
TagsWeight: 0.7,
ArtistsWeight: 0.3,
}
// Similarity returns weighted-Jaccard similarity in [0, 1]. Returns 0 if
// either input is Seed=true (low-confidence vectors don't contribute).
func Similarity(a, b SessionVector, w SimilarityWeights) float64
// ContextualMatchScore is the per-candidate scalar fed into ScoringInputs.
// Returns 0 if current.Seed is true OR likes (after filtering Seed=true
// entries) is empty. Otherwise: max(Similarity(current, l) for l in likes).
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64
```
**Per-axis semantics (classic set Jaccard):**
- Tags axis flattens `map[string]int` keysets and computes `|A ∩ B| / |A B|`.
Bag-of-counts data is preserved on disk; we discard counts at similarity time.
Generalized Jaccard remains a one-line upgrade path if telemetry justifies it.
- Artists axis is already a `[]string` (deduplicated artist UUIDs); same Jaccard.
- Both axes empty (zero union) → axis returns 0.0, not NaN.
### 3.2 New: `internal/db/queries/contextual_likes.sql`
```sql
-- name: ListActiveContextualLikesForUser :many
-- Returns all the user's active (non-soft-deleted) contextual_likes with
-- non-null vectors. Cardinality bounded by the user's actual like-while-
-- playing history — typically tens to low hundreds.
SELECT track_id, session_vector
FROM contextual_likes
WHERE user_id = $1 AND deleted_at IS NULL AND session_vector IS NOT NULL;
```
### 3.3 New: `internal/db/queries/events.sql` (or `recommendation.sql`)
```sql
-- name: GetCurrentSessionVectorForUser :one
-- Returns the session_vector_at_play of the user's most recent play_event
-- in a still-active (un-timed-out) session. NoRows means no current vector
-- — caller treats this as Seed=true sentinel.
SELECT pe.session_vector_at_play
FROM play_events pe
JOIN play_sessions s ON s.id = pe.session_id
WHERE pe.user_id = $1
AND s.ended_at IS NULL
ORDER BY pe.started_at DESC
LIMIT 1;
```
(Final placement decided at implementation time — wherever sqlc and existing
query files line up best.)
### 3.4 Modified: `internal/recommendation/score.go`
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time
PlayCount int
SkipCount int
ContextualMatchScore float64 // NEW — in [0, 1], 0 when no signal
}
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64 // NEW
}
// Score gains: + in.ContextualMatchScore * w.ContextWeight
```
Zero-value defaults: a `ScoringInputs{}` with zero `ContextualMatchScore` and a
`ScoringWeights{}` with zero `ContextWeight` produce the v1 score. Existing callers
not passing the new fields see no behavior change.
### 3.5 Modified: `internal/recommendation/candidates.go`
```go
func LoadCandidates(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector, // NEW
) ([]Candidate, error)
```
Body adds:
1. Existing `LoadRadioCandidates` call.
2. New `ListActiveContextualLikesForUser(userID)` call.
3. Group result by `track_id` into `map[pgtype.UUID][]SessionVector`, unmarshaling
each `jsonb` column into `SessionVector`. Unmarshal failures are logged and
skipped (don't poison the entire response over one bad row).
4. For each candidate, set `Inputs.ContextualMatchScore =
ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights)`.
### 3.6 Modified: `internal/api/radio.go`
Before calling `LoadCandidates`, fetch the current session vector:
```go
var currentVec recommendation.SessionVector
if raw, err := q.GetCurrentSessionVectorForUser(ctx, user.ID); err == nil && len(raw) > 0 {
if jerr := json.Unmarshal(raw, &currentVec); jerr != nil {
h.logger.Warn("api: radio: bad session_vector_at_play", "err", jerr)
currentVec = recommendation.SessionVector{Seed: true}
}
} else {
currentVec = recommendation.SessionVector{Seed: true}
}
```
Pass `currentVec` into `LoadCandidates`. Pass `recCfg.ContextWeight` through into the
`ScoringWeights` struct alongside the existing weights.
### 3.7 Modified: `internal/config/config.go`
```go
type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"` // NEW, default 2.0
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
```
`Default()` populates `ContextWeight: 2.0`.
## 4. Request flow
```
GET /api/radio?seed_track=<uuid>&limit=N
handleRadio
1. Auth, parse seed_track, parse limit (unchanged)
2. q.GetCurrentSessionVectorForUser(userID) (NEW)
NoRows / NULL / unmarshal fail → SessionVector{Seed: true}
3. recommendation.LoadCandidates(...) (extended)
a. q.LoadRadioCandidates (unchanged)
b. q.ListActiveContextualLikesForUser (NEW)
c. group by track_id → map[uuid][]SessionVector
d. per candidate: ContextualMatchScore() → ScoringInputs
4. recommendation.Shuffle(candidates, weights, now, rng, limit-1)
Score() now folds in ContextualMatchScore * ContextWeight
5. Resolve album/artist, build response (unchanged)
```
## 5. Cold-start handling
Every cold-start path collapses to `contextual_match_score = 0` for all candidates,
so scoring degrades cleanly to v1 behavior:
| Condition | Path |
|------------------------------------------------------|------------------------------------------------------|
| User has no `play_events` at all | `NoRows` → `Seed=true` sentinel → `ContextualMatchScore` returns 0 |
| User has plays but no active session | `NoRows` (joined `s.ended_at IS NULL` filters) |
| Active session but `session_vector_at_play` is NULL | `len(raw) == 0` → `Seed=true` sentinel |
| Vector populated but `Seed=true` | `ContextualMatchScore` short-circuits to 0 |
| Candidate has no `contextual_likes` | absent from map → empty slice → returns 0 |
| Candidate has only `Seed=true` likes | filtered out → empty → returns 0 |
| Candidate has only soft-deleted likes | excluded by `deleted_at IS NULL` in the SQL |
## 6. Test plan
### 6.1 `similarity_test.go` (pure, table-driven)
- Identical vectors → `1.0`.
- Fully disjoint axes → `0.0`.
- Mixed: shared tags, no shared artists → `0.7 * tagJaccard`.
- Mixed: no shared tags, shared artists → `0.3 * artistJaccard`.
- Either input `Seed=true` → `0.0`.
- Both vectors fully empty → `0.0` (not NaN).
- One side empty on an axis, other side populated → that axis contributes 0.
- Weight balance: shared all tags, default weights → exactly `0.7`.
### 6.2 `score_test.go` extensions
- Perfect contextual match (`ContextualMatchScore=1.0`) at `ContextWeight=2.0` adds
exactly `+2.0` to the base score.
- Half match (`0.5`) adds `+1.0`.
- Zero match (`0.0`) leaves score unchanged from v1 behavior — guards backward compat.
### 6.3 `candidates_test.go` (integration vs test DB)
- Candidate with one matching `contextual_like` → `ContextualMatchScore > 0`.
- Candidate with multiple `contextual_likes` → max similarity wins.
- Candidate whose only `contextual_likes` are `Seed=true` → score 0.
- Candidate whose only `contextual_likes` are soft-deleted → score 0 (SQL filter).
- User with no `contextual_likes` anywhere → every candidate scores 0.
- User with only soft-deleted `contextual_likes` → every candidate scores 0.
### 6.4 `radio_test.go` (integration, end-to-end)
- Seed a current session vibe (3+ tracks of one genre/artist set) by inserting
`play_events` with populated `session_vector_at_play`.
- Insert a `contextual_like` whose `session_vector` matches that vibe, on track T.
- Insert an unrelated control track C with no contextual signal.
- Call `/api/radio` with a deterministic RNG and seed track distinct from T and C.
- Assert: T ranks above C in the response.
### 6.5 Coverage gate
Combined `internal/recommendation` coverage stays ≥ 70% (currently 78.5% combined
with `internal/playevents` post-#341; this slice's pure functions are highly
testable so we expect to land closer to 85%+).
## 7. Backwards compatibility
- `/api/radio` request and response shapes are unchanged — same query params, same
JSON output. Web client requires no edits.
- `ScoringInputs.ContextualMatchScore` and `ScoringWeights.ContextWeight` default to
`0` in zero-value structs. Pre-existing tests that construct these directly continue
passing without modification because the new term contributes nothing when both are
zero.
- `LoadCandidates` gains a `currentVector SessionVector` parameter — this is a
signature change, but the only caller is `internal/api/radio.go`, which we update
in this slice. No external consumers.
- DB schema is unchanged (migrations 0007 already shipped the table + indexes
needed for this slice).
## 8. Out-of-scope (deferred)
- Generalized (bag-of-counts) Jaccard if telemetry shows tag-dominance discrimination
matters.
- YAML exposure of `SimilarityWeights`.
- Per-user override of any recommendation weight.
- Caching `currentVector` across requests within a session.
- ListenBrainz / similar-artist retrieval (M4).
- `/api/radio?explain=true` style debug endpoint.
- Tag enrichment beyond `tracks.genre`.
## 9. Milestone gate (closes M3)
After this slice merges:
- Recommendation engine has all three v1 components: weighted shuffle, session
vectors written, contextual matching folded into scoring.
- Manual end-to-end verification: like a track during a session of one vibe, build a
similar session later, observe the track surfaces above unrelated controls in
`/api/radio` output.
- M4 (radio refinements + scrobble polish) unblocked.
@@ -1,251 +0,0 @@
# M3 Session Vectors + Contextual Likes — Design Spec
**Status:** approved 2026-04-27
**Slice:** M3 sub-plan #2 of 3 (recommendation engine v1 + contextual likes). Spec §6 "Session vector" + §13 step 8.
**Fable task:** #341.
## Goal
Add the two write paths that produce the data the recommendation engine consumes for contextual matching:
1. **At every play_started**, compute a session vector from the prior tracks in the user's current play_session and persist it to `play_events.session_vector_at_play` (column already exists nullable since migration 0005).
2. **At every like**, if the user has an open play_event with a populated session_vector, snapshot it into a new `contextual_likes` row.
This slice is backend-only — no UI surface. It accumulates the data; sub-plan #3 (Fable #342) reads it to add the `contextual_match_score` term to the scoring formula.
## Non-goals
- Adding `contextual_match_score` to the scoring function — that's sub-plan #3.
- Album/artist contextual likes. `contextual_likes` is track-only per spec §5.
- MBID-derived tags. v1 uses `tracks.genre` (denormalized text from migration 0002) as the only tag source. MBID tags are a post-v1 enrichment.
- Audio-feature tags (BPM, energy, key). Require an analysis pipeline we don't have. Post-v1.
- Cross-session vector enrichment. Each session's vector only sees prior tracks within the same play_session row.
- A purge/expiry policy on `contextual_likes`. Rows accumulate indefinitely. v1 problem only when a user has tens of thousands of likes; not a concern at v1 scale.
## Important catch from exploration
The `contextual_likes` table does NOT exist yet. The M2 events migration (0005) commented that "contextual_likes ships nullable now," but the actual `CREATE TABLE` was missed. This slice ships the table in a new migration (`0007`).
## Architecture
A new pure function `BuildSessionVector(priorTracks) → SessionVector` in `internal/recommendation/sessionvector.go`. The vector struct serializes to JSONB with the spec §6 shape: `seed` flag, `artists` set, `tags` bag-of-counts, `recent_track_ids` ordered list.
`internal/playevents.Writer.RecordPlayStarted` extends inside its existing transaction:
1. Auto-close prior open row (existing).
2. FindOrCreate session (existing).
3. Insert play_event (existing).
4. **NEW:** Query the prior tracks in the session via a new sqlc query `ListRecentSessionTracks(sessionID, beforeTime, limit)`.
5. **NEW:** Build the vector via the pure function.
6. **NEW:** UPDATE the just-inserted play_event's `session_vector_at_play` column (new sqlc query `UpdatePlayEventVector`).
`internal/api/likes.handleLikeTrack` extends:
1. `LikeTrack` upsert (existing) — but the sqlc query becomes `:execrows` so the handler can detect "actually inserted" vs "already exists."
2. **NEW:** if rows == 1, look up the user's open play_event. If present and its `session_vector_at_play` is non-NULL, INSERT into `contextual_likes` with the vector + session_id snapshot.
`internal/api/likes.handleUnlikeTrack` extends:
1. `UnlikeTrack` (existing).
2. **NEW:** UPDATE all the user's `contextual_likes` rows for this track to set `deleted_at = now() WHERE deleted_at IS NULL`. Idempotent.
Subsonic `/rest/star` and `/rest/unstar` already call into `dbq.LikeTrack` / `dbq.UnlikeTrack` through `internal/subsonic/star.go`. After this slice, those calls naturally pick up the contextual capture / soft-delete behavior — no code changes in `internal/subsonic` beyond a verification test.
## Schema (migration `0007_contextual_likes.up.sql`)
```sql
-- contextual_likes captures the per-session-context snapshot at the time
-- of each like. The recommendation engine in M3 sub-plan #3 uses these
-- to compute contextual_match_score per (current session, candidate track).
--
-- Multiple rows per (user, track) are allowed and expected — each row
-- represents a like in a specific session context. Soft-deleted rows
-- remain in the table but are filtered out of the engine's queries via
-- the partial index. Re-liking a previously-unliked track adds a NEW row
-- (does not undelete the old one).
CREATE TABLE contextual_likes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
liked_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
session_vector jsonb,
session_id uuid REFERENCES play_sessions(id) ON DELETE SET NULL
);
-- Partial index: the engine queries only active rows; this is the hot path.
CREATE INDEX contextual_likes_active_idx
ON contextual_likes (user_id, track_id)
WHERE deleted_at IS NULL;
-- GIN index for vector similarity queries (used by sub-plan #3's
-- contextual_match_score lookup). Even though M3 sub-plan #3 doesn't yet
-- exist, this slice adds the index because the recommendation engine
-- will need it; better to migrate once.
CREATE INDEX contextual_likes_vector_idx
ON contextual_likes USING gin (session_vector);
-- Lookup support for the soft-delete update (UPDATE ... WHERE user_id = $1
-- AND track_id = $2 AND deleted_at IS NULL). Already covered by the
-- partial index above — no separate index needed.
```
`down.sql`:
```sql
DROP TABLE IF EXISTS contextual_likes;
```
## Session vector shape
```go
type SessionVector struct {
Seed bool `json:"seed"`
Artists []string `json:"artists"` // distinct, ordered by first appearance
Tags map[string]int `json:"tags"` // bag-of-counts
RecentTrackIDs []string `json:"recent_track_ids"` // newest last
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector
```
Rules:
- `Seed = len(priorTracks) < 3`. The engine in #342 filters seed vectors out of the contextual-match query (they don't represent enough context to match against).
- `Artists` is the deduplicated list of `priorTracks[i].ArtistID` values, formatted as UUID strings. Order is "first appearance" (older tracks first).
- `Tags` is a `map[string]int` counting how many tracks have each genre. `priorTracks[i].Genre == nil || == ""` means no contribution. Tags are case-sensitive (we don't normalize — `"Rock"` and `"rock"` are different bins; future cleanup can add normalization).
- `RecentTrackIDs` is the ordered list, newest last (matches the spec's "ordered list of the N track ids").
- The function does NOT truncate. The caller passes in at most N tracks; the function works with whatever it's given (extra coverage flexibility).
## API contracts
No HTTP shape changes. `POST /api/events`, `POST/DELETE /api/likes/...`, and Subsonic `/rest/star`, `/rest/unstar` all keep their current request/response contracts. The new behavior is internal data-write side effects.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | Schema for `contextual_likes` + partial index + GIN index. |
| `internal/db/queries/contextual_likes.sql` | `InsertContextualLike`, `SoftDeleteContextualLikesForUserTrack`. |
| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. |
| `internal/recommendation/sessionvector.go` | `SessionVector` struct + `BuildSessionVector(priorTracks)` pure function. |
| `internal/recommendation/sessionvector_test.go` | Unit tests for the pure function (boundary cases for the seed flag, dedup, tag counts, ordering, JSON round-trip). |
### Modified server files
| Path | Change |
|---|---|
| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` (joins play_events ↔ tracks, orders by started_at DESC). Add `UpdatePlayEventVector(id, vector) :exec`. |
| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` so the handler detects insert vs already-exists. |
| `internal/playevents/writer.go::RecordPlayStarted` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → UpdatePlayEventVector. All inside the existing transaction. |
| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same vector computation as RecordPlayStarted (Subsonic synthetic plays should also carry context). Single source of truth via a private helper. |
| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated vector, session-scope isolation). |
| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, GetOpenPlayEventForUser; if open + non-null vector, InsertContextualLike. |
| `internal/api/likes.go::handleUnlikeTrack` | After UnlikeTrack, SoftDeleteContextualLikesForUserTrack. |
| `internal/api/likes_test.go` | Five new tests (capture during open play, no-op without play, no duplicate on idempotent like, soft-delete on unlike, history accumulation across like/unlike/like). |
| `internal/subsonic/star_test.go` | One new test confirming Subsonic star captures contextual_likes when a now-playing event is open. |
### No web changes
The web client already calls `/api/events` and `/api/likes/...`. After this slice, behavior is identical from the SPA's perspective; the side-effects are server-side only.
## Data flow
**Play started:**
1. SPA calls `POST /api/events` with `type: 'play_started', track_id`.
2. `handleEventPlayStarted``events.Writer.RecordPlayStarted(ctx, userID, trackID, clientID, at)`.
3. Inside the transaction:
- Auto-close any prior open row (existing).
- `playsessions.FindOrCreate(...)``sessionID` (existing).
- `q.InsertPlayEvent(...)``playEventID` (existing).
- **NEW:** `priorTracks := q.ListRecentSessionTracks(ctx, sessionID, at, 5)`. Returns up to 5 tracks where `started_at < at` (excluding the just-inserted row).
- **NEW:** `vec := recommendation.BuildSessionVector(priorTracks)`.
- **NEW:** `vecJSON, _ := json.Marshal(vec)`.
- **NEW:** `q.UpdatePlayEventVector(ctx, UpdatePlayEventVectorParams{ID: playEventID, SessionVectorAtPlay: vecJSON})`.
4. Returns `StartedResult{PlayEventID, SessionID}`.
**Subsonic synthetic completed play:** `RecordSyntheticCompletedPlay` follows the same vector-computation path so Subsonic-driven plays carry context too.
**Like a track:**
1. `POST /api/likes/tracks/:id` (or Subsonic `/rest/star?id=X`).
2. `handleLikeTrack` validates auth + UUID + entity exists.
3. `q.LikeTrack(...)` returns rows-affected count (sqlc `:execrows`).
4. **If rows == 0** (already liked): return 204. No contextual capture.
5. **If rows == 1** (freshly inserted):
- `event, err := q.GetOpenPlayEventForUser(ctx, userID)`.
- If `errors.Is(err, pgx.ErrNoRows)` → return 204 (no open event, no contextual capture).
- If `event.SessionVectorAtPlay` is NULL → return 204 (event opened before this slice landed; no vector).
- Else: `q.InsertContextualLike(ctx, InsertContextualLikeParams{UserID, TrackID, SessionVector: event.SessionVectorAtPlay, SessionID: &event.SessionID})`.
6. Return 204.
**Unlike a track:**
1. `DELETE /api/likes/tracks/:id` (or Subsonic `/rest/unstar?id=X`).
2. `handleUnlikeTrack` validates UUID.
3. `q.UnlikeTrack(...)` (existing).
4. **NEW:** `q.SoftDeleteContextualLikesForUserTrack(ctx, userID, trackID)``UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL`.
5. Return 204.
**Edge cases:**
- **Like with no open play_event.** No contextual capture; only `general_likes`. Same outcome as today.
- **Like during cold-start session.** Vector has `seed: true`. Row is still inserted (the seed flag is informational; the engine in #342 filters on it).
- **Re-like of an already-liked track in the same session.** `LikeTrack` returns 0 affected rows; contextual capture skipped. No spam.
- **Re-like after unlike, in the same session.** Unlike soft-deleted prior rows. New like inserts a fresh row. The (user, track) pair has both rows in the table; only the fresh one is `deleted_at IS NULL`.
- **Unlike of a never-liked track.** `UnlikeTrack` is a no-op. `SoftDeleteContextualLikesForUserTrack` runs but updates 0 rows. Both safe.
- **Album / artist likes.** `contextual_likes` is track-only. Album/artist like handlers are untouched in this slice.
- **Subsonic `star?albumId=&artistId=`.** Album/artist branches don't trigger contextual capture (track-only). The track-id branch picks it up via the shared `dbq.LikeTrack` call.
## Testing
### Server (`go test`)
**`internal/recommendation/sessionvector_test.go`** (pure unit tests):
- Empty input → seed=true, all collections empty.
- 1 track → seed=true, artists has 1 entry, tags has 1 entry (count=1), recent_track_ids has 1 entry.
- 2 tracks → seed=true.
- 3 tracks → seed=false. Single-artist case: artists deduplicated to 1 entry. Multi-genre case: tags accumulates counts.
- 5 tracks across 3 artists, mixed genres → all populated, dedup correct, counts correct.
- Track with empty/nil genre → tag entry not added.
- 10 tracks input → function processes all 10 (caller controls truncation; document the contract).
- JSON round-trip via `encoding/json`: marshal, unmarshal, fields equal.
**`internal/playevents/writer_test.go`** (live-DB integration, three new):
- `TestRecordPlayStarted_PersistsSessionVector_Seed`: first play in fresh session → row's `session_vector_at_play.seed == true`, empty arrays.
- `TestRecordPlayStarted_PersistsSessionVector_Populated`: after 4 plays, the 5th's vector has `seed: false` and the 4 prior tracks' artists/tags/ids.
- `TestRecordPlayStarted_VectorScopedToSession`: open a play_event, advance > 30 minutes, start another (new session) — second play's vector is `seed: true` with nothing from the prior session.
**`internal/api/likes_test.go`** (live-DB integration, five new):
- `TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike`: user starts playing track A (creates play_event with non-null vector); likes track B; assert `contextual_likes` has one row for (user, B) with the matching `session_vector` and `session_id`.
- `TestLikeTrack_NoOpenPlayEvent_NoContextualLike`: user likes a track without playing — `general_likes` has the row, `contextual_likes` is empty.
- `TestLikeTrack_RepeatedLike_NoDuplicate`: like the same track twice — first call writes both rows; second call no-ops (LikeTrack returns 0 rows; contextual capture skipped).
- `TestUnlikeTrack_SoftDeletesContextualLikes`: like → unlike. `general_likes` row gone, `contextual_likes` row's `deleted_at` is set, row count unchanged.
- `TestLikeUnlikeRelike_HistoryAccumulates`: like → unlike → like. Two `contextual_likes` rows for that (user, track): one with `deleted_at` set, one without.
**`internal/subsonic/star_test.go`** (live-DB, one new):
- `TestHandleStar_DuringNowPlaying_WritesContextualLike`: send `submission=false` for track A (creates open play_event with vector), then `star?id=B``contextual_likes` row exists for (user, B).
**Migration smoke**: existing `internal/db` test runs the new 0007 migration; verify no errors.
### End-to-end manual
Final task in the implementation plan:
1. Sign in. Play 4 tracks all the way through.
2. `psql ... SELECT id, session_vector_at_play FROM play_events ORDER BY started_at DESC LIMIT 5` — vectors are populated; first 3 have `seed: true`, 4th onward `seed: false`.
3. While the 4th track is playing, like it via the heart button. `SELECT * FROM contextual_likes` shows a row with that track's session_vector.
4. Like a different track NOT currently playing. `general_likes` row appears; no `contextual_likes` row.
5. Unlike the first track. `general_likes` deleted; `contextual_likes` row still exists but `deleted_at` is set.
6. Re-like that track in a new session. New `contextual_likes` row with `deleted_at IS NULL` and a different `session_vector` (different artists/tags from the new session).
7. From Feishin: send a now-playing for track A, then star track B. Confirm contextual capture worked through the Subsonic path.
## Risks & mitigations
- **Vector size bloat**: each play_event row carries up to 5 artist UUIDs + a tag map + 5 track UUIDs. ~500 bytes per play_event in JSON. For a user with 50,000 plays, that's 25 MB of JSONB. Acceptable; PostgreSQL handles it. If telemetry shows the column dominates row width, future work can extract to a separate table or hash older vectors.
- **Tag normalization**: `tracks.genre` is denormalized free-text. `"Rock"` and `"rock"` end up as different bins. v1 doesn't normalize. Mitigation: documented; M3.5/M4 cleanup can `lower()` consistently; the recommendation engine in #342 can do its similarity calculation on lowercased tags as a workaround.
- **Eager column read**: extending `RecordPlayStarted` adds 1 SELECT (recent tracks) + 1 UPDATE (vector) per play. Single-digit ms overhead at v1 scale. Reasonable cost for the data we get.
- **NULL vector after this slice ships**: existing play_events from before the migration have NULL `session_vector_at_play`. The like-handler's contextual-capture path skips when the vector is NULL — those old plays simply don't generate contextual likes. No backfill; new plays going forward populate the vector.
- **Subsonic synthetic plays carry vector**: synthetic plays from `submission=true` use the same vector logic. Subsonic clients that scrobble in bulk after-the-fact (e.g. submit 10 plays in 5 seconds) generate vectors quickly across each new session/track — vectors may show artificial 5-track sliding windows during the catch-up. Acceptable for v1; treats Subsonic clients as first-class citizens of the recommendation pipeline.
- **`contextual_likes` table forgotten in 0005**: this slice adds it. Existing migration history is unchanged; the new 0007 migration brings the schema up to spec.
- **Soft-delete query without explicit unique constraint**: `SoftDeleteContextualLikesForUserTrack` updates ALL active rows for (user, track), which may be more than one if a user liked the same track in multiple sessions. That's the correct behavior — unliking means "all my historical likes for this track no longer count." Documented.
@@ -1,403 +0,0 @@
# M4a — ListenBrainz outbound scrobble worker
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #345
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
## 1. Goal
Send the user's qualifying plays to ListenBrainz with batching and exponential
retry. Reads from the existing M2 `play_events` stream; backed by a new
`scrobble_queue` table that the worker drains every 30 seconds. Per-user token
configuration via a new minimal `/settings` page in the SPA.
When this slice ships, a play that meets ListenBrainz's "≥240s OR ≥50%
completion" threshold appears in the user's LB profile within ~30 seconds, and
the system survives LB outages, bad networks, and process restarts without
losing or duplicating scrobbles.
## 2. Non-goals (explicit)
- **`now-playing` endpoint** — real-time "user is currently listening" status.
Requires push architecture; defer until a UI surface needs it.
- **Listen import** — backfilling historical plays into LB. Useful for migration
but not part of the core flow.
- **MusicBrainz ID resolution at scan time** — payload uses MBIDs only when
already populated in `tracks.mbid`/`albums.mbid`/`artists.mbid`. Better
scanner-side MBID coverage is a future improvement.
- **Encrypted-at-rest token storage** — chose plaintext for v1 (industry norm
for self-hosted scrobble apps).
- **Scrobble status indicator in PlayerBar** — latency-sensitive UX; bundle
with the future now-playing push.
- **Multi-instance queue safety** — single worker assumes single Minstrel
process. ROW LOCK FOR UPDATE SKIP LOCKED is a future change if/when Minstrel
goes multi-instance.
- **CLI admin token rotation** — operators can `UPDATE users …` for
break-glass; CLI tooling lands with M6 packaging.
- **Proactive rate limiting** — we honor LB's `Retry-After` headers but don't
throttle ourselves. Human-scale play rate is well below LB's limits.
- **Multiple scrobble services** (Last.fm, Maloja, etc.) — LB-only by design.
The `internal/scrobble/listenbrainz/` package structure leaves room for
siblings later if demand emerges.
## 3. Architecture overview
```
┌──────────────────────────┐
play_event closed ──► │ scrobble.MaybeEnqueue │ (called from
(M2 RecordPlayEnded) │ - apply LB threshold │ Writer hooks)
│ - INSERT scrobble_queue │
└────────────┬─────────────┘
┌────────────────────────┐
│ scrobble_queue table │ status: pending | failed
│ (work list, not log) │
└────────────┬───────────┘
tick 30s ─────► ┌────────────────────────────────────┐
(goroutine) │ scrobble.Worker │
│ - SELECT pending, next_attempt<= now │
│ - batch POST to LB submit-listens │
│ - on 2xx: DELETE row + │
│ UPDATE play_events.scrobbled_at │
│ - on 5xx/network: increment │
│ attempts, schedule next │
│ - on 4xx: mark failed, log │
│ - on 429 Retry-After: respect │
│ header │
└────────────────────────────────────┘
ListenBrainz https://api.listenbrainz.org
```
### 3.1 New Go packages
- **`internal/scrobble/listenbrainz/`** — pure HTTP client. One method:
`SubmitListens(ctx, token, listens)`. No DB, no worker plumbing. Tested
against `httptest.Server`.
- **`internal/scrobble/threshold.go`** — `Qualifies(durationPlayedMs,
completionRatio) bool`. Pure function.
- **`internal/scrobble/queue.go`** — `MaybeEnqueue(ctx, q, playEventID)`.
Reads user config + threshold, inserts row.
- **`internal/scrobble/worker.go`** — the goroutine. Started from
`cmd/minstrel/main.go` alongside `playevents.Writer`.
### 3.2 Hooked into existing code
- **`playevents.Writer.RecordPlayEnded`** — after the close transaction
commits, call `scrobble.MaybeEnqueue(ctx, q, playEvent.ID)` as a
best-effort post-commit step. Errors are logged and swallowed (matches
the M3 `CaptureContextualLikeIfPlaying` pattern: scrobble delivery is
enrichment, not a precondition for the canonical play history).
- **`playevents.Writer.RecordSyntheticCompletedPlay`** — same hook (Subsonic
`submission=true` plays from `/rest/scrobble`).
- **`internal/api/me.go`** — two new handlers: `handleGetListenBrainz` and
`handlePutListenBrainz`. Wired in `Mount(...)` under `/api/me/listenbrainz`.
- **`cmd/minstrel/main.go`** — start the worker:
```go
worker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger)
go worker.Run(ctx)
```
## 4. Database schema
New migration `0008_scrobble.up.sql`:
```sql
ALTER TABLE users
ADD COLUMN listenbrainz_token TEXT NULL,
ADD COLUMN listenbrainz_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE scrobble_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
play_event_id uuid NOT NULL REFERENCES play_events(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error TEXT NULL,
enqueued_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (play_event_id)
);
CREATE INDEX scrobble_queue_pending_idx
ON scrobble_queue (next_attempt_at)
WHERE status = 'pending';
```
Down migration drops the table and the user columns.
**Reused (no schema changes):**
- `play_events.scrobbled_at` — already exists from M2 migration 0005,
currently always NULL. Worker stamps it on successful submit.
- `play_events.duration_played_ms`, `completion_ratio` — already populated
by M2; used for the LB eligibility threshold.
**Lifecycle:**
- Successful submit → `DELETE FROM scrobble_queue WHERE id = $1` AND
`UPDATE play_events SET scrobbled_at = now() WHERE id = $1`. Canonical
record stays in `play_events`; queue is purely a work list.
- `UNIQUE(play_event_id)` makes `MaybeEnqueue` idempotent: re-running the
close path on a play_event with an existing queue row is a no-op via
`ON CONFLICT DO NOTHING`.
- No `'sent'` status enum value — the simpler binary `pending`/`failed`
state machine is sufficient because successful rows are deleted.
## 5. Backend components
### 5.1 ListenBrainz client (`internal/scrobble/listenbrainz/client.go`)
```go
type Listen struct {
ListenedAt int64
Track Track
}
type Track struct {
ArtistName string
TrackName string
ReleaseName string
DurationMs int
RecordingMBID string
ArtistMBIDs []string
ReleaseMBID string
}
type Client struct {
BaseURL string // default https://api.listenbrainz.org
HTTP *http.Client
}
// Errors are typed so the worker can branch on response semantics.
var (
ErrAuth = errors.New("listenbrainz: auth rejected") // 401
ErrPermanent = errors.New("listenbrainz: permanent error") // other 4xx
ErrTransient = errors.New("listenbrainz: transient error") // 5xx, network
)
type RetryAfterError struct{ Wait time.Duration }
func (e *RetryAfterError) Error() string { ... }
func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error
```
Sets `Authorization: Token <token>`. POSTs to `/1/submit-listens` with
`payload_type=import` for batches >1, `single` for batches of 1 (LB convention).
On 429, parses the `Retry-After` header and returns `*RetryAfterError`.
### 5.2 Threshold (`internal/scrobble/threshold.go`)
```go
// Qualifies returns true if a closed play_event meets ListenBrainz's
// recommended scrobble threshold.
func Qualifies(durationPlayedMs int, completionRatio float64) bool {
return durationPlayedMs >= 240_000 || completionRatio >= 0.5
}
```
### 5.3 Enqueue (`internal/scrobble/queue.go`)
```go
// MaybeEnqueue inserts a scrobble_queue row for the given play_event if:
// 1. The user has listenbrainz_enabled = true with a non-empty token.
// 2. The play_event passes Qualifies().
// Idempotent via UNIQUE(play_event_id) — re-runs are no-ops.
// Returns nil even when the row is skipped (no-op is not an error).
// Best-effort: callers (the playevents.Writer hooks) should log returned
// errors but not propagate them, since scrobble delivery is enrichment
// and must not block the canonical play history.
func MaybeEnqueue(ctx context.Context, q *dbq.Queries, playEventID pgtype.UUID) error
```
### 5.4 Worker (`internal/scrobble/worker.go`)
```go
type Worker struct {
pool *pgxpool.Pool
client *listenbrainz.Client
logger *slog.Logger
tick time.Duration // 30s in production, injectable for tests
now func() time.Time // injectable for tests
batch int // up to 50 rows per tick
}
func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker
// Run blocks until ctx is cancelled.
func (w *Worker) Run(ctx context.Context)
// tickOnce drains up to `batch` pending rows. Exposed for tests.
func (w *Worker) tickOnce(ctx context.Context) error
```
Backoff schedule (constants in `worker.go`):
```go
var backoffSchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
6 * time.Hour,
}
const maxAttempts = 5
// backoffDelay maps a failure count (the value of the `attempts` column
// AFTER the failure has been recorded) to the delay before the next
// attempt. attempts=1 (first failure just happened) → backoffSchedule[0]
// = 1m. attempts=5 → backoffSchedule[4] = 6h. attempts >= 6 → give up.
// Returns (_, false) when attempts > maxAttempts.
func backoffDelay(attempts int) (time.Duration, bool)
```
Per-row outcome handling:
- 2xx → `DELETE FROM scrobble_queue WHERE id = $1` + `UPDATE play_events SET
scrobbled_at = now() WHERE id = $play_event_id`.
- `*RetryAfterError` (429) → `UPDATE … SET next_attempt_at = now() + Wait`
WITHOUT incrementing `attempts` (server told us to wait, not that we failed).
- `ErrTransient` (5xx, network) → increment `attempts`, look up
`backoffDelay(attempts)`. If `false`, mark `failed`. Otherwise schedule next.
- `ErrPermanent` (4xx) → mark `failed` immediately, no retry.
- `ErrAuth` (401) → mark `failed` immediately AND set
`users.listenbrainz_enabled = FALSE` (defensive: bad token shouldn't keep
retrying or feed future rows).
### 5.5 API endpoints (`internal/api/me.go`)
```
GET /api/me/listenbrainz
→ 200 { enabled: boolean, token_set: boolean, last_scrobbled_at: string|null }
PUT /api/me/listenbrainz
body: { token?: string, enabled?: boolean }
- token: any string sets the token. Empty string clears it AND forces enabled=false.
- enabled: requires a non-empty stored token. 400 if attempting enabled=true with no token.
→ 200 { enabled, token_set, last_scrobbled_at }
```
The token is **write-only** — `GET` never returns the actual value, only
`token_set: bool`. `last_scrobbled_at` is computed via `MAX(scrobbled_at)
FROM play_events WHERE user_id = $1`.
## 6. Frontend (minimal `/settings`)
New page `web/src/routes/settings/+page.svelte` with a single "ListenBrainz"
section. Form:
- **Token field**: password input with Save button when unset; masked
"••••••••• (set)" + Clear button when set. Save calls `PUT /api/me/listenbrainz
{ token }`. Clear calls `PUT { token: "" }`.
- **Enabled checkbox**: "Send my plays to ListenBrainz". Disabled when no
token. Toggle calls `PUT { enabled: !current }`.
- **Last scrobbled at**: localized timestamp of `last_scrobbled_at` from the
GET response.
- **Disclaimer**: "Tokens are stored unencrypted in this server's database —
treat as sensitive."
Shell nav (`web/src/lib/components/Shell.svelte`) gains `{ href: '/settings',
label: 'Settings' }` after `/playlists`.
Helpers in `web/src/lib/api/client.ts` — confirm `apiPut` exists; add it if
not (mirrors the existing `apiGet` shape).
## 7. Test plan
### 7.1 Pure unit tests
- **`threshold_test.go`**: boundary cases (exactly 240s, exactly 50%, just
under both, both true).
- **`worker_test.go`**: `backoffDelay(1) == 1m`, `backoffDelay(2) == 5m`,
`backoffDelay(3) == 30m`, `backoffDelay(4) == 2h`, `backoffDelay(5) == 6h`,
`backoffDelay(6)` returns `_, false`.
- **`listenbrainz/client_test.go`** (uses `httptest.Server`):
- 2xx → nil
- 401 → `ErrAuth`
- 400, 403 → `ErrPermanent`
- 500, 503 → `ErrTransient`
- 429 with `Retry-After: 60` → `*RetryAfterError{Wait: 60s}`
- Network failure mid-request → `ErrTransient`
- Body shape (JSON) and `Authorization: Token <token>` header asserted
### 7.2 Integration (live test DB)
- **`queue_test.go`**:
- Idempotent: `MaybeEnqueue` twice for same play_event = one row
- User with `listenbrainz_enabled=false` → no row
- User with empty token → no row
- Play_event below threshold → no row
- Play_event passing threshold → row inserted (status=pending, attempts=0,
next_attempt_at ≈ now())
- **`worker_integration_test.go`** (mocked LB via httptest):
- 1 pending row + 200 → row deleted, `play_events.scrobbled_at` populated
- 503 once → row remains, `attempts=1`, `next_attempt_at ≈ now() + 1m`
- 503 five times → row marked `failed`, `last_error` populated
- 401 → row marked `failed` immediately, `users.listenbrainz_enabled` set to
`FALSE`
- 429 with `Retry-After: 300` → row remains, `next_attempt_at ≈ now() + 5m`,
`attempts` NOT incremented
- Batch of 10 pending → single LB POST, 10 listens in payload
- **`internal/api/me_test.go`** extensions:
- GET when no token → `{enabled:false, token_set:false, last_scrobbled_at:null}`
- PUT `{token: "abc"}` → DB updated, GET shows `token_set:true`
- PUT `{token: ""}` → token cleared, `enabled` forced to false
- PUT `{enabled: true}` while no token → 400
- `last_scrobbled_at` populated from `MAX(play_events.scrobbled_at)`
### 7.3 Frontend (`web/src/routes/settings/settings.test.ts`)
- Token-not-set state: input + Save button render
- Token-set state: masked + Clear button render
- Save mutation: PUT body has the typed token
- Enabled checkbox: disabled when no token
- Last-scrobbled-at: localized timestamp renders when present
- Mocked `apiGet`/`apiPut`; no real network
### 7.4 Coverage target
`internal/scrobble/...` ≥ 80%. M3 combined coverage stays well above the 70%
floor with these additions.
### 7.5 Manual verification post-merge
1. Generate a token at https://listenbrainz.org/profile/
2. /settings → paste token → Save → toggle enabled
3. Play a track for ≥240s OR finish it
4. Within 30s, check listenbrainz.org/<your-username> → listen appears
5. /settings → "Last scrobbled" timestamp populates
## 8. Backwards compatibility
- New migration; no changes to existing schema beyond two nullable columns
on `users` and the new `scrobble_queue` table.
- `/api/me/listenbrainz` is new; no existing endpoints change.
- `MaybeEnqueue` is a new hook in `playevents.Writer`; if all users have
`listenbrainz_enabled=false` (the default after migration), the hook is a
no-op and behavior is unchanged.
- The new `/settings` page is additive; the rest of the SPA is unchanged.
- `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay`
signatures are unchanged. Only their bodies gain the enqueue call.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Per-user token (vs global) | Forward-compat to multi-user; cost is 2 nullable columns. |
| 2 | Plaintext token storage (vs encrypted) | Industry norm for self-hosted scrobble apps; key-management is a separate scope. |
| 3 | Threshold ≥240s OR ≥50% (separate from skip threshold) | Matches LB recommendation; skip threshold serves a different purpose. |
| 4 | Pull-based 30s timer (vs push) | Survives restarts/outages; failure handling natural; latency invisible at single-user scale. |
| 5 | Minimal `/settings` page in M4a (vs API-only) | User needs somewhere to put the token; scaffold for M6 to extend. |
| 6 | Patient backoff: 1m → 5m → 30m → 2h → 6h, 5 attempts (~9h window) | Survives overnight LB outages without unbounded queue growth. |
| 7 | Delete sent rows; keep failed rows | Canonical record in `play_events.scrobbled_at`; queue is a work list. |
## 10. Sub-plan progression (M4)
- **M4a (this) — outbound scrobble worker**.
- M4b — inbound similarity ingest (`track_similarity` table + LB similarity
fetcher; weekly cache).
- M4c — radio similarity-driven candidate pool + queue-refresh-at-80%
(closes M4).
@@ -1,338 +0,0 @@
# M4b — ListenBrainz inbound similarity ingest
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #346
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
**Builds on:** M4a (outbound scrobble worker — shipped as PR #26)
## 1. Goal
A periodic background worker that pulls track-track and artist-artist similarity
edges from ListenBrainz's `/explore/similar-recordings/{mbid}` and
`/explore/similar-artists/{mbid}` endpoints and stores them in two new tables
(`track_similarity`, `artist_similarity`). Refreshes each row at most once per
7 days; bounded scope to "tracks the user has played" so cost stays
proportional to actual usage.
When this slice ships, M4c can build candidate pools for radio from a
similarity graph rather than the user's whole library.
## 2. Non-goals (explicit)
- **Lazy fetch on radio request** — M4c. If a user clicks a never-played track
as seed and `track_similarity` is empty for it, M4c can synchronously call
`SimilarRecordings` then.
- **Score normalization to [0, 1]** — store raw LB scores
(`DOUBLE PRECISION`); M4c normalizes at query time if its scoring formula
needs it.
- **Symmetric edges** (storing both `(A, B)` and `(B, A)`) — store one-way as
LB returns. M4c queries `WHERE track_a_id = $seed`.
- **`musicbrainz_tag` and `user_cooccurrence` source values** — schema reserves
them via the `source` enum, but M4b only writes `'listenbrainz'`.
- **Suggested-additions / Lidarr integration** (LB-returned MBIDs not in the
library) — M5. Spec line 225 covers it.
- **Configurable LB algorithm parameter** — hardcoded for v1.
- **Per-user similarity overrides** — `track_similarity` has no `user_id`;
data is global per-instance.
- **Force-refresh HTTP endpoint** — operators can `UPDATE … SET fetched_at =
'1970-01-01' WHERE …` for break-glass.
- **Multi-instance worker safety** — single-process worker assumed (matches
M4a).
- **Frontend surface** — M4b is invisible until M4c uses the data.
## 3. Architecture overview
```
┌────────────────────────────────────┐
│ similarity.Worker │ hourly tick
│ - SELECT distinct played tracks │
│ with mbid where (no row OR │
│ fetched_at < now() - 7d) │
│ - LIMIT 510 per tick │
└────────────┬───────────────────────┘
┌──────────────────────────────────────┐
│ listenbrainz.Client │
│ (extended from M4a) │
│ GET /1/explore/similar-recordings/ │
│ {mbid} │
│ GET /1/explore/similar-artists/ │
│ {artist_mbid} │
│ No auth — public endpoints │
└────────────┬─────────────────────────┘
For each LB response:
- Filter to MBIDs we have in our local library
- Take top 20 (sorted by LB score)
- UPSERT into track_similarity / artist_similarity
```
### 3.1 New Go package
**`internal/similarity/`** — the worker:
- `Worker` struct (pool, client, logger, tick, batch, topK)
- `NewWorker(pool, client, logger) *Worker` — production defaults: 1h tick,
batch=5, topK=20
- `(w *Worker) Run(ctx)` — blocks until ctx cancelled
- `(w *Worker) tickOnce(ctx) error` — drains one batch of tracks AND one
batch of artists; injectable for tests
### 3.2 Existing-code extensions
- **`internal/scrobble/listenbrainz/client.go`** — gains two methods on the
existing `Client` (which already houses `SubmitListens` from M4a):
- `SimilarRecordings(ctx, mbid, limit) ([]SimilarRecording, error)`
- `SimilarArtists(ctx, mbid, limit) ([]SimilarArtist, error)`
- Both return the same typed errors as `SubmitListens` (`ErrTransient`,
`ErrPermanent`, `*RetryAfterError`). 401 is defensive only — these are
public endpoints.
- The package stays at its current path. A future cleanup could move it
to `internal/listenbrainz/`, but that's a non-blocking refactor.
- **`cmd/minstrel/main.go`** — start the worker alongside the M4a scrobble
worker:
```go
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
```
### 3.3 Failure handling
**Passive retry via timer.** Unlike M4a's durable scrobble queue:
- A failed `SimilarRecordings` call does NOT update `fetched_at`. The next
hourly tick selects the row again (it still satisfies the "needs fetch"
predicate) and retries.
- 429 with `Retry-After`: the worker logs the value and **aborts the current
tick** without updating `fetched_at` on any in-flight rows. The next
hourly tick (typically far longer than any LB-suggested back-off) picks
the work back up. Avoids mid-tick sleeps that would block the goroutine.
- ErrPermanent (4xx): logged as a warning + skipped. Permanent errors on
similarity reads typically mean the MBID isn't in LB's graph — there's no
remediation, but `fetched_at` stays old so we'll just retry forever (cheap
no-op since LB returns 4xx fast). Acceptable; could mark "permanently
empty" in a future iteration if telemetry shows it matters.
- ErrTransient (5xx, network): logged + skipped, retry next tick.
No `scrobble_queue`-equivalent table needed; the work list IS the played-tracks
set + the `fetched_at` watermark.
## 4. Database schema
New migration `0009_similarity.up.sql`:
```sql
CREATE TABLE track_similarity (
track_a_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
track_b_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (track_a_id, track_b_id, source),
CHECK (track_a_id <> track_b_id)
);
CREATE INDEX track_similarity_a_score_idx
ON track_similarity (track_a_id, score DESC);
CREATE TABLE artist_similarity (
artist_a_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
artist_b_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (artist_a_id, artist_b_id, source),
CHECK (artist_a_id <> artist_b_id)
);
CREATE INDEX artist_similarity_a_score_idx
ON artist_similarity (artist_a_id, score DESC);
```
Down migration drops both tables.
**Notes:**
- Primary key includes `source` so the schema can hold multiple parallel
similarity sources (per spec line 119).
- `(track_a_id, score DESC)` index matches the M4c hot-path query: "for seed
T, give me top-N similar tracks descending by score."
- `CHECK (a <> b)` prevents self-edges.
- `ON DELETE CASCADE` from both endpoints so deleting a track cleans up edges
on either side.
## 5. New sqlc queries
`internal/db/queries/similarity.sql`:
```sql
-- name: ListPlayedTracksNeedingSimilarity :many
SELECT DISTINCT t.id, t.mbid
FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE t.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM track_similarity ts
WHERE ts.track_a_id = t.id
AND ts.source = 'listenbrainz'
AND ts.fetched_at > now() - interval '7 days'
)
ORDER BY t.id
LIMIT $1;
-- name: ListPlayedArtistsNeedingSimilarity :many
SELECT DISTINCT ar.id, ar.mbid
FROM artists ar
JOIN tracks t ON t.artist_id = ar.id
JOIN play_events pe ON pe.track_id = t.id
WHERE ar.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM artist_similarity asim
WHERE asim.artist_a_id = ar.id
AND asim.source = 'listenbrainz'
AND asim.fetched_at > now() - interval '7 days'
)
ORDER BY ar.id
LIMIT $1;
-- name: GetTracksByMBIDs :many
SELECT id, mbid FROM tracks WHERE mbid = ANY($1::text[]);
-- name: GetArtistsByMBIDs :many
SELECT id, mbid FROM artists WHERE mbid = ANY($1::text[]);
-- name: UpsertTrackSimilarity :exec
INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (track_a_id, track_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
```
## 6. Worker algorithm
`tickOnce(ctx)`:
1. **Track pass:**
- `q.ListPlayedTracksNeedingSimilarity(batch=5)` → `[(track_id, mbid)…]`
- For each `(track_id, mbid)`:
- Call `c.SimilarRecordings(ctx, mbid, 100)`
- On 429 → log the `Retry-After` and **return from `tickOnce` early**
(don't update `fetched_at`; the next hourly tick will pick up the
work, which is virtually always longer than LB's `Retry-After`)
- On other error → log warn, skip (no `fetched_at` update; next tick
retries)
- On success: collect returned MBIDs → `q.GetTracksByMBIDs(returnedMBIDs)`
→ take top 20 by score → for each `(local_id, score)` call
`q.UpsertTrackSimilarity(track_id, local_id, score)`
2. **Artist pass:** symmetric, using `ListPlayedArtistsNeedingSimilarity`,
`SimilarArtists`, `GetArtistsByMBIDs`, `UpsertArtistSimilarity`.
**Constants:**
- Tick interval: 1 hour (production); injectable to ms-scale for tests.
- Batch size: 5 (production). 5 LB calls per pass × 2 passes = 10 LB calls/tick
→ ~240/day, well under LB's documented rate limits (~100/5min unauth).
- Top-K: 20 per LB query.
- LB algorithm: hardcoded constant in the client (use LB's documented default
at implementation time).
## 7. Test plan
### 7.1 LB client unit tests (httptest)
In `internal/scrobble/listenbrainz/client_test.go`, add 7 tests for each new
method:
For `SimilarRecordings`:
- 200 + valid body → returns slice ordered by score
- 401 → `ErrAuth` (defensive — public endpoint shouldn't 401)
- 400 → `ErrPermanent`
- 503 → `ErrTransient`
- 429 with `Retry-After` → `*RetryAfterError`
- URL contains `algorithm=…`
- URL contains `limit=N`
Same 7 tests for `SimilarArtists`.
### 7.2 Worker integration tests (live DB + httptest)
In `internal/similarity/worker_integration_test.go`:
- `TickOnce_NoPlayedTracks_NoOp`: empty `play_events` → returns nil, no rows
in `track_similarity`
- `TickOnce_MapsLBResponseToLocalLibrary`: seed one played track with MBID;
LB returns 3 MBIDs (2 in library, 1 not); assert 2 rows inserted
- `TickOnce_TopKEnforced`: LB returns 50; assert
`count(*) WHERE track_a_id = $1` ≤ 20
- `TickOnce_RespectsSevenDayCap`: row with `fetched_at = now()` → not
re-queried (the LB endpoint isn't called)
- `TickOnce_RefreshesStaleRow`: row with `fetched_at = now() - interval '8
days'` → re-fetched, score updated, fetched_at bumps
- `TickOnce_429AbortsTick`: first response 429 with Retry-After → tickOnce
returns early; no `fetched_at` updates; subsequent tracks in the batch
are NOT processed (they're picked up on the next tick)
- `TickOnce_TransientErrorSkipsTrack`: 503 on one track → `fetched_at`
unchanged; other tracks in same batch process normally
- `TickOnce_FiltersInLibrary`: LB returns 5 MBIDs none of which are in the
library → 0 rows inserted (no error)
- `TickOnce_ArtistPassMirrors`: same coverage for the artist branch
- `TickOnce_NoMBIDOnTrack_Skipped`: track with `mbid IS NULL` → not selected
by `ListPlayedTracksNeedingSimilarity`
### 7.3 Coverage target
`internal/similarity` ≥ 75%. The worker has fewer error branches than M4a
because passive retry-via-timer eliminates the durable-queue state machine.
The new `Similar*` methods in `internal/scrobble/listenbrainz` add ~14 tests
to that package; should keep its coverage ≥ 85%.
### 7.4 Manual verification post-merge
1. Restart server with M4b code.
2. After ≥1 hour, `psql -c "SELECT count(*) FROM track_similarity WHERE
source = 'listenbrainz'"` → non-zero (assuming user has any MBID-tagged
played tracks).
3. After ≥7 days, observe a `fetched_at` timestamp that's recent —
confirms re-fetch cadence.
4. If MBID coverage in the library is sparse, the table will be small. Not a
bug — M5 (Lidarr suggested-additions) is the eventual answer for
tracks-not-in-library; tagging coverage via Picard is a separate user-side
improvement.
## 8. Backwards compatibility
- New migration; no changes to existing schema.
- New package; no changes to consumer code (M4c will consume; M3's `Score()`
doesn't need the data until then).
- `MaybeEnqueue` and other M4a paths unchanged.
- Worker is new; production behavior identical to pre-M4b until the worker's
first tick fires (1 hour after restart).
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Both `track_similarity` and `artist_similarity` in M4b | Symmetric pattern, M4c needs both, single-PR cost is small |
| 2 | Played-tracks-only input scope | Bounds work to user's actual interaction graph; LB's response naturally surfaces in-library tracks the user hasn't played, preserving discovery |
| 3 | Hourly tick, batch=5 | 240 LB calls/day fits well under LB rate limits; initial backfill in 24-48h |
| 4 | Top-K=20 per LB query, in-library only | Cuts long-tail noise; out-of-library tracks deferred to M5/Lidarr |
| 5 | Public endpoint, no auth | Similarity data is global to the instance; LB requires no token for `/explore/*` |
| 6 | Passive retry via timer (no durable queue) | Failure cost is "1 hour of staleness"; durable queue would be over-engineering vs. M4a's "lost scrobble" stakes |
| 7 | Hardcoded `algorithm` parameter | YAGNI; expose as YAML if telemetry warrants |
## 10. Sub-plan progression (M4)
- M4a (done) — outbound scrobble worker (PR #26).
- **M4b (this) — inbound LB similarity ingest.**
- M4c — radio similarity-driven candidate pool + queue refresh at 80%
(closes M4). M4c also picks up the discovery-mitigation work flagged in
brainstorm: serendipity floor (% random library picks), fallback to wider
pool when similarity-row count is sparse, lazy fetch on radio for
never-played seeds.
@@ -1,444 +0,0 @@
# M4c — Radio similarity-driven candidate pool + queue refresh at 80% (closes M4)
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #347
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
**Builds on:** M4a (PR #26 — outbound scrobble), M4b (PR #27 — inbound similarity ingest)
## 1. Goal
Replace M3's "whole library minus seed minus recently-played" candidate pool
with a similarity-driven pool drawn from four sources (LB-similar tracks,
tracks by similar artists, MB-tag overlap, user's general likes overlapping
seed tags) plus a random-fill source that guarantees a minimum pool size.
Add a sixth scoring term (`SimilarityScore × SimilarityWeight`) so within-pool
ranking reflects per-track similarity strength. Web client auto-refreshes
the radio queue when 80% consumed.
When this slice merges, the M4 milestone closes: the engine has all three v1
components (scoring, session vectors, LB-derived similarity) wired through
both backend and frontend.
## 2. Non-goals (explicit)
- **Lazy LB fetch on radio click** — radio handler stays synchronous and
uses only data already in `track_similarity` / `artist_similarity`.
Sparse-data UX is handled by random-fill augmentation.
- **YAML-configurable per-source Ks** — hardcoded constants for v1.
- **Symmetric edge storage** — still one-way as M4b stored.
- **Per-source score weights as YAML** — tunable in code only.
- **Token-based queue-refresh continuation** — explicit `?exclude=...`
query param.
- **Pre-fetch similarity on track play** — M4b's hourly tick is the only
fetch trigger.
- **Suggested additions / Lidarr** — out-of-library LB matches discarded;
M5 territory.
- **Multi-seed / persistent radio stations** — every call is single-seed
and stateless.
- **Cross-user collaborative filtering source** — `user_cooccurrence`
schema slot reserved, not populated.
- **`SimilarityWeight` per-user override** — operator-only YAML for v1.
## 3. Architecture overview
```
GET /api/radio?seed_track=<uuid>&limit=N&exclude=t1,t2,...
handleRadio
1. Auth + parse params (existing M3)
2. Get seed track + album + artist (existing)
3. q.GetCurrentSessionVectorForUser() (existing — M3)
4. recommendation.LoadCandidatesFromSimilarity(...) ← NEW
- 5-way SQL UNION: LB-similar / similar-artist tracks /
tag-overlap / likes-overlap / random-fill
- Excludes seed + ?exclude= list + recently-played
- Returns []Candidate with per-row SimilarityScore
- On error → fallback to M3's LoadCandidates (logged)
5. recommendation.Shuffle(candidates, weights, ...) ← extended
- M3's Score() gains SimilarityScore × SimilarityWeight term
- Otherwise unchanged
6. Resolve album/artist for picks (existing)
7. Return RadioResponse{Tracks: [seed, pick1, ...]}
Web client
8. Player store watches currentIndex / queue.length ← NEW
- At ≥ 80% consumed, AND radioSeedId is set, AND no refresh
in-flight: GET /api/radio?seed_track=…&exclude=<queue>
- Append response.tracks (skipping index 0 = seed already in queue)
- radioSeedId cleared when user manually enqueues from elsewhere
```
## 4. Candidate pool SQL (`LoadRadioCandidatesV2`)
5-way UNION; each branch produces `(track_id, similarity_score)`. After UNION,
the outer SELECT joins `tracks` + general_likes (for `is_liked`) + LATERAL
play_events aggregation (for `last_played_at` / `play_count` / `skip_count`),
GROUP BY track id, taking `max(similarity_score)` across sources.
```sql
-- name: LoadRadioCandidatesV2 :many
WITH
seed_artist AS (SELECT artist_id FROM tracks WHERE id = $2),
seed_tags AS (
SELECT trim(g) AS tag
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g
WHERE t.id = $2 AND trim(g) <> ''
),
exclude_set AS (
SELECT unnest($5::uuid[]) AS id
UNION ALL
SELECT track_id FROM play_events
WHERE user_id = $1 AND started_at > now() - $4 * interval '1 hour'
),
lb_similar AS (
SELECT ts.track_b_id AS id, ts.score AS sim_score
FROM track_similarity ts
WHERE ts.track_a_id = $2
AND ts.source = 'listenbrainz'
AND ts.track_b_id NOT IN (SELECT id FROM exclude_set)
ORDER BY ts.score DESC
LIMIT $6
),
similar_artists AS (
SELECT t.id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'listenbrainz'
AND t.id NOT IN (SELECT id FROM exclude_set)
ORDER BY asim.score DESC, random()
LIMIT $7
),
tag_overlap AS (
SELECT t.id,
(count(DISTINCT trim(g_raw))::float8
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_raw
WHERE trim(g_raw) IN (SELECT tag FROM seed_tags)
AND t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
GROUP BY t.id
HAVING count(DISTINCT trim(g_raw)) > 0
ORDER BY sim_score DESC
LIMIT $8
),
likes_overlap AS (
SELECT gl.track_id AS id, 0.6::float8 AS sim_score
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
AND t.id NOT IN (SELECT id FROM exclude_set)
AND EXISTS (
SELECT 1 FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') g
WHERE trim(g) IN (SELECT tag FROM seed_tags)
)
ORDER BY random()
LIMIT $9
),
random_fill AS (
SELECT t.id, 0.0::float8 AS sim_score
FROM tracks t
WHERE t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
AND t.id NOT IN (
SELECT id FROM lb_similar
UNION SELECT id FROM similar_artists
UNION SELECT id FROM tag_overlap
UNION SELECT id FROM likes_overlap
)
ORDER BY random()
LIMIT $10
)
SELECT
sqlc.embed(t),
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
max(u.sim_score) AS similarity_score
FROM (
SELECT * FROM lb_similar
UNION ALL SELECT * FROM similar_artists
UNION ALL SELECT * FROM tag_overlap
UNION ALL SELECT * FROM likes_overlap
UNION ALL SELECT * FROM random_fill
) u
JOIN tracks t ON t.id = u.id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
count(*) AS play_count,
count(*) FILTER (WHERE was_skipped) AS skip_count
FROM play_events
WHERE user_id = $1 AND track_id = t.id
) pe ON true
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.created_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
```
### 4.1 Per-source K and score (defaults, hardcoded for v1)
| Source | K | sim_score per row |
|---|---|---|
| `lb_similar` (track_similarity) | 30 | LB raw score (01) |
| `similar_artists` | 30 | `artist_similarity.score × 0.5` |
| `tag_overlap` | 20 | jaccard: `shared_tags / seed_tag_count` |
| `likes_overlap` | 20 | constant `0.6` |
| `random_fill` | 30 | `0.0` |
Total ideal: 130 candidates pre-dedup; 60100 after dedup. Random fill is
drawn AFTER the 4 similarity sources are exhausted (`NOT IN (lb_similar
UNION similar_artists UNION tag_overlap UNION likes_overlap)`), so it
strictly augments rather than overlaps. On very small libraries (<130
tracks total), the pool is naturally smaller — there is no hard floor;
the design assumes typical libraries have hundreds of tracks. M3's `Score()`
+ `Shuffle()` happily ranks small pools.
`max(sim_score)` on dedup so a track in multiple sources keeps its strongest
signal (LB's 0.85 beats tag's 0.4).
## 5. Score() formula extension
`internal/recommendation/score.go`:
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time
PlayCount int
SkipCount int
ContextualMatchScore float64 // M3
SimilarityScore float64 // NEW — max across the 4 similarity sources, in [0,1]
}
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64 // M3
SimilarityWeight float64 // NEW — default 2.0
}
```
Updated formula:
```
score = base
+ (is_general_liked ? LikeBoost : 0)
+ recency_decay * RecencyWeight
- skip_ratio * SkipPenalty
+ contextual_match_score * ContextWeight
+ similarity_score * SimilarityWeight ← NEW
+ jitter
```
`config.RecommendationConfig` gains `SimilarityWeight float64` (yaml
`similarity_weight`, default `2.0`). Same magnitude as `LikeBoost` and
`ContextWeight` — at perfect similarity (1.0), an LB-similar track gets a
+2.0 boost equivalent to an explicit general like.
**Backwards compatible:** zero-value `ScoringInputs{}` and `ScoringWeights{}`
produce M3 behavior because both new fields are zero-defaulted.
## 6. Go-side wiring
### 6.1 `LoadCandidatesFromSimilarity`
`internal/recommendation/candidates.go` gains a sibling to `LoadCandidates`:
```go
type CandidateSourceLimits struct {
LBSimilar int // 30
SimilarArtist int // 30
TagOverlap int // 20
LikesOverlap int // 20
RandomFill int // 30 — drawn from tracks NOT already returned by the 4 similarity sources
}
func DefaultCandidateSourceLimits() CandidateSourceLimits
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// Returns []Candidate (same type as M3 LoadCandidates) so Shuffle() is
// unchanged. Caller falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
exclude []pgtype.UUID,
limits CandidateSourceLimits,
) ([]Candidate, error)
```
Body:
1. `q.LoadRadioCandidatesV2(...)` with the 10 params from §4
2. Existing `loadContextualLikesByTrack(...)` for the contextual scoring inputs
3. Project rows → `[]Candidate` with `Inputs.SimilarityScore = row.SimilarityScore`
`LoadCandidates` (M3 fallback) **stays in place**, still consumed by callers
that want whole-library scoring (unit tests, the radio handler's error path).
### 6.2 Radio handler change
`internal/api/radio.go`:
```go
exclude := parseExcludeParam(r.URL.Query().Get("exclude")) // []pgtype.UUID
exclude = append(exclude, seedID) // always exclude the seed
limits := recommendation.DefaultCandidateSourceLimits()
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec,
exclude, limits,
)
if err != nil {
h.logger.Warn("api: radio: similarity-pool failed, falling back",
"err", err)
candidates, err = recommendation.LoadCandidates(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec,
)
if err != nil {
h.logger.Error("api: radio: load candidates", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error",
"candidate load failed")
return
}
}
weights := recommendation.ScoringWeights{
BaseWeight: h.recCfg.BaseWeight,
LikeBoost: h.recCfg.LikeBoost,
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight, // NEW
}
```
`parseExcludeParam(s string) []pgtype.UUID` — splits on `,`, parses each
UUID, silently drops malformed entries. Returns nil for empty input.
## 7. Frontend (queue refresh at 80%)
`web/src/lib/player/store.svelte.ts`:
- New `radioSeedId` `$state<string | null>` set inside `playRadio()`.
- New `$effect` watching `(player.currentIndex + 1) / player.queue.length`:
- Fires when `radioSeedId` is set, queue is non-empty, ratio ≥ 0.8, and
no refresh in-flight.
- Calls `/api/radio?seed_track=<radioSeedId>&exclude=<queue.map(t=>t.id).join(',')>`.
- On success: appends `response.tracks.slice(1)` (drops the seed at index 0).
- On failure: logs + clears in-flight flag (next track-advance can retry).
- New `appendToQueue(tracks)` helper — pushes onto `player.queue` without
changing `currentIndex`.
- `radioSeedId = null` when user enqueues from non-radio paths (`playQueue`,
`enqueueTrack`, `enqueueTracks`) so the auto-refresh doesn't fire on
manually-built queues.
`RadioResponse` JSON shape unchanged. `TrackRef[]` array works for both
initial calls and refresh calls.
## 8. Test plan
### 8.1 Backend pure tests
`internal/recommendation/score_test.go` extensions:
- `TestScore_SimilarityScore_PerfectMatch_AddsWeightedTerm``1.0 × 2.0 = +2.0` over baseline
- `TestScore_SimilarityScore_HalfMatch``0.5 × 2.0 = +1.0`
- `TestScore_SimilarityScore_Zero_NoEffect` — random/serendipity tracks score same as M3 baseline
### 8.2 Backend integration tests
`internal/recommendation/candidates_v2_test.go` (new):
- `TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes`
- `TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute` (artist score × 0.5 verified)
- `TestLoadCandidatesFromSimilarity_TagOverlapContributes` (jaccard score)
- `TestLoadCandidatesFromSimilarity_LikesOverlapContributes` (0.6 constant)
- `TestLoadCandidatesFromSimilarity_RandomFillToTargetSize` (empty similarity tables → pool ≥ 60)
- `TestLoadCandidatesFromSimilarity_ExcludeListRespected`
- `TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded`
- `TestLoadCandidatesFromSimilarity_DedupTakesMaxScore` (LB 0.85 beats tag 0.4)
- `TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded`
- `TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError`
### 8.3 Backend HTTP tests
`internal/api/radio_test.go` extensions:
- `TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher` (deterministic via fixed RNG)
- `TestHandleRadio_ExcludeParam_FiltersOut`
- `TestHandleRadio_ExcludeParam_MalformedSkipped`
- `TestHandleRadio_FallbackToM3OnSimilarityError` (inject fault)
### 8.4 Frontend tests
`web/src/lib/player/store.test.ts` extensions:
- `radio refresh fires at 80% queue consumption` (5-track queue at index 3)
- `radio refresh appends new tracks (excluding seed)` (queue 5 → 9 after 5-track response)
- `radio refresh does NOT double-fire` (in-flight guard)
- `radio refresh resets when user enqueues from non-radio source`
- `radio refresh below threshold` (5-track queue at index 2 → no refresh)
### 8.5 Coverage targets
- `internal/recommendation` post-M4c: ≥ 80% (currently 73%)
- `internal/api/radio.go`: ≥ 75%
- Web `player/store`: ≥ 80% on the new refresh logic
### 8.6 Manual end-to-end gate (closes M4)
After deploy + M4b worker has filled `track_similarity` for ≥10 played tracks:
1. Click radio from a played track — queue should differ noticeably from M3
2. Listen through ~80% of the queue — observe queue length increase as
auto-refresh fires
3. `psql -c "SELECT count(*) FROM track_similarity"` shows non-trivial data
4. Subjectively: radio quality should feel meaningfully better than M3's
baseline. **This is the closing gate for M4.**
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | 4-source pool composition + random fill | Cross-source diversity; sparse-fallback is automatic via random fill |
| 2 | Always augment with random to floor of 60 | Never returns empty radio; serendipity built in; degrades gracefully on sparse libraries |
| 3 | New `SimilarityScore × SimilarityWeight` term in M3 Score() | Wastes the LB scores otherwise; consistent with the M3 multi-input pattern |
| 4 | No lazy LB fetch | Keeps radio handler synchronous; M4b worker + augmentation cover the gap |
| 5 | `?exclude=...` query param for queue refresh | Stateless, simple, no new server state |
| 6 | Per-source K + score: hardcoded for v1 | YAGNI; expose later if telemetry warrants |
| 7 | Fallback to M3 LoadCandidates on similarity-pool error | Defense in depth for the central radio surface |
## 10. Backwards compatibility
- New SQL query alongside existing `LoadRadioCandidates`; M3 callers
unaffected.
- M3 `LoadCandidates` retained for the fallback path and direct test usage.
- `Score()` signature unchanged; new fields are zero-defaulted so existing
zero-value `ScoringInputs{}`/`ScoringWeights{}` constructions produce M3
scores.
- `/api/radio` request shape extended (adds optional `?exclude=`); existing
callers that don't pass it work identically.
- `RadioResponse` shape unchanged.
- No schema migration — relies on M4b's `track_similarity` /
`artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`.
## 11. M4 closure
After this slice merges, M4 is complete:
- M4a (PR #26) — outbound LB scrobble worker
- M4b (PR #27) — inbound LB similarity ingest
- M4c (this) — radio similarity-driven candidate pool + 80% queue refresh
Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in
library). Out-of-library LB-returned tracks (currently filtered out by the
in-library JOIN) become the natural input for M5's "would you like to add
this?" workflow.
@@ -1,345 +0,0 @@
# M5a — Lidarr connection + search/add proxy + admin shell
> **Status:** Draft for review · 2026-04-29
>
> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). M5 was decomposed into three slices during brainstorming on 2026-04-29:
>
> - **M5a (this spec)** — Lidarr connection + search/add + admin shell. Foundation; ships first.
> - **M5b** — Quarantine workflow (per-user soft-hide, admin resolution UI). Depends on M5a only for the admin shell.
> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance). Depends on M5a's `lidarr_requests` table and add path.
>
> Each ships as its own PR with its own brainstorm/spec/plan cycle.
## 1. Goal
Connect Minstrel to a household Lidarr instance, give every user a search-and-request workflow at `/discover`, and give admins a moderation queue at `/admin/requests`. Approved requests fire Lidarr adds synchronously; a background reconciler matches the resulting downloaded tracks back to the originating request when the next library scan picks them up.
This slice does NOT introduce quarantine, soft-hide, or radio suggested-additions — those are M5b and M5c.
## 2. Goals and non-goals
### Goals
- Operator can connect, configure, test, and disconnect a Lidarr instance from `/admin/integrations` without editing YAML or restarting the server.
- Any authenticated user can search Lidarr at artist / album / track granularity from `/discover`.
- Any authenticated user can submit an add request, which gets persisted to `lidarr_requests` with status `pending`.
- Admin can review pending requests at `/admin/requests`, approve (with optional per-request override of quality profile / root folder) or reject (with optional note).
- Approved requests trigger a synchronous Lidarr add and a library scan.
- A background reconciler worker matches `approved` requests to newly scanned tracks and transitions them to `completed`.
- Hard route gating on `/admin/*` — non-admin users redirected before any admin content loads.
### Non-goals (this slice)
- Quarantine workflow, soft-hide on tracks, admin "delete via Lidarr" path. → M5b.
- Radio "suggested additions" surfacing out-of-library similar tracks. → M5c.
- Webhook ingestion from Lidarr (push notifications on download complete). → optional follow-up; the polling reconciler is sufficient for v1.
- "Pending too long" failure detection / requestor notification on stalled adds. → open question, deferred.
- Self-service password reset, OIDC, or any other identity work. → orthogonal.
- Per-user Lidarr accounts. Lidarr is a single household instance.
## 3. Architecture
### New Go packages
- **`internal/lidarr/`** — HTTP client for Lidarr's v1 API. Mirrors `internal/scrobble/listenbrainz/` shape: `Client` struct with `BaseURL`, `APIKey`, `HTTP` fields. Methods: `LookupArtist(ctx, query)`, `LookupAlbum(ctx, query)`, `LookupTrack(ctx, query)`, `AddArtist(ctx, params)`, `AddAlbum(ctx, params)`, `ListQualityProfiles(ctx)`, `ListRootFolders(ctx)`, `Ping(ctx)`. Returns typed structs; never leaks raw JSON to callers.
- **`internal/lidarrconfig/`** — singleton config service. Reads/writes the `lidarr_config` row, exposes `Get(ctx) (*Config, error)` returning a typed struct, `Save(ctx, *Config) error`. The `Get` method also handles the "config not yet set" case by returning a zero-value `Config{Enabled: false}` so callers can branch cleanly.
- **`internal/lidarrrequests/`** — request lifecycle service:
- `Service``Create(ctx, userID, params)`, `ListPending(ctx)`, `ListByStatus(ctx, status)`, `ListForUser(ctx, userID)`, `Approve(ctx, requestID, adminID, overrides)`, `Reject(ctx, requestID, adminID, notes)`, `Cancel(ctx, requestID, userID)`. `Approve` calls `lidarr.Client.Add*` synchronously and triggers a library scan via the existing scanner package.
- `Reconciler` — background worker analogous to `internal/similarity.Worker`. `Run(ctx)` loop with `tick` interval (default 5 min) calls `tickOnce(ctx)`, which:
1. SELECTs `lidarr_requests WHERE status = 'approved'` with row limits.
2. For each, joins against `tracks`/`albums`/`artists` by MBID hierarchy.
3. Transitions matched rows to `completed`, sets `matched_*_id`, `completed_at`.
### Wiring
- `cmd/minstrel/main.go` gains a third worker spin-up (alongside the scrobble and similarity workers). The reconciler skips its work when `lidarr_config.enabled = false`.
- New handler files: `internal/api/lidarr.go` (search proxy), `internal/api/requests.go` (user-facing endpoints), `internal/api/admin/lidarr.go` (config CRUD + profiles/folders lookups), `internal/api/admin/requests.go` (approval queue).
- New middleware: `RequireAdmin` — checks the user resolved by `RequireUser` has `is_admin = true`; 403s with `{"error":"not_authorized"}` otherwise. Mounted on the `/api/admin/*` route group.
### Data flow — happy path
1. User opens `/discover`, types query, SPA hits `GET /api/lidarr/search?q=…&kind=artist|album|track`.
2. Handler invokes the appropriate `lidarr.Client.Lookup*`, normalizes the response, returns JSON.
3. User clicks "Request" → `POST /api/requests` with `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`.
4. Handler validates the kind→fields invariant, creates a `lidarr_requests` row with status `pending`, returns 201.
5. Admin opens `/admin/requests`, SPA hits `GET /api/admin/requests?status=pending`.
6. Admin clicks "Approve" → `POST /api/admin/requests/:id/approve` with optional `{quality_profile_id, root_folder_path}`.
7. Handler snapshots the chosen values into the row, calls `lidarr.Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. Returns 200 (or surfaces Lidarr error in 4xx/5xx).
8. Reconciler worker on next tick (≤5 min) sees the `approved` row, joins against `tracks`, finds the new track, transitions to `completed` with `matched_track_id` set.
9. Requester's `/requests` page shows status `completed` with a "Listen" link to the now-playable track.
### SPA route gating
- `/admin/*` route group has a `+layout.svelte` (or `+layout.ts`) guard: if `currentUser.is_admin === false`, calls `goto('/')` before child routes load. Page-level gate, not content gating — the operator's instruction.
- Same pattern as the existing auth gate; small extension of the existing layout machinery.
## 4. Schema
Migration **0010_lidarr** in two files (`up.sql`, `down.sql`).
### `lidarr_config` (singleton)
```sql
CREATE TABLE lidarr_config (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled boolean NOT NULL DEFAULT false,
base_url text,
api_key text,
default_quality_profile_id int,
default_root_folder_path text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO lidarr_config (id, enabled) VALUES (1, false);
```
The `CHECK (id = 1)` plus seed row enforces "exactly one row, ever." The Settings UI shows "Connect Lidarr" instead of "Lidarr is connected" when `enabled=false` or `base_url IS NULL`.
### `lidarr_requests`
```sql
CREATE TYPE lidarr_request_status AS ENUM (
'pending', 'approved', 'rejected', 'completed', 'failed'
);
CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track');
CREATE TABLE lidarr_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status lidarr_request_status NOT NULL DEFAULT 'pending',
kind lidarr_request_kind NOT NULL,
lidarr_artist_mbid text NOT NULL,
lidarr_album_mbid text,
lidarr_track_mbid text,
artist_name text NOT NULL,
album_title text,
track_title text,
quality_profile_id int,
root_folder_path text,
decided_at timestamptz,
decided_by uuid REFERENCES users(id) ON DELETE SET NULL,
notes text,
completed_at timestamptz,
matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL,
matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id);
CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status);
CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid);
CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid)
WHERE lidarr_album_mbid IS NOT NULL;
```
**Shape notes:**
- Three matched-* FKs (one per kind) instead of polymorphic — clean SQL, `ON DELETE SET NULL` keeps the historical audit even if the matched track gets removed later.
- Track-kind requests still set `lidarr_album_mbid` (because that's what Lidarr actually adds); `lidarr_track_mbid` is preserved for "I requested this song specifically" display.
- `quality_profile_id` and `root_folder_path` are NULL until decision time. When admin approves, the override (or the config default) gets snapshotted into the row.
- `failed` is reserved in the enum for a future reconciler timeout (e.g., "approved >7 days ago, no match"), but no reconciler logic transitions to `failed` in this slice. It's a placeholder for an obvious follow-up.
**Indexes rationale:**
- `user_id``/requests` page scan ("show my requests").
- `status``WHERE status='pending'` for admin queue, `WHERE status='approved'` for reconciler.
- `lidarr_artist_mbid` / `lidarr_album_mbid` — reconciler joins against `tracks`, `albums` by MBID.
### Down migration
Drops in reverse: indexes → table → enum types → singleton row deletion (table goes anyway).
## 5. API surface
All endpoints under `/api/*`, JSON request/response, `{error: "<code>", message: "<human>"}` envelope on errors. `/api/admin/*` group passes through `RequireAdmin` middleware (403 with `not_authorized` for non-admin tokens).
### User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/lidarr/search?q=&kind=artist\|album\|track` | Proxies Lidarr lookup. Returns normalized list `[{mbid, name|title, secondary_text, image_url, in_library: bool, requested: bool}]`. The `in_library` and `requested` fields are computed server-side: `in_library` joins against `artists`/`albums`/`tracks` by MBID; `requested` is true when ANY user has a non-`rejected`, non-`failed` `lidarr_requests` row with the same MBID — covers `pending`, `approved`, and `completed`. (`completed` in the DB but `in_library=false` in the response would only happen briefly between Lidarr add and library scan; both flags can be true together — UI prefers `in_library` for that case.) Returns `503 lidarr_disabled` if `lidarr_config.enabled=false`. |
| `POST` | `/api/requests` | Create a request. Body: `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`. Server validates kind→required-fields invariant. Returns `201 {request}`. |
| `GET` | `/api/requests` | List the caller's own requests (any status). Ordered by `requested_at desc`. Pagination: `?limit=&before=` cursor. |
| `GET` | `/api/requests/:id` | Single request detail. 404 if not caller's own and caller is not admin. |
| `DELETE` | `/api/requests/:id` | Cancel a still-pending request the caller created. 409 `request_not_pending` if status != `pending`. |
### Admin-only
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/admin/lidarr/config` | Returns current config. `api_key` masked as `"***"` when set, `null` when unset. |
| `PUT` | `/api/admin/lidarr/config` | Body: `{base_url, api_key, default_quality_profile_id, default_root_folder_path, enabled}`. `api_key`: empty string = leave saved value unchanged; non-empty = update. URL validated. |
| `POST` | `/api/admin/lidarr/test` | Body: `{base_url?, api_key?}`. Each field independently falls back to the saved value when absent or empty. Calls `Client.Ping`. Always returns 200 with envelope `{ok: true, version: "..."}` or `{ok: false, error: "..."}` — never an HTTP-level error envelope, so the SPA can render results uniformly. |
| `GET` | `/api/admin/lidarr/quality-profiles` | Proxies Lidarr's quality profile list — populates the Settings dropdown. |
| `GET` | `/api/admin/lidarr/root-folders` | Proxies Lidarr's root folder list — populates the Settings dropdown. |
| `GET` | `/api/admin/requests?status=pending\|approved\|rejected\|completed\|failed&limit=` | Admin's queue view. Default `status=pending`. |
| `POST` | `/api/admin/requests/:id/approve` | Body: `{quality_profile_id?, root_folder_path?}` (override fields; absent = use config default). Snapshots chosen values, calls `Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. |
| `POST` | `/api/admin/requests/:id/reject` | Body: `{notes?}`. Sets status `rejected`, records `decided_*` and `notes`. |
### Error codes
`lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `lidarr_lookup_failed`, `mbid_required`, `request_not_pending`, `request_not_found`, `not_authorized`.
## 6. UI surfaces
All four screens land at the FabledSword design system bar (memory: `project_design_system.md`). Tokens are referenced by name; concrete values live in the design-system memory. Mockups produced during brainstorming live in `.superpowers/brainstorm/<session>/content/` (gitignored) — `discover-fs-v2.html`, `admin-integrations.html`, `admin-requests.html`, `user-requests.html`.
### `/discover` (user-facing)
Search input at top, kind tabs (Artists / Albums / Tracks), card grid of results. Card state derives from the search response's `in_library` and `requested` flags:
- **Kept** (`in_library=true`) — wins over `requested`. Disabled ghost button "In library", "Kept" pill in the badge slot (forest-teal at 15% opacity bg).
- **Requested** (`in_library=false && requested=true`) — disabled ghost button "Requested", subtitle line shows "awaiting review" for pending requests, "downloading" for approved.
- **Requestable** (`in_library=false && requested=false`) — Moss `Request` button with plus icon.
Card layout discipline:
- Card body is `display: flex; flex-direction: column`. Inside, a `.text` block has `min-height` reserving title + meta + badge-row even when fields are absent. Actions block uses `margin-top: auto` so the button always anchors to the bottom of the card.
- `.badge-row` reserves 22px regardless of badge presence — title sits at the same Y across cards.
- Grid `align-items: stretch` keeps cards on the same row equal-height.
Implementation: `<DiscoverResultCard>` Svelte component with props `{kind, artistName, albumTitle?, trackTitle?, imageUrl?, state: 'requestable'|'kept'|'requested', onRequest}`. Reused for any future "list of music things" surface.
Track-kind result requests open a confirmation modal: "Requesting *Track X* will add the album *Album Y*. Continue?" Confirm = Moss, Cancel = Bronze. Disclosure is explicit, not silent.
### `/admin` shell + sidebar
`/admin/*` routes share `+layout.svelte`:
- Role gate: `if (!currentUser.is_admin) goto('/')` before child routes load.
- 220px sidebar with Iron card surface. Active item: 12% accent-tinted background, 2px forest-teal left strip ("you are here").
- Nav items (this slice): Overview · **Integrations** · **Requests** · Quarantine (placeholder for M5b) · Users (future) · Library (future).
Lucide icons at 16px, 1px stroke. Sidebar text: Vellum default, Parchment on active.
### `/admin/integrations`
Lidarr panel (single section in this slice; designed to host more integrations later):
- Header status pill: "Lidarr · connected" (Moss-tinted) when `enabled && reachable`, "unset" (Pewter ghost) otherwise.
- Form rows: Base URL · API key (masked) · Default quality profile (dropdown) · Default root folder (dropdown).
- Action row: **Save changes** = Moss, **Test connection** = Pewter ghost, **Disconnect** = Oxblood + trash icon (right-aligned, separated).
- Inputs on Obsidian (inset feel), 0.5px Pewter borders, 8px radius, focus = `box-shadow: 0 0 0 2px var(--fs-accent)` (no layout shift).
A second placeholder section for "MusicBrainz overrides" with status `unset` foreshadows the panel's role as the integration hub. Not implemented in this slice.
### `/admin/requests`
Tabbed list (Pending / Approved / Completed / Rejected). Tab counts as accent-tinted pills. Default tab `Pending` with badge showing count.
Row anatomy: 56px album-art square (Slate fallback when Lidarr returns no cover) · meta-row with kind pill + "requested by alice · 2h ago" small caps · title in Parchment · meta in Vellum · action cluster: **Override** (Pewter ghost, opens modal) → **Approve** (Moss + check icon) → **Reject** (Bronze + ✕ icon).
Override modal: collapsed-by-default override of `quality_profile_id` and `root_folder_path` for the specific approval. Most approvals click "Approve" without opening this.
Track-kind row's meta line spells out "Approving will add the album *Geogaddi*" — explicit disclosure of the album-promotion behavior.
### `/requests` (user's own)
Single panel listing the caller's requests, ordered by `requested_at desc`. Row anatomy mirrors `/admin/requests` but action cluster is reduced:
- **Pending** → Cancel (Pewter ghost + ✕ icon).
- **Approved** → no actions, "Approved · downloading" status pill (Info-tinted).
- **Completed** → "Listen" link in forest-teal (the page's only brand-moment), navigates to the matched track.
- **Rejected** → no actions, admin's note rendered as Vellum meta when present.
Status pills use the doc's semantic palette (Warning · Info · Moss/Success · Error). Voice rule applied: "Kept" instead of "Completed", "Set aside" instead of "Rejected", "Awaiting review" instead of "Pending review."
## 7. Error handling
### Lidarr unreachable / auth-failed
- `Client.*` methods return typed errors: `lidarr.ErrUnreachable`, `lidarr.ErrAuthFailed`, `lidarr.ErrLookupFailed`.
- Search proxy translates to `503 lidarr_unreachable` or `401 lidarr_auth_failed` — the SPA shows a callout: "Lidarr is unreachable right now. Try again, or check Settings → Integrations." (Voice rule: this is an error/waiting moment, gets the flavored register.)
- Admin approval handler same pattern: returns the error to admin so they can retry without losing the request. The request stays `pending` if the Lidarr call fails — never advances to `approved` without confirmation Lidarr accepted the add.
### Test connection from Settings
- `POST /api/admin/lidarr/test` always returns 200 with `{ok: bool, error?: string, version?: string}` — never an error envelope. Lets the SPA always render the result without parsing HTTP-level errors.
### Reconciler
- Worker errors logged at `WARN`, never propagated. A failing tick doesn't stop the worker — next tick retries.
- If `lidarr_config.enabled = false`, worker silently no-ops each tick.
- Postgres unavailability is a global concern; reconciler errors with the same backoff pattern as `internal/similarity.Worker`.
### Form validation
- Settings: URL must parse, API key must be non-empty if `enabled=true`. Quality profile + root folder must be present and known to Lidarr (validated against `Client.ListQualityProfiles` / `ListRootFolders`).
- Request creation: kind→required-MBID-fields invariant enforced server-side. SPA validates client-side first to avoid round-trips.
## 8. Testing
### Unit tests
- `internal/lidarr/` — table-driven request/response parsing tests against canned fixtures (real Lidarr response samples committed under `internal/lidarr/testdata/`). Covers happy path + auth-failure + 5xx + bad-JSON for each method.
- `internal/lidarrconfig/``Get` returns sensible zero-value when row says `enabled=false`; `Save` updates `updated_at`.
- `internal/lidarrrequests.Service` — pure-logic tests: kind→required-fields validation, status-transition validation (can only approve `pending`, can only cancel `pending`, etc.).
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- Reconciler — seeds an `approved` request + a matching track row, runs `tickOnce`, asserts status transitions to `completed` with correct `matched_*_id`. Covers:
- artist-kind matched by `lidarr_artist_mbid` against `artists.mbid`
- album-kind matched by `lidarr_album_mbid` against `albums.mbid`
- track-kind matched by `lidarr_album_mbid` (track-promoted) against `albums.mbid`
- no match (no transition)
- already-completed row not re-processed
- `lidarr_config.enabled=false` short-circuits to no-op
### HTTP tests (handler level)
- `internal/api/lidarr.go` — search proxy with stubbed `Client`: 200 happy path, 503 disabled, 503 unreachable, 401 auth-failed.
- `internal/api/requests.go` — create with valid + invalid kind/MBID combinations; list-mine returns only caller's rows; cancel pending vs cancel non-pending; cross-user 404.
- `internal/api/admin/lidarr.go` — config GET masks api_key; PUT empty-string preserves api_key; test-connection always returns 200 envelope.
- `internal/api/admin/requests.go` — approve fires Lidarr stub, transitions row, captures defaults from config; approve with override snapshots override values; reject without notes works; non-admin 403 across the board.
### Frontend tests (vitest)
- `<DiscoverResultCard>` — renders three states correctly; calls `onRequest` only in requestable state; reserved-slot CSS verified by computed-style assertion (badge-row min-height, button anchored).
- `/discover` page — debounce search input; renders results from store; transitions state on request submit; track-kind opens confirmation modal; modal Confirm calls API, modal Cancel does not.
- `/admin/requests` page — tab switch refetches; approve fires API and removes row from pending; override modal returns chosen values to approve handler; admin-only redirect verified at layout level.
- `/admin/integrations` panel — empty-state copy; test-connection updates status pill; Disconnect requires confirmation.
- `/requests` page — status pills render with correct semantic class; Cancel works on pending; "Listen" link only renders on completed rows.
### Coverage target
- `internal/lidarr/` ≥ 80%
- `internal/lidarrrequests/` ≥ 80%
- `internal/api/lidarr.go`, `internal/api/requests.go`, `internal/api/admin/*` — handler coverage measured combined ≥ 70% (matches current api package threshold)
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Decompose M5 into M5a / M5b / M5c | Matches the M4 cadence; smaller PRs, faster review, clearer scope per slice |
| 2 | Permissions: search-all, add-admin (request queue) | Operator's call — "browse-and-suggest workflow lets non-trusted household members participate without giving them library-write access" |
| 3 | Config storage: DB-only, Settings UI as the entry point | Operator's product principle — "no one wants to configure yamls; this is a finished product, not a project" (memory: `project_product_not_project.md`) |
| 4 | Settings shape: dedicated `/admin/*` route group with hard route gate | Per-app product surface for admin actions; redirect at layout-level, never load admin content for non-admin (operator's instruction) |
| 5 | Search UX: standalone `/discover` route (option B), not inline-in-search | Operator preference — explicit "I want to add music" surface, separate from local-library search |
| 6 | Granularity: artist + album + track | Operator preference — track-kind resolves to album-kind under the hood (Lidarr's monitor unit is album), explicit modal disclosure rather than silent expansion |
| 7 | Lifecycle detection: library scan as source of truth | Reuses existing scanner; reconciler is a 5-min worker; webhook is a clean follow-up if latency matters |
| 8 | Quality profile / root folder: default + per-add override | Default covers >90% of approvals; override is the escape hatch. Modal is collapsed-by-default |
| 9 | Approve fires Lidarr synchronously, reconcile asynchronously (Approach 1) | Admin gets immediate "Lidarr accepted/rejected" feedback; reconciliation has to be async because downloads take minutes-to-hours |
| 10 | New `RequireAdmin` middleware on `/api/admin/*` route group | Centralized auth check; SPA route gate is UX, server middleware is the security boundary |
| 11 | UI lands at FabledSword design system bar (memory: `project_design_system.md`) | Operator's quality bar — "no more scaffolding-feel UI" (memory: `project_ui_quality.md`); accent only for brand-moments, Moss/Bronze/Oxblood for actions |
| 12 | Track-kind disclosure modal | Lidarr can't fetch a single track without its album; explicit "this will add the album" beats silently expanding the request |
| 13 | `lidarr_requests.failed` status reserved but not transitioned in this slice | Foreshadows a "stalled approval" timeout follow-up; not v1 |
## 10. Out of scope (this slice)
Tracked in the M5 milestone for later sub-plans:
- **Quarantine workflow** — `lidarr_quarantine` table, soft-hide on tracks, admin "delete via Lidarr" path, `/admin/quarantine` page. → M5b.
- **Radio suggested-additions** — `/api/radio` response includes a separate field for out-of-library MBIDs from `track_similarity`; SPA shows "Would you add these?" affordance with inline request submission. → M5c.
- **Lidarr webhook ingestion** — push notifications on download complete; near-real-time status updates on `/requests`. → cheap follow-up after M5c.
- **Failed-request timeout** — reconciler transitions long-stuck `approved` requests to `failed` with operator-facing diagnostics. → operational tightening; not v1.
- **Admin requestor notifications** — toast/badge when a user logs in if their request was approved/completed/rejected. → polish, slot into Fable #349 (UI polish pass) or earlier if it becomes friction.
## 11. Open questions
- **Album cover art proxying** — Lidarr returns image URLs that point at MusicBrainz/Cover Art Archive. The SPA could fetch directly (extra origins, CORS), or Minstrel could proxy them through `/api/cover-art?lidarr=...`. **Decision deferred to plan time** — start with direct fetch, add proxy if CORS bites.
- **Search debounce / cache** — Lidarr's lookup endpoint is the rate-limit-sensitive one. SPA debounce of 250ms + 60s server-side LRU cache on `(query, kind)` is the conservative starting point. Tunable.
- **`failed` status promotion** — at what time threshold does `approved``failed`? Suggest 7 days, but no logic for it ships in this slice.
@@ -1,389 +0,0 @@
# M5b — Quarantine workflow + admin resolution UI
> **Status:** Draft for review · 2026-04-30
>
> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). Decomposition recap from the M5a spec:
>
> - **M5a** — Lidarr connection + search/add + admin shell. Shipped on `dev`.
> - **M5b (this spec)** — Quarantine workflow (per-user soft-hide of tracks, admin resolution UI).
> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance).
>
> Each ships as its own PR with its own brainstorm/spec/plan cycle.
## 1. Goal
Authenticated users can flag any local track as broken with a reason and optional notes. A flagged track disappears from that user's library, search, browse, and `/api/radio` responses — but appears on a dedicated `/library/hidden` page where they can review and un-hide. Admins see an aggregated queue at `/admin/quarantine` (one row per track with reason distribution + per-user details + inline playback) and resolve each row by clearing the reports, deleting the local file, or telling Lidarr to remove the parent album with import-list exclusion.
The dominant user mental model is **data quality**: "this track is a bad rip / wrong file / wrong tags / duplicate." Personal preference framing is out of scope.
## 2. Goals and non-goals
### Goals
- Authenticated user can flag any track with reason ∈ {`bad_rip`, `wrong_file`, `wrong_tags`, `duplicate`, `other`} plus optional notes.
- Trigger affordance is a `<TrackMenu>` overflow (kebab) on every track row and on the now-playing player bar — sibling to `<LikeButton>` but one-click-removed to prevent miss-clicks.
- Quarantined tracks are hidden from that user's `/api/albums/:id`, `/api/artists/:id`, library track lists, search, `/api/radio`, and contextual radio. They remain visible only on `/library/hidden`.
- `/library/hidden` page lists the user's own quarantines with un-hide affordance.
- Admin queue at `/admin/quarantine` aggregates by track, shows reason distribution + count, expandable per-user reports, inline playback, and the three resolution actions.
- Admin resolution actions: **Resolve** (clear the row), **Delete file** (rm file from disk + tracks row + clear), **Delete via Lidarr** (call Lidarr `DELETE /api/v1/album/{id}?deleteFiles=true&addImportListExclusion=true`, cascade Minstrel rows, clear).
- Admin actions write to a `lidarr_quarantine_actions` audit log with snapshot fields so the log stays readable after the underlying tracks/albums are deleted.
- Subsonic API (`/rest/*`) stays unfiltered — quarantine is `/api/*`-only.
### Non-goals (this slice)
- Per-album / per-artist quarantine. Tracks only.
- Quarantine on shared playlists, queues, or Subsonic clients.
- Auto-resolve rules ("if 3 users flag, auto-quarantine globally"). Admin always decides.
- Notifying users when their report is acted on (toast / email). → polish slot.
- Bulk admin operations (multi-select + apply). → revisit if queue grows beyond what one-by-one handles.
## 3. Architecture
### New Go packages
- **`internal/lidarrquarantine/`** — `Service` owning the quarantine lifecycle:
- `Flag(ctx, userID, trackID, reason, notes)` — upsert a `lidarr_quarantine` row.
- `Unflag(ctx, userID, trackID)` — remove the caller's row.
- `ListMine(ctx, userID)` — caller's own quarantines (drives `/library/hidden`).
- `ListAdminQueue(ctx)` — aggregated by track. Returns `[]AdminQueueRow{TrackID, TrackTitle, ArtistName, AlbumTitle, AlbumID, LidarrAlbumMBID, ReportCount, ReasonCounts, LatestAt, Reports []UserReport}`.
- `Resolve(ctx, trackID, adminID)` — delete all per-user rows for the track + write audit row.
- `DeleteFile(ctx, trackID, adminID)` — call `library.DeleteTrackFile` then Resolve.
- `DeleteViaLidarr(ctx, trackID, adminID)` — look up the parent album in Lidarr by MBID, call `Client.DeleteAlbum(id, deleteFiles=true, addImportListExclusion=true)`, remove Minstrel rows for all tracks of that album, clear all related quarantine rows, write audit row.
### Lidarr client additions (`internal/lidarr`)
- `LookupArtistByMBID(ctx, mbid) (LidarrArtist, error)``GET /api/v1/artist?mbId=…`.
- `LookupAlbumByMBID(ctx, mbid) (LidarrAlbum, error)``GET /api/v1/album?foreignAlbumId=…`.
- `DeleteAlbum(ctx, lidarrAlbumID, deleteFiles bool, addImportListExclusion bool) error``DELETE /api/v1/album/{id}?deleteFiles=...&addImportListExclusion=...`. M5b admin path always passes both `true`.
Returns the same typed errors the existing client uses: `ErrUnreachable`, `ErrAuthFailed`, `ErrLookupFailed`, plus `ErrNotFound` for the lookup methods when no row matches.
### Library deletion (`internal/library`)
- New `DeleteTrackFile(ctx, trackID) error` — removes the file from disk and the row from `tracks`. Album/artist rows stay (other tracks may reference them). The reconciler's MBID lookup will simply find no match next pass; M5a's reconcile is unaffected.
### Soft-hide enforcement
Every endpoint that returns track lists in user-context joins against `lidarr_quarantine`:
```sql
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $userID AND q.track_id = tracks.id
)
```
Affected queries (modified, not new):
- `GetAlbumDetail` (track list filter)
- `GetArtistDetail` / library artist views (transitively, via track filter)
- `Search` (track facet)
- Library track-list endpoints
- Radio generator + contextual radio endpoints + similarity-driven autoplay
`/rest/*` Subsonic queries are NOT modified — Subsonic clients see the unfiltered library.
### Wiring
- `cmd/minstrel/main.go` constructs `lidarrquarantine.Service` and injects into `internal/api/handlers`. No new background worker.
- New handler files: `internal/api/quarantine.go` (user-facing), `internal/api/admin_quarantine.go` (admin endpoints).
- Reuses M5a's `RequireAdmin` middleware — `/api/admin/quarantine/*` mounts on the existing admin route group.
### SPA additions
- `<TrackMenu>` Svelte component — kebab icon button + dropdown menu. Renders a single "Flag this track…" item for M5b; designed for future actions.
- `<FlagPopover>` Svelte component — opens from `<TrackMenu>` with the reason `<select>` + notes textarea + Cancel/Flag buttons. Pre-fills if the user has an existing flag (re-flag is upsert).
- `/library/hidden` page — caller's quarantines with un-hide affordance.
- `/admin/quarantine` page — aggregated admin queue with the three resolution actions, inline playback, expandable per-user reports.
- `Shell.svelte` — add `Hidden` to the main nav (visible to all auth'd users).
- `AdminSidebar.svelte` — promote `Quarantine` from placeholder to real link.
- `<TrackRow>` and `<PlayerBar>` — mount `<TrackMenu>` next to `<LikeButton>`.
### Data flow — happy paths
**User flag → soft-hide:**
1. User clicks the `<TrackMenu>` kebab on a track row → "Flag this track…" → popover opens.
2. User picks reason, optionally types notes, clicks Flag → SPA `POST /api/quarantine {track_id, reason, notes?}`.
3. Server upserts `lidarr_quarantine` row, returns 201.
4. SPA optimistically removes the row from the visible list and toasts "Hidden — review on the Hidden tab."
5. Subsequent reads from this user join the quarantine table and silently exclude the track.
**Admin Resolve:**
1. Admin opens `/admin/quarantine`, sees aggregated row, clicks Resolve.
2. SPA `POST /api/admin/quarantine/:track_id/resolve`.
3. Service deletes all per-user rows for the track in a single statement, writes audit row, returns 200 with `{action_id, affected_users}`.
4. SPA removes the row from the queue and toasts the count cleared.
**Admin Delete file:**
1. Admin clicks Delete file → modal-confirm.
2. SPA `POST /api/admin/quarantine/:track_id/delete-file`.
3. Service calls `library.DeleteTrackFile` (rm + DELETE FROM tracks), then deletes per-user rows, writes audit row.
4. SPA removes the row.
**Admin Delete via Lidarr:**
1. Admin clicks Delete via Lidarr → typed-confirm modal ("DELETE").
2. SPA `POST /api/admin/quarantine/:track_id/delete-via-lidarr`.
3. Service:
- Looks up the track's parent album in the local DB (gets `lidarr_album_mbid`).
- 404 with `album_mbid_missing` if the track has no MBID.
- Calls `Client.LookupAlbumByMBID` to translate MBID → Lidarr-internal album ID.
- Calls `Client.DeleteAlbum(id, true, true)`. **No partial state**: if Lidarr fails, returns the typed error; nothing in Minstrel changes; admin retries.
- On Lidarr success, deletes Minstrel rows for all tracks of the parent album, clears related quarantine rows, writes audit row with `affected_users` count + `lidarr_album_mbid`.
4. SPA removes the row, toasts "Removed — Lidarr will not redownload."
## 4. Schema — migration `0011_lidarr_quarantine`
### `lidarr_quarantine` (per-user complaints)
```sql
CREATE TYPE lidarr_quarantine_reason AS ENUM (
'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other'
);
CREATE TABLE lidarr_quarantine (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
reason lidarr_quarantine_reason NOT NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id);
CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC);
```
### `lidarr_quarantine_actions` (admin audit log)
```sql
CREATE TYPE lidarr_quarantine_action AS ENUM (
'resolved', 'deleted_file', 'deleted_via_lidarr'
);
CREATE TABLE lidarr_quarantine_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
track_id uuid NOT NULL, -- not FK: track may be gone
track_title text NOT NULL, -- snapshot
artist_name text NOT NULL, -- snapshot
album_title text, -- snapshot
action lidarr_quarantine_action NOT NULL,
admin_id uuid REFERENCES users(id) ON DELETE SET NULL,
lidarr_album_mbid text, -- non-null on deleted_via_lidarr
affected_users int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id);
CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC);
```
**Shape notes:**
- `(user_id, track_id)` PK mirrors the `general_likes` pattern. Re-flagging upserts (replaces reason/notes).
- `lidarr_quarantine_actions.track_id` deliberately is NOT a foreign key — `deleted_via_lidarr` removes the track row. Snapshot text columns keep the audit log readable post-delete.
- `affected_users` lets the admin see "delete-via-Lidarr at 2026-05-02 affected 4 users" after the fact.
- `lidarr_album_mbid` is non-null only on `deleted_via_lidarr`; gives recovery context if the operator later wants to re-add the album in Lidarr.
### Down migration
Drops in reverse order: indexes → tables → enum types.
## 5. API surface
All endpoints under `/api/*`, JSON, `{error: {code, message}}` envelope on errors. `/api/admin/*` passes through `RequireAdmin` (M5a).
### User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
| `POST` | `/api/quarantine` | Body: `{track_id, reason, notes?}`. Upserts a `lidarr_quarantine` row for the caller. Returns `201 {track_id, reason, notes, created_at}`. 400 `bad_reason` if reason is not in the enum. 404 `track_not_found` if track_id doesn't exist. |
| `DELETE` | `/api/quarantine/:track_id` | Removes the caller's row. 204 on success. 404 `quarantine_not_found` if no row exists for this user+track. |
| `GET` | `/api/quarantine/mine` | Returns the caller's quarantined tracks with full track detail (joined). Used by `/library/hidden`. Ordered by `created_at DESC`. |
### Admin-only
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/admin/quarantine` | Aggregated queue, one row per track. Each row: `{track_id, track_title, artist_name, album_title, album_id, lidarr_album_mbid?, report_count, reason_counts: {bad_rip:N,...}, latest_at, reports: [{user_id, username, reason, notes, created_at}]}`. Ordered by `latest_at DESC`. |
| `POST` | `/api/admin/quarantine/:track_id/resolve` | Clears all `lidarr_quarantine` rows for the track. Writes audit row. Returns `200 {action_id, affected_users}`. |
| `POST` | `/api/admin/quarantine/:track_id/delete-file` | Deletes file from disk + `tracks` row, then clears all quarantine rows, writes audit row. Returns `200 {action_id, affected_users}`. 404 `track_not_found` if already gone. 500 `file_delete_failed` on OS error. |
| `POST` | `/api/admin/quarantine/:track_id/delete-via-lidarr` | Calls Lidarr DELETE on the parent album with `deleteFiles=true&addImportListExclusion=true`, then removes Minstrel rows for all tracks of that album, clears related quarantine rows, writes audit row. Returns `200 {action_id, affected_users, deleted_track_count}`. 503 `lidarr_disabled`/`lidarr_unreachable`/`lidarr_auth_failed`. 404 `album_mbid_missing` if track has no resolvable album MBID. 502 `lidarr_album_lookup_failed` if Lidarr returns no album for the MBID. |
| `GET` | `/api/admin/quarantine/actions?limit=` | Recent admin actions log for audit/recovery. Default `limit=50`, max 200. Ordered by `created_at DESC`. |
### Modified read endpoints (soft-hide enforcement)
Existing endpoints get a quarantine join when called with an authenticated user context:
- `GET /api/albums/:id`
- `GET /api/artists/:id`
- `GET /api/library` and friends
- `GET /api/search` (track facet)
- `GET /api/radio` and contextual radio endpoints
The filter is the `WHERE NOT EXISTS` clause described in §3. `/rest/*` (Subsonic) stays unchanged.
### Error codes added
`bad_reason`, `quarantine_not_found`, `track_not_found`, `album_mbid_missing`, `lidarr_album_lookup_failed`, `file_delete_failed`. Reuses M5a's: `lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `not_authorized`.
## 6. UI surfaces
All against the FabledSword design tokens established in M5a. Voice rule: sentence case, "understated mythic" register on errors and empty states.
### `<TrackMenu>` — overflow menu component
Replaces the originally-considered standalone Flag button. Mounted in:
- `TrackRow.svelte` — sibling to `<LikeButton>` in the row's right-cluster.
- `PlayerBar.svelte` — next to the like button in the player's right cluster.
Layout: Lucide `MoreHorizontal` (or `MoreVertical` — finalize during implementation) at 16px / 1px stroke, `text-text-muted` default, `text-text-primary` on hover. Click toggles a small dropdown anchored to the button. Click-outside and Escape both close.
For M5b the menu has a single item: **Flag this track…** (Lucide `Flag` icon + sentence-case label). The component takes a `track: TrackRef` prop and is structured for future items (Add to queue, Add to playlist, View album, etc.).
### `<FlagPopover>` — flagging UI
Opens from `<TrackMenu>`. NOT a full modal — a small popover anchored near the trigger so the track context stays visible.
Contents:
- Header: "Flag this track as broken" (Inter 13/500).
- Reason `<select>`: "Bad rip", "Wrong file", "Wrong tags", "Duplicate", "Other".
- Notes `<textarea>` (optional, placeholder "What's wrong with it? (optional)"). 200-char soft limit.
- Actions: Cancel (Pewter ghost) · Flag (Bronze + Lucide `Flag` icon).
If the user already has a quarantine on this track, the popover pre-fills the saved reason/notes and the action button reads "Update flag." Re-submitting upserts.
On submit: optimistic hide of the track row + toast "Hidden — review on the Hidden tab." Failure rolls back the optimistic hide and shows an error toast.
### `/library/hidden` (user-facing)
New nav item under the existing Library section in `Shell.svelte`. Position: between Liked and Search (defer to plan time if a different position reads better).
Page shows the user's quarantined tracks ordered by `created_at DESC`. Row anatomy mirrors `/requests` for visual consistency:
- 56px album art square (Slate fallback).
- Pills: kind ("Track") + reason (`bad_rip` etc., accent-tint).
- Title (Parchment) + meta line "by Artist · Album · flagged 2d ago".
- Notes (Vellum, italic) — only when present.
- Action: Un-hide (Pewter ghost + Lucide `RotateCcw` icon). One click — no confirmation. Optimistic remove.
Empty state: "Nothing hidden yet." (voice rule)
### `/admin/quarantine` (admin)
`AdminSidebar.svelte` — promote Quarantine from `placeholder: true` to a real link.
Page header: H2 "Quarantine" + count pill in `accent-tint` when `report_count > 0`.
Aggregated row (one per track):
- 56px album art (left).
- Title (Parchment) · meta: "artist · album · 3 reports — latest 4h ago".
- **Reason distribution row**: pills with `<count>× <reason>` (`2× bad_rip`, `1× wrong_tags`), accent-tint background.
- **Reports details** (collapsed by default): expand caret reveals per-user list with `username · reason · notes · 2h ago`.
- **Inline play button** (Lucide `Play`, accent-colored — qualifies as a brand moment per the design system Hybrid rule): plays via the existing `enqueueTrack` action so admin can verify the issue.
- Action cluster (right-aligned):
- Resolve — Pewter ghost + Lucide `RotateCcw` icon.
- Delete file — Bronze + Lucide `Trash2` icon. Modal-confirm.
- Delete via Lidarr — Oxblood + Lucide `Trash2` + `Cloud` icon. Typed-confirm modal ("DELETE", trimmed equality, matching the M5a Disconnect pattern).
Modal-confirm copy for Delete file: "Remove `<track title>` from disk and clear `<N>` reports? Lidarr may auto-redownload depending on its monitor settings." Cancel (Pewter ghost) · Delete (Bronze).
Typed-confirm copy for Delete via Lidarr: "This will tell Lidarr to remove `<album title>` (artist `<artist name>`) and add it to the import-list exclusion. Affects all `<N>` tracks on the album. Type `DELETE` to confirm." Cancel (Pewter ghost) · Delete (Oxblood, disabled until trimmed input equals "DELETE").
When Delete via Lidarr fails, the modal stays open with an inline error: "Couldn't reach Lidarr — try again, or check Settings → Integrations." Same understated-mythic register as the M5a toast.
When `lidarr_album_mbid` is missing on a row, the Delete-via-Lidarr button renders dimmed with title-attribute tooltip "Local-only track — no Lidarr album to remove." Defers operator confusion ("why doesn't this button work?") at minimal UX cost.
Empty state: "Nothing to triage right now." (voice rule)
## 7. Error handling
### Lidarr unreachable / auth-failed during admin actions
- `Client.LookupAlbumByMBID` and `Client.DeleteAlbum` return the same typed errors as M5a: `ErrUnreachable`, `ErrAuthFailed`, `ErrLookupFailed`, plus `ErrNotFound` for lookups.
- Admin handler maps to the same HTTP codes M5a established (503 + JSON envelope).
- Failure leaves Minstrel state untouched. The quarantine rows stay; admin retries. **No partial-state writes** — Minstrel deletion fires only after Lidarr DELETE confirms.
### Track has no `lidarr_album_mbid`
- Some legacy/imported tracks may have empty MBIDs. The Delete-via-Lidarr action 404s with `album_mbid_missing`. UI dims the button on those rows (see §6).
### File-deletion errors
- `library.DeleteTrackFile` failure (file already gone, permission error) returns the OS error wrapped. Admin handler maps to 500 with `file_delete_failed`. The quarantine row stays so admin can retry. **No partial state.**
### User-facing flag flow
- `POST /api/quarantine` is idempotent on (user_id, track_id) — re-flagging upserts. Errors: 400 `bad_reason`, 404 `track_not_found`. Optimistic SPA hide rolls back on failure with an error toast.
### Soft-hide query joins
- The `WHERE NOT EXISTS` filter is gated on user context. Subsonic clients (legacy API) skip the join entirely. If the join fails (DB error), the entire request fails — quarantine is an integrity boundary, not "best effort."
## 8. Testing
### Unit tests (no DB)
- `internal/lidarr/` — additions: table-driven tests for `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum`. Covers happy + auth-fail + 5xx + 404 + bad-JSON for each, against canned fixtures in `internal/lidarr/testdata/`.
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- `internal/lidarrquarantine.Service`:
- Flag (insert + upsert behaviors).
- Unflag (cleanup).
- ListMine.
- ListAdminQueue — verify aggregation: 3 users × 1 track = 1 admin row with `report_count=3`, correct reason distribution.
- Resolve — writes audit row, clears all per-user rows.
- DeleteFile — track row gone, quarantine cleared, audit written.
- DeleteViaLidarr — with stub Lidarr server: lookup returns album, DELETE called with both flags `true`, Minstrel rows for all album tracks removed, audit row written, `affected_users` count correct. Failure stub: nothing changes locally.
- Soft-hide enforcement — seed 2 users + 1 quarantined track on user A. Verify:
- User A's `GET /api/albums/:id` excludes the track.
- User B's includes it.
- Admin's `/api/admin/quarantine` shows it aggregated.
- Same shape for search and radio endpoints.
### HTTP tests (handler level)
- `internal/api/quarantine.go` — POST (with valid + invalid reasons), DELETE happy + not-found, GET mine.
- `internal/api/admin_quarantine.go` — GET aggregated queue shape, POST resolve / delete-file / delete-via-lidarr (with stub Lidarr), non-admin 403 across the board, action-log GET.
### Frontend tests (vitest)
- `<TrackMenu>` — opens on click, closes on outside-click + Escape, single "Flag this track…" item present, takes track prop.
- `<FlagPopover>` — submits with reason, optional notes; Cancel does not submit; pre-fills when re-flagging; "Update flag" button label when re-flagging.
- `/library/hidden` page — renders user's quarantines, un-hide removes row.
- `/admin/quarantine` page — aggregated rows render with reason distribution, expandable per-user list, Play button calls the player, Resolve/Delete-file/Delete-via-Lidarr each fire the correct API and remove the row on success. Modal-confirm gates Delete file. Typed-confirm gates Delete via Lidarr. Error states surface inline. Dimmed Delete-via-Lidarr button when MBID missing.
### Coverage targets
- `internal/lidarr/` (now extended) ≥ 80%.
- `internal/lidarrquarantine/` ≥ 80%.
- `internal/api/quarantine.go` + `internal/api/admin_quarantine.go` — handler coverage measured combined ≥ 70%.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Per-user soft-hide; admin sees aggregate | Fits the data-quality framing — user files complaint, admin treats as bug queue, but each user's hide stays personal until admin acts |
| 2 | Hide everywhere except `/library/hidden` | Strongest hide; matches "I never want to see this until admin fixes it"; `/library/hidden` keeps un-hide reachable |
| 3 | Subsonic API stays unfiltered | Per `project_subsonic_legacy` memory; new behavior is `/api/*`-only |
| 4 | Track-level only (no album/artist quarantine) | Matches M5a §10 framing; album-level flag is rare enough to not need a dedicated affordance |
| 5 | Admin actions: Resolve / Delete file / Delete via Lidarr | Three buckets covering false alarm, bad file, bad release. The §10 carve-out specifically named "delete via Lidarr" |
| 6 | Always `deleteFiles=true + addImportListExclusion=true` on Lidarr DELETE | Otherwise "Delete via Lidarr" doesn't actually prevent the same broken release from re-downloading |
| 7 | Audit log for admin actions, not for per-user flags | Per-user rows are conceptually transient (flag → unflag or resolve); admin destructive actions need recovery context |
| 8 | Trigger via `<TrackMenu>` overflow (kebab), not standalone Flag button | Prevents miss-clicks against `<LikeButton>`; gives a home for future track actions |
| 9 | Aggregated admin queue, not per-user feed | Admin acts on tracks, not on users; one row per track keeps the queue actionable |
| 10 | Reason fixed enum + optional notes | Aggregation gets actionable buckets; notes give the long tail an escape hatch |
| 11 | Inline Play button in admin queue is brand-moment accent | Per the design-system Hybrid rule — Minstrel-feature interaction (audition the issue) gets the accent |
| 12 | Flag widget is a popover, not a full modal | Keeps track context visible while flagging; smaller commitment than the M5a Add modal |
## 10. Out of scope (this slice)
- Album / artist quarantine.
- Bulk admin operations.
- Auto-resolve thresholds.
- User notifications when reports are acted on.
- Subsonic API quarantine honoring.
- "Delete via Lidarr" with finer-grained options (deleteFiles=false, exclusion=false). Always full-cascade in v1.
## 11. Open questions
- **Default position of `/library/hidden` in nav:** between Liked and Search? Or under a future Library submenu? — defer to plan time.
- **Track without `lidarr_album_mbid`:** UI dims the button with tooltip (current proposal) vs. hides it entirely — confirm during plan time. Leaning toward dimmed-with-tooltip so the operator understands why the action isn't available.
- **Reason "duplicate" follow-up:** the admin might want to know which other track is the duplicate. Out of scope here; potential M5b polish task or M5c overlap with similarity. — defer.
@@ -1,360 +0,0 @@
# M5c — Suggested additions on `/discover`
> **Status:** Draft for review · 2026-04-30
>
> **Sub-plan of:** M5 (Lidarr integration). Final slice of the M5 trilogy:
>
> - **M5a** — Lidarr connection + search/add + admin shell. Shipped on `dev`.
> - **M5b** — Quarantine workflow. Shipped on `dev`, in PR #30.
> - **M5c (this spec)** — Personalized artist suggestions surfaced on `/discover`.
>
> Note: this spec deliberately diverges from the M5a §10 carve-out, which described surfacing out-of-library MBIDs in `/api/radio` responses. Operator redirected during brainstorming: the suggestion surface is a dedicated recommendation feed on `/discover`, decoupled from radio. The §10 reference is preserved here for traceability.
## 1. Goal
Authenticated users land on `/discover` and see a "Suggested for you" feed by default — top-12 out-of-library artists ranked by per-user signal (likes + recency-decayed plays) projected through ListenBrainz artist-similarity. Each suggestion shows attribution ("Because you liked X, played Y, and played Z"), and one click fires an artist-kind add request through M5a's existing Lidarr-add path.
When the user types in the search input, the suggestion feed is replaced by Lidarr search results — M5a's existing behavior. When the input clears, suggestions return.
## 2. Goals and non-goals
### Goals
- Top-12 personalized artist suggestions on `/discover` when the search input is empty.
- Per-user ranking weighted by explicit likes (×5) plus implicit plays (count, recency-decayed by half-life ~30 days).
- Top-3 contributing seed artists named per suggestion: "Because you liked X, played Y, and played Z."
- One-click add via the existing M5a `POST /api/requests` artist-kind path.
- Suggestions hide automatically when the candidate is in-library or already in a non-terminal `lidarr_requests` row.
- The M4b similarity ingest worker is extended to persist unmatched similar-artist MBIDs that it currently discards. No new background worker.
### Non-goals (this slice)
- Album-level or track-level suggestions. Artist-only for v1; albums are a potential v2 layer once we see how artist suggestions feel in practice.
- Realtime invalidation on every like/play. The feed updates passively as user signals accumulate; no push, no immediate refresh.
- Cross-user collaborative filtering. The recommendation is the user's own seed set × ListenBrainz similarity — single-user data only.
- Pagination beyond top-12. Polish slot if the operator wants it later.
- Materialized aggregation of `play_events`. Documented as a v2 lever if on-demand performance ever becomes an issue.
- Cover art for out-of-library suggestions. v1 uses the Lucide `Disc3` fallback; a MusicBrainz Cover-Art-Archive lookup is a polish-pass slot.
- Anything in the `/api/radio` response. M5c is decoupled from radio.
## 3. Architecture
### Data flow
1. **M4b similarity worker (existing)** fetches similar-artists from ListenBrainz per played artist. Today it discards MBIDs that aren't in `artists`. M5c keeps that filter for the `artist_similarity` table but ALSO writes the discarded MBIDs to a new `artist_similarity_unmatched` table — same structure but with no FK on the candidate side.
2. **Suggestion query** (on demand at `/api/discover/suggestions`) joins the user's likes + plays against `artist_similarity_unmatched` via the seeds, computes per-candidate score, ranks top-N.
3. **SPA renders** the response on `/discover` when the search input is empty. Each card reuses the existing `<DiscoverResultCard>` with an extra `attribution` prop.
4. **One-click Request** fires the same M5a `POST /api/requests` flow used by the search-side `<DiscoverResultCard>` — no new add machinery.
### New Go components
- **`internal/recommendation/suggestions.go`** — exports a single `SuggestArtists(ctx, pool, userID, halfLifeDays, limit) ([]ArtistSuggestion, error)` function. Single CTE query (see §4). Returns ranked candidates with their top-3 attribution seeds.
- **`internal/api/suggestions.go`** — `GET /api/discover/suggestions` handler. Authenticated; no admin gate. Calls the recommendation service and resolves seed artist names via a single `GetArtistsByIDs` follow-up (≤36 distinct IDs across top-12 candidates × 3 seeds each).
### Modified Go components
- **`internal/similarity/worker.go`** — `upsertArtistSimilar` keeps the existing matched path and adds a parallel unmatched-persist loop that calls a new `UpsertArtistSimilarityUnmatched` query. Top-K cap mirrors the matched path.
- **`internal/db/queries/similarity.sql`** — extension to ingest the new table. Reads in §4.
### Frontend changes
- `web/src/routes/discover/+page.svelte` — when `debouncedQ === ''`, render the new `<SuggestionFeed>` subcomponent. Hide the kind tabs in this state. Existing search behavior unchanged when input is non-empty.
- `web/src/lib/components/DiscoverResultCard.svelte` — add an optional `attribution?: string` prop that renders an italic Vellum line below the title.
- `web/src/lib/api/suggestions.ts` (new) — `listSuggestions(limit?)` and `createSuggestionsQuery()` factory with `staleTime: 5 * 60_000`.
- `web/src/lib/api/queries.ts``qk.suggestions(limit)` query key.
- `web/src/lib/api/types.ts``ArtistSuggestion`, `SeedContribution` types.
### Wiring
No `cmd/minstrel/main.go` or `internal/server/server.go` changes. The new endpoint mounts on the existing authed route group; the similarity worker is already constructed.
## 4. Schema — migration `0012_artist_similarity_unmatched`
```sql
-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would
-- otherwise discard. Mirrors artist_similarity shape: same composite PK
-- with source, same (seed_id, score DESC) index, same source enum check.
-- The candidate side is text + name (no FK) — that's the whole point.
CREATE TABLE artist_similarity_unmatched (
seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
candidate_mbid text NOT NULL,
candidate_name text NOT NULL,
score DOUBLE PRECISION NOT NULL,
source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (seed_artist_id, candidate_mbid, source)
);
CREATE INDEX artist_similarity_unmatched_seed_score_idx
ON artist_similarity_unmatched (seed_artist_id, score DESC);
```
### Schema notes
- `seed_artist_id` cascades on artist delete — consistent with `artist_similarity`. Removes orphaned suggestion rows automatically when an operator deletes a seed artist.
- `candidate_mbid` deliberately has no FK and no unique constraint by itself; the same out-of-library MBID can appear for many seed artists, and that's the whole mechanism: aggregating contributions across the user's seeds is what produces the score.
- `(seed_artist_id, candidate_mbid, source)` PK gives the worker a clean upsert target while leaving room for future similarity sources.
- `(seed_artist_id, score DESC)` index satisfies the dominant query pattern: "for seed S, give me top-K unmatched candidates by score." This is what the suggestion query exploits inside its CTE join.
- Storage estimate at v1 scale: ~2k seed artists × ~100 unmatched candidates × ~150 bytes = **~30 MB**. Negligible.
### Down migration
```sql
DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx;
DROP TABLE IF EXISTS artist_similarity_unmatched;
```
### Suggestion query shape
```sql
-- name: SuggestArtistsForUser :many
WITH seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0)
AS signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
GROUP BY a.id
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,
seeds.artist_id AS seed_id,
seeds.signal * u.score AS contribution
FROM artist_similarity_unmatched u
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
)
SELECT candidate_mbid,
candidate_name,
SUM(contribution)::float AS total_score,
(array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids,
(array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions
FROM contributions
GROUP BY candidate_mbid, candidate_name
ORDER BY total_score DESC
LIMIT $3;
```
The handler then resolves the top-3 seed UUIDs to artist names via `GetArtistsByIDs` (one extra round-trip; cheap because there are at most 36 distinct seed IDs across top-12 candidates).
### `seeds` CTE rationale
- Likes contribute a flat `5.0` per liked artist — explicit user signal worth more than a single play.
- Plays contribute `Σ exp(-age_days / half_life)` summed across all play events for the user × artist. Recent plays carry close to 1.0; week-old plays ~0.79; 30-day-old plays ~0.37; 90-day-old plays ~0.05. The exponential decay matches what radio's `recencyDecay` already does conceptually (different polarity).
- `WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL` skips artists the user has neither liked nor played — they'd contribute zero signal anyway.
### Worker upsert query
```sql
-- name: UpsertArtistSimilarityUnmatched :exec
INSERT INTO artist_similarity_unmatched (seed_artist_id, candidate_mbid, candidate_name, score, source)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
candidate_name = EXCLUDED.candidate_name,
score = EXCLUDED.score,
fetched_at = now();
```
Idempotent on re-fetch; `candidate_name` and `score` get refreshed when ListenBrainz returns updated values.
## 5. API surface
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/discover/suggestions?limit=&half_life_days=` | Returns the caller's top-N artist suggestions. Defaults `limit=12` (capped at 50), `half_life_days=30`. Both query params are operator-tunable; the SPA only sends `limit` in v1. Returns `200 [{mbid, name, score, attribution: [{artist_id, name, contribution}]}]`. |
### Response type
```ts
type ArtistSuggestion = {
mbid: string;
name: string;
score: number;
attribution: SeedContribution[]; // top-3, ordered by contribution DESC
};
type SeedContribution = {
artist_id: string;
name: string;
contribution: number;
};
```
### Empty cases
- New user with no likes and no plays → `200 []`. Empty-state copy: "Listen to something or like an artist to start getting suggestions."
- All recommendations already in library or already requested → `200 []`. Same copy works (the operator's library exhaustively covers their taste).
### Error codes added
None. The endpoint is read-only and can only fail on internal DB error → `500 server_error`.
### Performance
- Single CTE query at request time + one follow-up `GetArtistsByIDs` lookup for attribution names. Sub-50ms end-to-end at household scale (a few thousand seeds × hundreds of candidates fits well within Postgres' happy path with the planned indexes).
- Frontend wraps the call in TanStack Query with `staleTime: 5 * 60_000` (5 minutes). Repeat visits within a session don't re-query.
### v2 lever (deferred)
If the per-request `seeds` CTE aggregation over `play_events` becomes the bottleneck at large-library scale (year-3 territory), materialize a per-user `artist_signal_cache` table refreshed daily. The suggestion query plugs into the cache instead of scanning `play_events`. No API surface change required.
## 6. UI surfaces
All against the FabledSword design tokens. Voice rule: sentence case, "understated mythic" register on errors and empty states.
### `/discover` page-level state machine
The page has these states (existing M5a states preserved unless noted):
| Search input | Header copy | Tabs | Content |
|---|---|---|---|
| Empty | **"Suggested for you"** + Vellum subtitle (new) | hidden (new) | Suggestion grid, top-12 (new) |
| Empty + zero suggestions | **"Suggested for you"** | hidden | Empty-state copy (new) |
| Non-empty | "Add music to the library" (existing) | visible (existing) | Lidarr search results (existing) |
Subtitle when suggestions are showing: "Out-of-library artists drawn from what you've liked and played."
Empty-state copy when no suggestions: "Listen to something or like an artist to start getting suggestions."
When the user starts typing, the suggestion feed swaps out for the search-results UI. When they clear the input, the suggestion feed comes back. TanStack `staleTime` keeps both responses cached so swaps within a session are instant.
### `<DiscoverResultCard>` extension
Add an optional `attribution?: string` prop. When set, renders below the title in italic Vellum 12px. Empty / undefined → no attribution row (existing search-result rendering unchanged).
Card layout (top to bottom) with attribution:
- Art square (1:1 aspect, Slate fallback with Lucide `Disc3`).
- **Artist name** in Parchment, font-medium 14px.
- **Attribution** (italic Vellum 12px) — only when prop is set.
- Reserved badge slot (22px min-height; empty for suggestions).
- Action button: Moss "Request" with Plus icon.
### Attribution copy format
The handler returns up to 3 contributors per suggestion, ordered by contribution. The SPA formats:
| Contributors | Format |
|---|---|
| 1 | "Because you liked X." or "Because you played X." |
| 2 | "Because you liked X and played Y." |
| 3 | "Because you liked X, played Y, and played Z." (Oxford comma) |
The verb ("liked"/"played") matches the dominant signal for each seed: if the user liked the artist, "liked"; otherwise "played." A single signal type per seed keeps the copy clean.
In v1 the seed names are plain text, not links. Wiring them to `/artists/{id}` is a polish-pass refinement (Fable #349).
### `<SuggestionFeed>` subcomponent
Owned by `/discover/+page.svelte`. ~50 lines. Responsibilities:
- Reads `createSuggestionsQuery()` for data.
- Manages the optimistic-requested `Set<string>` (mirrors the existing search-side machinery on the same page so suggestions and search results share the same optimistic state — a request from search hides the matching card from suggestions on next render too).
- Renders the empty state when `data.length === 0`.
- Maps suggestions → `<DiscoverResultCard>` with `state="requestable"` and the formatted `attribution` string.
Grid: same `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4` as the search-results grid for visual continuity.
## 7. Error handling
### Worker-side (similarity ingest extension)
- `UpsertArtistSimilarityUnmatched` per-row failure logged at WARN, not propagated. Mirrors the existing `UpsertArtistSimilarity` row-error policy.
- Missing `Name` field on a `SimilarArtist` LB response: skip that row at DEBUG (we can't render a suggestion without the name). Verify the LB client surface during T2 — extend the client if `Name` isn't already exposed.
- Existing rate-limit/network handling carries over (same client call, just persisting more of the response).
### Handler-side (`/api/discover/suggestions`)
- DB error on the CTE query → `500 server_error`. SPA renders `<ApiErrorBanner>`. User can retry, or clear the input to fall back to search.
- Empty result is NOT an error — `200 []` with the SPA empty-state copy.
- No Lidarr-disabled branch on the read path: the suggestion feed reads pre-computed similarity data and works even when Lidarr is disconnected. The Request button on each card surfaces the M5a `lidarr_disabled` error when clicked, if Lidarr isn't configured.
### Stale-data behavior
- An admin adding the artist between worker re-ingest and the user's next refresh: the suggestion-query's `WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = ...)` filters in-library candidates at query time, so staleness window is "until next page refresh," not "until next worker tick." Acceptable.
- A user requesting an artist between page renders: same — the `WHERE NOT EXISTS (... lidarr_requests ...)` filters at query time.
## 8. Testing
### Unit tests (no DB)
- Pure-helper tests if any scoring logic lands in Go (not the SQL CTE). Most logic is in SQL; expect minimal Go unit-test surface.
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- `internal/recommendation/suggestions_integration_test.go`:
- **TestSuggestArtists_LikesAndPlaysContributeToScore** — single user, 1 liked + 1 played seed both pointing at the same out-of-library candidate; verify combined contribution.
- **TestSuggestArtists_Top12Cap** — 30 candidates with descending scores; verify only top-12 returned, in order.
- **TestSuggestArtists_AttributionTopThree** — 5 contributing seeds for one candidate; verify `Attribution` has exactly the top-3 by contribution.
- **TestSuggestArtists_RecencyDecayDownweightsOldPlays** — two seeds with 1-day vs 90-day-old plays pointing at the same candidate; verify recent contributes more (exact ratio derives from `exp(-age/half_life)`).
- **TestSuggestArtists_FiltersInLibraryCandidates** — candidate that exists in `artists` is excluded from response.
- **TestSuggestArtists_FiltersAlreadyRequested** — non-terminal `lidarr_requests` row hides the candidate.
- **TestSuggestArtists_RejectedRequestStillShown** — `status='rejected'` does NOT hide the candidate (rejected requests are terminal; user can re-request after admin's reject).
- **TestSuggestArtists_EmptyForNewUser** — user with no likes and no plays returns `[]`.
### Worker integration test
- `internal/similarity/worker_test.go``TestUpsertArtistSimilar_PersistsUnmatchedToTable`: seed an `artists` table with 1 in-library artist; mock the LB client to return 5 similar-artists where 1 is in-library and 4 are not. Verify `artist_similarity` got 1 row and `artist_similarity_unmatched` got 4 rows.
### HTTP handler tests
- `internal/api/suggestions_test.go`:
- **TestSuggestions_HappyPath** — stub the recommendation service, verify JSON shape.
- **TestSuggestions_EmptyForNewUser** — `200 []`.
- **TestSuggestions_AttributionShape** — response includes `attribution` array with up to 3 entries.
### Frontend tests (vitest)
- `web/src/routes/discover/discover.test.ts` extension:
- **suggestion feed renders when input is empty** — mock `createSuggestionsQuery` to return 12 rows; verify 12 cards rendered, kind tabs hidden.
- **typing replaces feed with search** — input `"miles"` → search results visible, suggestions hidden.
- **clearing input restores feed**.
- **attribution copy renders correctly for 1/2/3 seeds**.
- **request button optimistically removes the card**.
- **empty state copy** when query returns `[]`.
### Coverage targets
- `internal/recommendation/` (new code only) ≥ 80%.
- `internal/similarity/` (worker extension) maintained at current level (≥ 80% combined per existing target).
- Combined Lidarr-suite (lidarr, lidarrconfig, lidarrrequests, lidarrquarantine, plus new suggestion code) stays ≥ 80% per the M5a/M5b cadence.
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Surface on `/discover` (default state, search-input-empty), not on `/api/radio` | Operator's redirection during brainstorming — recommendations should be a deliberate "I want to add music" surface, not a radio side-channel |
| 2 | Artist-only granularity for v1 | Cleanest mental model; matches Lidarr's monitor unit; album/track suggestions deferred to v2 |
| 3 | Recency-decayed plays + likes weighted 5× | Recency matches radio's existing decay model conceptually; 5× honors the M2 distinction between explicit (like) and implicit (play) signal |
| 4 | Top-3 contributing seeds named per suggestion | Operator framing was "you might like X because you liked Y, Z, **and W**" — multi-seed attribution is the whole point of the surface |
| 5 | Search-empty replaces initial-copy state with the feed; tabs hidden | Operator wanted suggestions front-and-center for users who don't know the feature exists; search is the explicit action |
| 6 | Top-12 fixed, no pagination | Score-vs-noise drops sharply past top-12; operator workflow ends with external search engine for actual decision |
| 7 | On-demand SQL + 5-minute SPA cache; no backend caching layer | Single CTE at household scale is sub-50ms; TanStack `staleTime` handles repeat visits; v2 materialization noted as a future lever if needed |
| 8 | New `artist_similarity_unmatched` table; extend M4b worker | Mirrors `artist_similarity` shape; worker already runs and discards these MBIDs today; persisting is a small addition |
| 9 | Reuse `<DiscoverResultCard>` with optional `attribution` prop | Cheaper than maintaining a parallel `<SuggestionCard>`; the attribution slot fits cleanly above the reserved badge row |
## 10. Out of scope (this slice)
- Album / track suggestions.
- Cross-user collaborative filtering ("users like you also liked X").
- Pagination / infinite scroll on the suggestion feed.
- Cover art for out-of-library candidates (would require MusicBrainz Cover-Art-Archive lookup).
- Linking attribution-seed names to `/artists/{id}` (polish-pass refinement).
- Materialized per-user signal aggregation. v2 lever if performance ever requires it.
- Tunable `half_life_days` exposed in the SPA. Backend accepts it, frontend always uses default.
## 11. Open questions
- **`SimilarArtist.Name` on the LB client.** Verify during T2 that the existing client surfaces the artist name alongside MBID. If not, extend the client (small, additive).
- **Cold-start duration after install.** Until the M4b worker has had a chance to run a few ticks against the user's listened-to artists, the unmatched table is small and suggestions will be sparse. Documented; not a v1 fix.
- **Cross-source diversity.** When `musicbrainz_tag` and `user_cooccurrence` source providers come online (post-M5), the same candidate MBID may appear from multiple sources with different scores. The `seeds → contributions` join already aggregates per `(seed, candidate)` regardless of source via the GROUP BY in the outer CTE — but ranking diversity (don't surface 12 candidates all sourced from one seed) is a polish-pass concern.
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
- platform: android
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
- platform: ios
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+81
View File
@@ -0,0 +1,81 @@
# Minstrel mobile client
Flutter (iOS + Android) sibling of the SvelteKit web SPA. v1 first
slice ships: scaffold, auth, library browse (home / artist / album),
likes, player with background audio + lock-screen controls.
## Setup
```bash
cd flutter_client
flutter pub get
./tool/sync_shared.sh # copy tokens / error-copy / placeholders from ../web
dart run tool/gen_tokens.dart # regen lib/theme/tokens.dart from shared/fabledsword.tokens.json
flutter run
```
`sync_shared.sh` is idempotent. CI runs it on every build; rerun locally
whenever `web/src/lib/styles/tokens.json`,
`web/src/lib/styles/error-copy.json`, or
`web/static/placeholders/album-fallback.svg` changes.
## Project layout
See `docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md`
section 3. Briefly:
- `lib/api/` — dio client, ApiError, error-copy loader, per-surface endpoints.
- `lib/auth/` — server URL screen, login screen, AuthController + secure storage.
- `lib/library/` — home, artist detail, album detail, providers, widgets.
- `lib/likes/` — LikeButton + optimistic toggle controller.
- `lib/player/` — audio handler, player provider, mini PlayerBar, NowPlaying.
- `lib/theme/` — FabledSword token Dart class (generated), ThemeExtension, ThemeData.
- `lib/shared/` — routing (go_router shell), version gate, connection error banner.
`shared/fabledsword.tokens.json` and `assets/error-copy.json` are
synced from web/. Don't edit them directly — edit `web/src/lib/styles/`
and re-run `./tool/sync_shared.sh`.
## Architecture
- **State:** Riverpod 2 (`AsyncValue`, `AsyncNotifier`, family providers).
- **HTTP:** dio 5 with a Bearer-auth interceptor; 401 clears the session
and the router redirects to login.
- **Audio:** just_audio wrapped by audio_service for background
playback + lock-screen / notification controls.
- **Auth:** Bearer tokens in flutter_secure_storage. Server already
supports both cookie (web SPA) and Bearer (`internal/auth/session.go`).
- **Routing:** go_router with a ShellRoute so the PlayerBar persists
across navigation. Cold launch flow: no server-url → `/server-url`,
url set / no token → `/login`, token present → `/home`.
## CI
`.forgejo/workflows/flutter.yml` runs on push to dev/main, tag pushes,
PR to main, and manual dispatch — path-filtered to `flutter_client/**`
plus the shared web inputs. Steps: sync, analyze, test, build APK.
Debug APKs upload as artifacts; release APKs attach to the Forgejo
release on tag.
## Versioning
`pubspec.yaml` `version` mirrors the server tag (e.g. server `v0.1.0`
→ Flutter `0.1.0+1`). The server's `/healthz` returns
`min_client_version`; old clients show an Update Required modal and
refuse to operate. Bump `internal/server/version.go` `MinClientVersion`
when a server-side change requires a paired client update.
## Distribution
- **Android:** APK on the Forgejo release page. No Play Store for v1.
- **iOS:** TestFlight on tag (manual upload v1, automate later).
## Out-of-scope for slice 1
Search, discover, requests, settings beyond server URL, admin,
listening history, playlists, offline cache (#357). Those land in
subsequent slices with their own brainstorm/spec/plan cycles. Per the
operator's M7 ordering decision (2026-05-02), slice 2+ pauses to ship
the missing web features first (#352 playlists, #362 theme toggle,
#363 queue UI, #364 listening history, etc.) before mirroring on
Flutter.
+12
View File
@@ -0,0 +1,12 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- lib/theme/tokens.dart # generated; allow magic numbers
linter:
rules:
avoid_print: true
prefer_const_constructors: true
sort_pub_dependencies: false
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.fabledsword.minstrel"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.fabledsword.minstrel"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,64 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application
android:label="minstrel"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.fabledsword.minstrel
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+48
View File
@@ -0,0 +1,48 @@
{
"unknown": "Something went wrong.",
"unauthenticated": "Your session has ended. Please sign in again.",
"auth_required": "You need to sign in to do that.",
"forbidden": "You don't have permission to do that.",
"not_authorized": "You don't have permission to do that.",
"invalid_credentials": "Wrong username or password.",
"wrong_password": "Current password is incorrect.",
"password_too_short": "Password must be at least 8 characters.",
"username_invalid": "That username isn't valid.",
"username_taken": "That username is already taken.",
"email_invalid": "Enter a valid email address.",
"email_taken": "That email is already in use.",
"invalid_token": "That link has expired or already been used. Request a new one.",
"invite_invalid": "That invite has expired or already been used.",
"invite_required": "An invite is required to register on this server.",
"last_admin": "Can't demote or delete the last admin.",
"no_email_on_file": "No email is associated with that account.",
"not_configured": "This integration isn't set up yet.",
"validation": "Some fields aren't valid. Check and try again.",
"missing_fields": "Required fields are missing.",
"missing_query": "Search needs at least one keyword.",
"invalid_id": "Invalid identifier.",
"invalid_body": "Couldn't read the request.",
"bad_request": "Invalid request.",
"bad_body": "Couldn't read the request.",
"bad_kind": "Invalid request type.",
"bad_paging": "Invalid page size or offset.",
"bad_reason": "Invalid quarantine reason.",
"mbid_required": "An MBID is required for this lookup.",
"system_playlist_readonly": "System playlists can't be edited directly.",
"connection_refused": "Couldn't reach the server. Check the URL and try again.",
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled": "Lidarr integration is not enabled.",
"lidarr_auth_failed": "Lidarr authentication failed.",
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.",
"lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing": "This track has no Lidarr album to remove.",
"request_not_pending": "This request is no longer pending.",
"request_not_found": "That request no longer exists.",
"track_not_found": "That track no longer exists.",
"album_not_found": "That album no longer exists.",
"artist_not_found": "That artist no longer exists.",
"playlist_not_found": "That playlist no longer exists.",
"user_not_found": "That user no longer exists."
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Album cover fallback. Used by AlbumCard, CompactTrackCard, and the
PlayerBar's onerror handler when an album has no cover_art_path. Colors
are FabledSword tokens hard-coded as hex (data-URL / static SVGs can't
resolve CSS variables): iron background to match the card surface, a
pewter border to give the placeholder a visible edge consistent with
real album art, and an ash-tone beamed-pair glyph centered inside.
Beamed-pair path is from SVG Repo (svgrepo.com), CC0; rescaled and
recolored from the original solid-black 800px source.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1E2228"/>
<rect x="6" y="6" width="500" height="500"
fill="none" stroke="#3F4651" stroke-width="10"/>
<g fill="#9C9A92" transform="translate(76.8 76.8) scale(0.7)">
<path d="M503.319,5.939c-5.506-4.705-12.783-6.767-19.958-5.635L169.555,49.852c-12.04,1.901-20.909,12.28-20.909,24.47v99.097 v156.903H99.097C44.455,330.323,0,371.073,0,421.161C0,471.25,44.455,512,99.097,512c54.642,0,99.097-40.75,99.097-90.839v-66.065 V194.588l264.258-41.725v136.169h-49.548c-54.642,0-99.097,40.75-99.097,90.839s44.455,90.839,99.097,90.839 S512,429.959,512,379.871v-66.065V123.871V24.774C512,17.529,508.827,10.646,503.319,5.939z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1,620 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Some files were not shown because too many files have changed in this diff Show More