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.
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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 — 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 && npm install && 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.
|
||||
@@ -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 M2–M5 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 M2–M5 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 27–30 (M2–M5). 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 M2–M5 (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, ¤tVec); 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 5–10 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 (0–1) |
|
||||
| `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; 60–100 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.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |